openvibers/openvibe.shared Commits
4.160.0: Feed v2, section icons, composer Options, navbar slots, the keyboard, attach (#368)
Changes
@@@ -1,5 +1,46 @@# Changelog++## 4.160.0 — 2026-11-09++++- **Feed v2: the menu's Feed is a real feed** (the owner, 11-09: "a real feed… you scroll through easy content… a filter++ system… clean, intuitive"). When Community's `/api/v1/feed` answers, the Feed tab is the composer, today's numbers, the++ live bar and one stream of cards by kind (live, clips and videos with a muted preview that plays only in view and never++ with reduced motion or data saver, pictures, posts, memes, games, compact updates), chips on top (For you, Live, Clips,++ Videos, Pics, Posts, Memes, Games, Updates), more as the end comes near, "You're all caught up" at the end; without++ the API, the wall and its sections stand as before.++++- **Every section heading has an icon, and sections stand apart** (the owner, 11-09: "these section titles should have an++ icon… look nicer and more consistent, separate things… a universal system"). The showcase kit's headings carry an icon++ tile (features: grid, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, questions, network,++ apps each have a default; `icon:` picks another); each section has a hairline and room above it.++- **More on OpenVibe** (was "Part of OpenVibe"): the related sites with their icons, a live "Happening on OpenVibe" strip++ (who is live and the sites' trending items, from Network's Frame, text only) when the page includes `sc.NETWORK_JS`,++ and "Every OpenVibe site →" to openvibe.network/sites.++++- **`openvibe-shared/ai-marks`: the AI makers' marks, one place** (the AI comparison plan, R3; the owner, 11-09: our++ hand-drawn family glyphs read as "what is it even supposed to be?"). `family(idOrName)` finds the maker from a model++ id, slug, family or provider name (16 makers: OpenAI, Anthropic, Google, Gemma, Meta, DeepSeek, Qwen, Mistral,++ Moonshot, Zhipu, MiniMax, Microsoft, xAI, NVIDIA, Cohere, Perplexity); `mark()` draws it monochrome in a theme tile,++ labelled with the maker's name; anyone else gets the neutral spark. Drawings from @lobehub/icons-static-svg (MIT),++ regenerated by `scripts/build-ai-marks.js`.++- **The composer's Options, the navbar's own buttons, the keyboard** (the owner, 11-09, on openvibe.codes: "too many++ options… hidden under a cog"; the sidebar button "to the left of the logo… the navbar SDK and API… other people can add++ icons"; the keyboard "takes up a lot of space"). `thread.composer({ options, examples })`: one Options button with the++ pickers behind it (a panel on a computer, a sheet on a phone with the page held still, a `<details>` without script),++ changed pickers as chips with ×, a dot on the button, and a placeholder that walks through examples.++ `OpenVibeNavbar.lead(spec)` and `.action(spec)`: a site's own buttons in the bar; a kit sidebar's toggle moves there on++ a phone. `html.ov-kb-open` and `--ov-kb` on every site; start pages fold `data-ovt-fold`; the Dock's phone sheet ends++ above the keyboard and no longer opens it by itself on a touch screen.++- **The Dock's pin is an upright pushpin** whose lines never cross (it read as a pin with a line through it).++- **The composer's attach, answers' actions and the quiet line** (the owner, 11-09, on Vibe: "the image button is broken…++ support all file types… a better file attach button… Copy and Try again should be actual icons… 'Vibe can get things++ wrong' should be under"): attach is one + button for every file type, and with `attach: { menu: true, library }` a++ menu of Files, Photos and videos, Take a photo (phones) and From your OpenVibe files; Copy and a site's actions are++ icon buttons named by their word (a tick when copied; Try again draws the retry arrows); the composer's note sits++ under the box.++- **A phone's drawer holds the page still** (kit.sidebar): the page behind cannot scroll and the drawer never hands its++ scroll on (the owner: a swipe on the drawer "scrolls the actual page").++## 4.159.0 — 2026-11-09- **Homes v2: `openvibe-shared/showcase-stage`** (plan `OpenVibe_Homes_v2_Plan_2026-11-09.md`; the owner, 11-09: "make the@@@ -11,8 +52,6 @@Server-rendered words and numbers; motion only adds, never inside an SVG's children, and stops for reduced motion andCalm. Its CSS (`CSS`) and starter (`MOTION_JS`) are separate, so homes that do not use it do not grow.--## Unreleased--- **The menu's You tab is a home screen** (owner, 11-09: "make it more like a phone"). `ov-home.js` (a sibling, loadedthe first time You or Feed opens): your Actor on top (its state and step, or one invitation), Recent (the last fiveplaces opened), then Home: your places in a grid, the most popular until you arrange it, with Tools (every tool with@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.159.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.160.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.159.0",++ "version": "4.160.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++// Generated by scripts/build-ai-marks.js from @lobehub/icons-static-svg 1.102.0 (MIT, © LobeHub,++// https://github.com/lobehub/lobe-icons). Do not edit: re-run the script. Each mark is its owner's trademark, used here to++// name the maker of a model.++module.exports = {++ "OpenAI": "<path d=\"M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z\"></path>",++ "Anthropic": "<path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\"></path>",++ "Google": "<path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\"></path>",++ "Gemma": "<path d=\"M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z\"></path>",++ "Meta": "<path d=\"M6.897 4c1.915 0 3.516.932 5.43 3.376l.282-.373c.19-.246.383-.484.58-.71l.313-.35C14.588 4.788 15.792 4 17.225 4c1.273 0 2.469.557 3.491 1.516l.218.213c1.73 1.765 2.917 4.71 3.053 8.026l.011.392.002.25c0 1.501-.28 2.759-.818 3.7l-.14.23-.108.153c-.301.42-.664.758-1.086 1.009l-.265.142-.087.04a3.493 3.493 0 01-.302.118 4.117 4.117 0 01-1.33.208c-.524 0-.996-.067-1.438-.215-.614-.204-1.163-.56-1.726-1.116l-.227-.235c-.753-.812-1.534-1.976-2.493-3.586l-1.43-2.41-.544-.895-1.766 3.13-.343.592C7.597 19.156 6.227 20 4.356 20c-1.21 0-2.205-.42-2.936-1.182l-.168-.184c-.484-.573-.837-1.311-1.043-2.189l-.067-.32a8.69 8.69 0 01-.136-1.288L0 14.468c.002-.745.06-1.49.174-2.23l.1-.573c.298-1.53.828-2.958 1.536-4.157l.209-.34c1.177-1.83 2.789-3.053 4.615-3.16L6.897 4zm-.033 2.615l-.201.01c-.83.083-1.606.673-2.252 1.577l-.138.199-.01.018c-.67 1.017-1.185 2.378-1.456 3.845l-.004.022a12.591 12.591 0 00-.207 2.254l.002.188c.004.18.017.36.04.54l.043.291c.092.503.257.908.486 1.208l.117.137c.303.323.698.492 1.17.492 1.1 0 1.796-.676 3.696-3.641l2.175-3.4.454-.701-.139-.198C9.11 7.3 8.084 6.616 6.864 6.616zm10.196-.552l-.176.007c-.635.048-1.223.359-1.82.933l-.196.198c-.439.462-.887 1.064-1.367 1.807l.266.398c.18.274.362.56.55.858l.293.475 1.396 2.335.695 1.114c.583.926 1.03 1.6 1.408 2.082l.213.262c.282.326.529.54.777.673l.102.05c.227.1.457.138.718.138.176.002.35-.023.518-.073.338-.104.61-.32.813-.637l.095-.163.077-.162c.194-.459.29-1.06.29-1.785l-.006-.449c-.08-2.871-.938-5.372-2.2-6.798l-.176-.189c-.67-.683-1.444-1.074-2.27-1.074z\"></path>",++ "DeepSeek": "<path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\"></path>",++ "Qwen": "<path d=\"M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z\"></path>",++ "Mistral": "<path clip-rule=\"evenodd\" d=\"M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z\"></path>",++ "Kimi": "<path d=\"M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z\"></path><path d=\"M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z\"></path>",++ "GLM": "<path d=\"M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z\"></path>",++ "MiniMax": "<path d=\"M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z\"></path>",++ "Microsoft": "<path d=\"M11.49 2H2v9.492h9.492V2h-.002z\"></path><path d=\"M22 2h-9.492v9.492H22V2z\"></path><path d=\"M11.49 12.508H2V22h9.492v-9.492h-.002z\"></path><path d=\"M22 12.508h-9.492V22H22v-9.492z\"></path>",++ "xAI": "<path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"></path>",++ "NVIDIA": "<path d=\"M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z\"></path>",++ "Cohere": "<path clip-rule=\"evenodd\" d=\"M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z\"></path><path clip-rule=\"evenodd\" d=\"M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z\"></path><path d=\"M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z\"></path>",++ "Perplexity": "<path d=\"M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z\"></path>"++};nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++/**++ * openvibe-shared/ai-marks — the AI makers' marks, one place for every site that lists models (Exchange, Report, Chat,++ * Bench, Design). The owner, 11-09, on Exchange's home: model-family glyphs we drew ourselves read as "what is it even++ * supposed to be?"; the AI comparison plan asks for the makers' recognisable marks, as every leaderboard shows them.++ *++ * const marks = require('openvibe-shared/ai-marks');++ * marks.family('deepseek/deepseek-chat') → 'DeepSeek' (a model id, a slug, a family or a provider name)++ * marks.mark('anthropic', { size: 24 }) → <span class="ov-aim ov-aim--anthropic" …><svg …>…</svg></span>++ * marks.mark('someone-new') → the neutral spark (never a letter in a circle, never another maker's mark)++ * marks.CSS the few rules (a tile in the theme's tokens), once per page++ *++ * Marks are monochrome in currentColor (they follow the theme); the maker's name is the label (label: false when the row++ * already names it). The drawings come from @lobehub/icons-static-svg (MIT; see ai-marks-data.js and NOTICE in it).++ */++const DATA = require('./ai-marks-data');++++const NAMES = {++ OpenAI: 'OpenAI', Anthropic: 'Anthropic', Google: 'Google', Gemma: 'Gemma', Meta: 'Meta', DeepSeek: 'DeepSeek', Qwen: 'Qwen',++ Mistral: 'Mistral AI', Kimi: 'Moonshot AI', GLM: 'Zhipu AI', MiniMax: 'MiniMax', Microsoft: 'Microsoft', xAI: 'xAI',++ NVIDIA: 'NVIDIA', Cohere: 'Cohere', Perplexity: 'Perplexity',++};++// Order matters: the more specific first (gemma before gemini/google; grok before x).++const RULES = [++ [/gemma/i, 'Gemma'], [/gemini|google|palm|bard/i, 'Google'], [/claude|anthropic/i, 'Anthropic'],++ [/deepseek/i, 'DeepSeek'], [/qwen|qwq|alibaba|tongyi/i, 'Qwen'], [/kimi|moonshot/i, 'Kimi'], [/glm|zhipu|z\.ai|chatglm/i, 'GLM'],++ [/minimax|abab/i, 'MiniMax'], [/llama|meta\b|^meta|facebook/i, 'Meta'], [/mistral|mixtral|codestral|ministral|magistral|pixtral/i, 'Mistral'],++ [/\bphi-?\d|microsoft|wizardlm/i, 'Microsoft'], [/grok|x-ai|\bxai\b/i, 'xAI'], [/nvidia|nemotron/i, 'NVIDIA'],++ [/cohere|command-?r|\baya\b/i, 'Cohere'], [/perplexity|sonar/i, 'Perplexity'],++ [/openai|gpt|\bo[134](-|\b)|chatgpt|davinci|whisper|dall-?e/i, 'OpenAI'],++];++const SPARK = '<path d="M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>';++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** The maker's family key for a model id, slug, family or provider name; null when none matches. */++function family(text) {++ const t = String(text || '').trim();++ if (!t) return null;++ const exact = Object.keys(DATA).find((k) => k.toLowerCase() === t.toLowerCase());++ if (exact) return exact;++ for (const [re, key] of RULES) if (re.test(t)) return key;++ return null;++}++++/** The mark as HTML: a tile with the maker's drawing (or the neutral spark), labelled with the maker's name. */++function mark(text, { size = 24, label = true, cls = '' } = {}) {++ const key = family(text);++ const px = Math.max(12, Math.min(96, Number(size) || 24));++ const name = key ? NAMES[key] : String(text || 'Other');++ const svg = `<svg viewBox="0 0 24 24" width="${Math.round(px * 0.62)}" height="${Math.round(px * 0.62)}" fill="currentColor" fill-rule="evenodd" aria-hidden="true" focusable="false">${key ? DATA[key] : SPARK}</svg>`;++ return `<span class="ov-aim ov-aim--${key ? key.toLowerCase() : 'other'}${cls ? ` ${esc(cls)}` : ''}" style="--ov-aim:${px}px"${label ? ` role="img" aria-label="${esc(name)}" title="${esc(name)}"` : ' aria-hidden="true"'}>${svg}</span>`;++}++++const CSS = '.ov-aim{display:inline-grid;place-items:center;flex:none;width:var(--ov-aim,24px);height:var(--ov-aim,24px);border-radius:calc(var(--ov-aim,24px) * .28);'++ + 'background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent);color:var(--text-primary,#e8ecf3);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.1))}'++ + '.ov-aim svg{display:block}.ov-aim--other{color:var(--accent,#3b82f6)}';++++module.exports = { family, mark, CSS, FAMILIES: Object.keys(DATA), NAMES };@@@ -269,7 +269,9 @@list.append(h('li', 'ovdk-wait', null));list.firstChild.innerHTML = '<span></span>';await pull(true);-- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ // A touch screen does not get the keyboard on opening (11-09, the owner: it "immediately opens the keyboard… you++ // can't see the chat input or what it did"): the answer and the box show first; a tap on the box types.++ if (!(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);}function mount(panel, c) {@@@ -35,7 +35,8 @@const G = {spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',-- pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',++ // An upright pushpin whose lines never cross (owner, 11-09: the old one had "a line going through it").++ pin: '<path d="M7.5 3.5h9M10 3.5v5.3L6.5 13h11L14 8.8V3.5M12 13v7.5"/>',close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',@@@ -101,7 +102,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };++ const DOCK_REV = { 'dock-vibe.js': '40a5f8e3970b', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -261,7 +262,7 @@.ovdk-err[hidden] { display: none; }.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }@media (max-width: 640px) {-- .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: 0; width: auto !important; height: auto !important; max-width: none; max-height: none;++ .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: var(--ov-kb, 0px); width: auto !important; height: auto !important; max-width: none; max-height: none;border: 0; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: none; transform-origin: 50% 100%; }.ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }.ovdk-head { cursor: default; }@@@ -698,7 +699,7 @@list.append(h('li', 'ovdk-wait', null));list.firstChild.innerHTML = '<span></span>';await pull(list, scroll, true);-- if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);++ if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);}/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */async function pull(list, scroll, first) {@@@ -22,7 +22,7 @@ const BROWSER = Object.freeze(['notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person-- 'presence.js', 'overlay.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person++ 'presence.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent@@@ -31,6 +31,8 @@function setSidebar(sb, on) {if (!sb) return;sb.classList.toggle('is-open', on);++ // The page behind a phone's drawer stays put (11-09, the owner: a swipe on the drawer "scrolls the actual page").++ document.documentElement.classList.toggle('ovk-drawer-open', !!on);const wrap = sb.closest('.ovk-with-sidebar') || document;const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]');if (scrim) scrim.hidden = !on;@@@ -434,3 +434,11 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++++/* A phone's drawer holds the page still: the page cannot scroll behind it, the drawer scrolls on its own and never hands++ the scroll on to the page, and the scrim takes no scroll gestures (11-09). */++@media (max-width: 760px) {++ html.ovk-drawer-open, html.ovk-drawer-open body { overflow: hidden; overscroll-behavior: none; }++ .ovk-sidebar { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }++ .ovk-sidebar-scrim { touch-action: none; overscroll-behavior: contain; }++}nnew file mode 100644@@@ -0,0 +1,67 @@++/**++ * nav-slots.js (openvibe-shared 4.160.0): a site's own buttons in the navbar, drawn here so the bar stays small. The++ * navbar loads this the first time a site calls OpenVibeNavbar.lead(spec) or .action(spec) and calls paint() after each++ * render. Known icons by name, or 24-unit path data (letters, numbers and path commands only: no markup from a site).++ */++(function (root) {++ 'use strict';++ const CSS = `.openvibe-navbar .ovnav-slot { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }++.openvibe-navbar .ovnav-slot:hover, .openvibe-navbar .ovnav-slot:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--text-primary, #e6edf7); }++.openvibe-navbar .ovnav-slot:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.openvibe-navbar .ovnav-slot:active { transform: scale(.9); }++.openvibe-navbar .ovnav-slot[aria-expanded="true"], .openvibe-navbar .ovnav-slot[aria-pressed="true"] { color: var(--accent-light, #60a5fa); background: var(--accent-glow, rgba(59,130,246,.14)); }++.openvibe-navbar .ovnav-slot svg { width: 22px; height: 22px; }++.openvibe-navbar .ovnav-slot b { position: absolute; top: -1px; right: -3px; min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff; font: 700 10px/16px Inter, system-ui, sans-serif; text-align: center; box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }++.openvibe-navbar .ovnav-slot b:empty { display: none; }++.openvibe-navbar .ovnav-lead { margin-left: -6px; animation: ovnav-slot-in .22s cubic-bezier(.2, .9, .3, 1) both; }++@keyframes ovnav-slot-in { from { opacity: 0; transform: translateX(-6px); } }++@media (prefers-reduced-motion: reduce) { .openvibe-navbar .ovnav-lead { animation: none; } }++@media (max-width: 400px) { .openvibe-navbar .ovnav-slot { width: 32px; height: 32px; } }++@media (min-width: 761px) { .openvibe-navbar .ovnav-slot.ovnav-phone-only { display: none; } }`;++ const SLOT_ICONS = {++ sidebar: 'M4 5.5h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1zM9.5 5.5v13',++ back: 'M15 5l-7 7 7 7',++ close: 'M6 6l12 12M18 6L6 18',++ plus: 'M12 5v14M5 12h14',++ search: 'M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM15.5 15.5L20 20',++ settings: 'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zM12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7',++ filter: 'M4 6h16M7 12h10M10 18h4',++ share: 'M12 15V4M8 8l4-4 4 4M5 13v6h14v-6',++ star: 'M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.7l5.9-.9z',++ info: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM12 11v5.5M12 7.6v.4',++ };++ const PATH_OK = /^[MmLlHhVvCcSsQqTtAaZz0-9 ,.\-]{1,2000}$/;++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ function html(spec, cls, id) {++ const label = esc(spec.label || 'Menu');++ const ctl = spec.sidebar || spec.controls;++ const state = (spec.sidebar ? ' data-ovk-sidebar-open' : '') + (ctl ? ` aria-controls="${esc(ctl)}" aria-expanded="${spec.expanded ? 'true' : 'false'}"` : spec.pressed != null ? ` aria-pressed="${spec.pressed ? 'true' : 'false'}"` : '');++ if (spec.phoneOnly) cls += ' ovnav-phone-only';++ const d = spec.path && PATH_OK.test(spec.path) ? spec.path : SLOT_ICONS[spec.icon] || SLOT_ICONS.info;++ const svg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;++ const badge = spec.badge != null ? `<b>${esc(String(spec.badge).slice(0, 3))}</b>` : '';++ const href = spec.href && /^(https?:\/\/|\/)/.test(String(spec.href)) ? spec.href : null;++ return href ? `<a class="ovnav-slot ${cls}" id="${esc(id)}" href="${esc(href)}" aria-label="${label}" title="${label}"${state}>${svg}${badge}</a>`++ : `<button type="button" class="ovnav-slot ${cls}" id="${esc(id)}" aria-label="${label}" title="${label}"${state}>${svg}${badge}</button>`;++ }++ /** Draw the lead button before the brand and the actions before the bell; clicks go to the site's handlers. */++ function paint(nav, lead, actions) {++ if (!nav || !nav.querySelector) return;++ if (!document.getElementById('ovnav-slots-styles')) { const st = document.createElement('style'); st.id = 'ovnav-slots-styles'; st.textContent = CSS; document.head.appendChild(st); }++ nav.querySelectorAll('.ovnav-slot').forEach((el) => el.remove());++ const brand = nav.querySelector('.openvibe-navbar-brand');++ if (lead && brand) brand.insertAdjacentHTML('afterbegin', html(lead, 'ovnav-lead', 'ovnav-lead'));++ const bell = nav.querySelector('#openvibe-bell-mount');++ if (bell && actions) for (const a of actions.values()) bell.insertAdjacentHTML('beforebegin', html(a, 'ovnav-act', `ovnav-act-${a.id}`));++ nav.__ovSlots = { lead, actions };++ if (nav.__ovSlotsBound) return;++ nav.__ovSlotsBound = true;++ nav.addEventListener('click', (e) => {++ const b = e.target && e.target.closest ? e.target.closest('.ovnav-slot') : null;++ if (!b || !nav.__ovSlots) return;++ const s = b.id === 'ovnav-lead' ? nav.__ovSlots.lead : nav.__ovSlots.actions.get(b.id.replace(/^ovnav-act-/, ''));++ if (s && typeof s.onClick === 'function') { if (!s.href) e.preventDefault(); try { s.onClick(e, b); } catch (err) { console.error(err); } }++ });++ }++ root.OpenVibeNavSlots = { paint, icons: Object.keys(SLOT_ICONS) };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -13,6 +13,9 @@const _runtimeMenu = { before: [], after: [] };let _runtimeLinks = null;let _navEl = null;++ // A site's own buttons (lead before the brand, actions before the bell): nav-slots.js.++ let _lead = null;++ const _actions = new Map();function injectStyles() {if (document.getElementById('openvibe-navbar-styles')) return;@@@ -547,7 +550,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '04ac8238e9ed', 'dock.js': '45215d0d60f4', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '267bf753169d' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '6c1578a7dfdf', 'dock.js': '4d29a2d5ecb4', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '8229ff459796', 'nav-slots.js': 'a5af310d62e0' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -913,6 +916,40 @@</div>`;}++ // The on-screen keyboard: html.ov-kb-open and --ov-kb (its height, against the tallest view seen without one).++ let _kbWatch = false;++ function watchKeyboard() {++ if (_kbWatch || typeof window === 'undefined' || !window.visualViewport || typeof document === 'undefined') return;++ _kbWatch = true;++ const vv = window.visualViewport; const html = document.documentElement;++ let raf = 0; let base = 0; let turned = typeof screen !== 'undefined' && screen.orientation ? screen.orientation.type : '';++ const typing = () => { const a = document.activeElement; return !!a && (a.isContentEditable || a.tagName === 'TEXTAREA' || (a.tagName === 'INPUT' && !/^(button|checkbox|radio|range|color|file|submit|reset|image|hidden)$/i.test(a.type || ''))); };++ const paint = () => {++ raf = 0;++ const o = typeof screen !== 'undefined' && screen.orientation ? screen.orientation.type : '';++ if (o !== turned) { turned = o; base = 0; }++ const t = typing();++ if (!t) base = Math.max(base, vv.height);++ const kb = Math.max(0, Math.round(Math.max(base, window.innerHeight) - vv.height - vv.offsetTop));++ const on = t && kb > 120;++ if (html.classList.contains('ov-kb-open') !== on) html.classList.toggle('ov-kb-open', on);++ html.style.setProperty('--ov-kb', `${on ? kb : 0}px`);++ };++ const later = () => { if (!raf) raf = requestAnimationFrame(paint); };++ vv.addEventListener('resize', later); vv.addEventListener('scroll', later);++ document.addEventListener('focusin', later); document.addEventListener('focusout', () => setTimeout(later, 60));++ later();++ }++++ // A site's buttons: nav-slots.js draws them, loaded on first use; each render paints them again.++ function paintSlots() {++ if (!_navEl || typeof document === 'undefined') return;++ if (root.OpenVibeNavSlots) { root.OpenVibeNavSlots.paint(_navEl, _lead, _actions); return; }++ if (!_lead && !_actions.size) return;++ if (document.getElementById('ov-slots-loader')) return;++ try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ }++ }++// The OpenVibe menu's button: nine dots as a diamond (the old launcher's, which the owner liked), the centre in the// accent. Rings: 0 the centre, 1 the edges, 2 the corners (the ripple's order; corners and centre make the ×).const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];@@@ -2189,6 +2226,8 @@document.body.prepend(nav);}_navEl = nav;++ paintSlots();++ watchKeyboard();attachAvatarFallbacks(nav);hookTop();loadWalletChips();@@@ -2552,6 +2591,20 @@* settles; a page that draws such a box later may call this). Returns how many it marked. */focusScrollers() { try { return focusScrollers(); } catch { return 0; } },++ /** A button before the brand: { icon | path, label, controls, sidebar, phoneOnly, pressed, href, onClick }; null removes it. README: Navbar slots. */++ lead(spec) {++ _lead = spec ? { ...spec } : null;++ paintSlots();++ return { set(patch) { if (_lead) { Object.assign(_lead, patch || {}); paintSlots(); } }, remove() { _lead = null; paintSlots(); } };++ },++ /** An icon button before the bell: lead()'s spec plus id and badge. */++ action(spec) {++ if (!spec || !spec.id || !/^[a-z0-9-]{1,40}$/.test(spec.id)) throw new Error('OpenVibeNavbar.action needs an id: a-z, 0-9 and -');++ _actions.set(spec.id, { ...spec });++ paintSlots();++ return { set(patch) { const a = _actions.get(spec.id); if (a) { Object.assign(a, patch || {}); paintSlots(); } }, remove() { _actions.delete(spec.id); paintSlots(); } };++ },++/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },askAgent(text) { askAgent(text); },@@@ -75,6 +75,7 @@background: var(--bg-secondary, #141a24); box-shadow: 0 12px 30px rgba(0,0,0,.35), inset 0 0 0 1px var(--border, rgba(255,255,255,.14)); transform: scale(1.08); }@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }.ovh-hint { margin: 2px 6px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }++.ovh-all[hidden], .ovh[hidden] { display: none !important; }.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }@@@ -578,7 +579,10 @@const draw = (j, append) => {if (!append) list.textContent = '';const items = (j && Array.isArray(j.items) ? j.items : []).filter((x) => x && typeof x.title === 'string' && safe(x.url));-- if (!items.length && !append) { list.append(h('p', 'ovh-note', 'Nothing new on the network yet today.')); return; }++ // Nothing to show: the section goes, not a line saying so (11-09, the owner: "why does it say show more if there's++ // nothing new").++ if (!items.length && !append) { el.hidden = true; more.hidden = true; return; }++ el.hidden = false;// Went live, back to back: one row of names.let lives = null;items.forEach((x) => {@@@ -612,5 +616,164 @@more.addEventListener('click', async () => { more.disabled = true; const j = await loadPulse(o, next); more.disabled = false; if (j) draw(j, true); });}-- root.OpenVibeHome = { home, wallTop, timeline, loadPulse, _defaultHome: defaultHome };++++ // ── Feed v2: a real feed (owner, 11-09: "a real feed… you scroll through easy content… sections or a filter system…++ // clean, intuitive"). Community's GET /api/v1/feed (plan "Feed v2"): one stream of cards by kind, chips on top, more as++ // you near the end. When the API is not there yet, the pulse wall above stands (timeline()).++ const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', 'Videos'], ['pic', 'Pics'], ['post', 'Posts'], ['meme', 'Memes'], ['game', 'Games'], ['update', 'Updates']];++ function feedUrl(o, kind, cursor) {++ const p = new URLSearchParams({ limit: '12' });++ if (kind) p.set('kind', kind);++ if (cursor) p.set('cursor', cursor);++ return `${o.communityBase || COMMUNITY}/api/v1/feed?${p}`;++ }++ function loadFeed(o, kind, cursor) {++ return fetch(feedUrl(o, kind, cursor), { credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {}) })++ .then((r) => (r.ok ? r.json() : null)).then((j) => (j && Array.isArray(j.items) ? j : null)).catch(() => null);++ }++ const still = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced' || !!(navigator.connection && navigator.connection.saveData); } catch { return false; } };++ const count = (n) => { const v = Number(n) || 0; return v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v); };++ let player = null; // one IntersectionObserver plays the preview in view and pauses the rest++ function watchPreview(box, c) {++ const media = c.media || {};++ const preview = safe(media.preview);++ if (!preview || still() || typeof IntersectionObserver !== 'function') return;++ player = player || new IntersectionObserver((es) => es.forEach((x) => {++ const v = x.target.querySelector('video');++ if (x.isIntersecting && x.intersectionRatio >= 0.6) {++ if (!v) { const nv = document.createElement('video'); nv.muted = true; nv.loop = true; nv.playsInline = true; nv.setAttribute('playsinline', ''); nv.preload = 'none'; nv.src = x.target.getAttribute('data-preview'); nv.className = 'ovf-video'; x.target.append(nv); nv.play().catch(() => {}); }++ else v.play().catch(() => {});++ } else if (v) v.pause();++ }), { threshold: [0, 0.6] });++ box.setAttribute('data-preview', preview);++ player.observe(box);++ }++ function feedCard(c) {++ const kind = String(c.kind || 'post');++ const url = safe(c.url);++ if (!url) return null;++ if (kind === 'update') {++ const a = h('a', 'ovf-update'); a.href = url;++ const ic = h('span', 'ovh-ic'); ic.append(siteIcon(c.site || 'ov', 20));++ const t = h('span', 'ovf-update-txt'); t.append(h('b', '', String(c.title || '').slice(0, 160)), h('small', '', [c.site_name || c.site, ago(c.at)].filter(Boolean).join(' · ')));++ a.append(ic, t); return a;++ }++ const a = h('a', `ovf-card ovf-${kind.replace(/[^a-z]/g, '')}`); a.href = url;++ const head = h('span', 'ovf-head');++ const face = h('span', 'ovf-face');++ const av = c.actor && safe(c.actor.avatar);++ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); } else face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase();++ const who = h('span', 'ovf-who');++ who.append(h('b', '', String((c.actor && c.actor.name) || c.site_name || '').slice(0, 60)));++ const meta = h('small', '');++ meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', ago(c.at)].filter(Boolean).join(' · ')}`));++ who.append(meta);++ head.append(face, who);++ if (kind === 'live') head.append(h('span', 'ovf-livechip', 'LIVE'));++ a.append(head);++ const media = c.media || {};++ const thumb = safe(media.thumb);++ if (thumb && /^https:/.test(thumb)) {++ const box = h('span', 'ovf-media');++ const r = Number(media.ratio) > 0 ? Math.max(0.8, Math.min(1.91, Number(media.ratio))) : (kind === 'pic' || kind === 'meme' ? 1 : 16 / 9);++ box.style.aspectRatio = String(r);++ const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);++ if (media.length) box.append(h('span', 'ovf-len', String(media.length).slice(0, 10)));++ if (kind === 'live' && c.counts && c.counts.viewers) box.append(h('span', 'ovf-len ovf-viewers', `${count(c.counts.viewers)} watching`));++ a.append(box);++ if (kind === 'clip' || kind === 'video' || kind === 'live') watchPreview(box, c);++ }++ if (c.title) a.append(h('b', 'ovf-title', String(c.title).slice(0, 200)));++ if (c.text && (kind === 'post' || !thumb)) a.append(h('p', 'ovf-text', String(c.text).slice(0, 500)));++ const n = c.counts || {};++ const bits = [n.views ? `${count(n.views)} views` : '', n.likes ? `♥ ${count(n.likes)}` : '', n.comments ? `${count(n.comments)} replies` : ''].filter(Boolean);++ if (bits.length) a.append(h('small', 'ovf-counts', bits.join(' · ')));++ return a;++ }++ function feed(el, o) {++ styles(); feedStyles();++ el.classList.add('ovh', 'ovf');++ el.setAttribute('data-sec', 'wall');++ el.textContent = '';++ const chips = h('div', 'ovf-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');++ const list = h('div', 'ovf-list');++ const sentinel = h('div', 'ovf-more');++ el.append(chips, list, sentinel);++ let kind = '', cursor = null, busy = false, done = false, seq = 0;++ const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovf-skel')); };++ const clearSkels = () => list.querySelectorAll('.ovf-skel').forEach((x) => x.remove());++ async function more(first) {++ if (busy || done) return;++ busy = true; const my = ++seq;++ if (first) { list.textContent = ''; skeletons(); } else skeletons();++ const j = await loadFeed(o, kind, cursor);++ if (my !== seq) return;++ clearSkels(); busy = false;++ if (!j) {++ if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall stands++ done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;++ }++ if (first && o.feedLive) o.feedLive();++ j.items.map(feedCard).filter(Boolean).forEach((c) => list.append(c));++ if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }++ cursor = j.next_cursor || null;++ done = !cursor;++ sentinel.textContent = '';++ if (done) sentinel.append(h('p', 'ovh-note ovf-caught', list.childNodes.length ? "You're all caught up." : 'Nothing here yet.'));++ }++ KINDS.forEach(([k, label]) => {++ const b = h('button', 'ovf-chip', label); b.type = 'button'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', k === kind ? 'true' : 'false');++ b.addEventListener('click', () => {++ if (k === kind) return;++ kind = k; cursor = null; done = false; busy = false;++ chips.querySelectorAll('.ovf-chip').forEach((x) => x.setAttribute('aria-selected', x === b ? 'true' : 'false'));++ more(true);++ });++ chips.append(b);++ });++ if (typeof IntersectionObserver === 'function') {++ const io = new IntersectionObserver((es) => { if (es.some((x) => x.isIntersecting)) more(false); }, { root: el.closest('.ovs-list') || null, rootMargin: '600px 0px' });++ io.observe(sentinel);++ }++ more(true);++ return { update() {} };++ }++ function feedStyles() {++ if (document.getElementById('ovf-styles')) return;++ const st = document.createElement('style'); st.id = 'ovf-styles';++ st.textContent = `++.ovf-chips{position:sticky;top:0;z-index:2;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:8px 4px;margin:0 -4px;background:var(--bg-secondary,#141a24)}++.ovf-chips::-webkit-scrollbar{display:none}++.ovf-chip{flex:none;min-height:36px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}++.ovf-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}++.ovf-list{display:grid;gap:10px;padding:4px 0}++@media (min-width:861px){.ovf-list{grid-template-columns:repeat(2,minmax(0,1fr))}}++.ovf-card{display:grid;gap:8px;padding:10px;border-radius:16px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none;min-width:0}++@media (hover:hover){.ovf-card:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 7%,transparent)}}++.ovf-head{display:flex;align-items:center;gap:8px;min-width:0}++.ovf-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}++.ovf-face img{width:100%;height:100%;object-fit:cover}++.ovf-who{min-width:0;display:grid}++.ovf-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.ovf-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}++.ovf-livechip{margin-left:auto;padding:2px 8px;border-radius:6px;background:var(--live,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.06em}++.ovf-media{position:relative;display:block;border-radius:12px;overflow:hidden;background:var(--bg-tertiary,#1f2633)}++.ovf-media img,.ovf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}++.ovf-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.72);color:#fff;font-size:12px;font-weight:700}++.ovf-viewers{left:8px;right:auto}++.ovf-title{font-size:14.5px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++.ovf-text{margin:0;font-size:14px;line-height:1.5;color:var(--text-secondary,#a8b3c4);display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}++.ovf-counts{font-size:12px;color:var(--text-muted,#93a0b4)}++.ovf-update{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:12px;color:inherit;text-decoration:none}++.ovf-update .ovh-ic{width:34px;height:34px;border-radius:10px;flex:none}++.ovf-update-txt{min-width:0;display:grid;gap:2px}.ovf-update-txt b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovf-update-txt small{font-size:12px;color:var(--text-muted,#93a0b4)}++.ovf-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovf-shimmer 1.4s ease-in-out infinite}++.ovf-more{min-height:1px}.ovf-caught{text-align:center;padding:10px 0}++@keyframes ovf-shimmer{to{background-position:-200% 0}}++@media (prefers-reduced-motion:reduce){.ovf-skel{animation:none}}++`;++ document.head.appendChild(st);++ }++++ root.OpenVibeHome = { home, wallTop, timeline, feed, loadPulse, _defaultHome: defaultHome };})(typeof window !== 'undefined' ? window : globalThis);@@@ -41,7 +41,11 @@var label = btn.querySelector('span') || btn;var was = label.textContent;label.textContent = 'Copied';-- setTimeout(function () { label.textContent = was; }, 1400);++ // An icon button shows a tick for a moment (its word is only for screen readers and the tooltip).++ var path = btn.querySelector('svg path'); var d = path && path.getAttribute('d');++ if (path) path.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5');++ btn.classList.add('is-done'); if (btn.title) btn.title = 'Copied';++ setTimeout(function () { label.textContent = was; if (path) path.setAttribute('d', d); btn.classList.remove('is-done'); if (btn.title) btn.title = was; }, 1400);}).catch(function () { /* a refused clipboard changes nothing */ });}@@@ -303,11 +307,101 @@b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });});++ // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).++ if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {++ text.__ovtEx = true;++ var exI = 0;++ var exTimer = setInterval(function () {++ if (!text.isConnected) { clearInterval(exTimer); return; }++ var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }++ if (!list.length || text.value || document.activeElement === text || document.hidden) return;++ exI = (exI + 1) % list.length;++ text.classList.add('is-ph-out');++ setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);++ }, 3600);++ }++++ // Options: one button, the pickers behind it (a <details>, so it opens without script too). A tap outside,++ // Escape or Done closes it; on a phone it is a sheet and the page behind stays put; a changed picker shows as++ // a chip beside the button, and its × puts the default back.++ qa(box, '[data-ovt-opts]').forEach(function (d) {++ if (d.__ovt) return; d.__ovt = true;++ var chips = d.parentNode ? q(d.parentNode, '[data-ovt-opts-chips]') : null;++ var dot = q(d, '.ovt-opts-dot');++ function phone() { return root.matchMedia && root.matchMedia('(max-width: 640px)').matches; }++ function lock(on) { document.documentElement.classList.toggle('ovt-sheet-open', !!on && phone()); }++ function close() { if (d.open) { d.open = false; } }++ d.addEventListener('toggle', function () {++ lock(d.open);++ if (d.open) { var f = q(d, '.ovt-opts-body select, .ovt-opts-body input, .ovt-opts-body button'); if (f && !phone()) f.focus({ preventScroll: true }); }++ });++ document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) close(); }, true);++ d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); close(); var s = q(d, 'summary'); if (s) s.focus(); } });++ var x = q(d, '[data-ovt-opts-close]'); if (x) x.addEventListener('click', function (e) { e.preventDefault(); close(); });++ function label(el) {++ if (el.tagName === 'SELECT') { var o = el.options[el.selectedIndex]; return o ? o.textContent.trim() : el.value; }++ var l = el.closest('label'); return l ? l.textContent.trim() : el.value;++ }++ function paint() {++ if (!chips) return;++ chips.textContent = '';++ var any = false;++ qa(d, '[data-ovt-default]').forEach(function (el) {++ if (el.closest('[hidden]') || (el.offsetParent === null && !d.open && getComputedStyle(el.closest('label, .ovt-opt') || el).display === 'none')) return;++ var def = el.getAttribute('data-ovt-default');++ var cur = el.tagName === 'SELECT' ? el : q(el, 'input:checked');++ var val = cur ? cur.value : def;++ if (val === def) return;++ any = true;++ var chip = document.createElement('span'); chip.className = 'ovt-opt-chip';++ var t = document.createElement('span'); t.textContent = label(cur).slice(0, 40); chip.appendChild(t);++ var b = document.createElement('button'); b.type = 'button'; b.className = 'ovt-opt-chip-x'; b.setAttribute('aria-label', 'Back to the default: ' + t.textContent); b.textContent = '×';++ b.addEventListener('click', function () {++ if (el.tagName === 'SELECT') el.value = def; else { var r = q(el, 'input[value="' + (root.CSS && CSS.escape ? CSS.escape(def) : def) + '"]'); if (r) r.checked = true; }++ (el.tagName === 'SELECT' ? el : q(el, 'input:checked') || el).dispatchEvent(new Event('change', { bubbles: true }));++ });++ chip.appendChild(b);++ chip.addEventListener('click', function (e) { if (e.target === b) return; d.open = true; });++ chips.appendChild(chip);++ });++ if (dot) dot.hidden = !any;++ }++ d.addEventListener('change', paint);++ if (form) form.addEventListener('ovt:options', paint);++ paint();++ });++++ // Attach's menu: a tap outside or a chosen file closes it.++ qa(box, '[data-ovt-attach]').forEach(function (d) {++ if (d.__ovt) return; d.__ovt = true;++ document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) d.open = false; }, true);++ d.addEventListener('change', function () { d.open = false; });++ d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); d.open = false; var s = q(d, 'summary'); if (s) s.focus(); } });++ });++++ // A kit sidebar's button goes into the navbar, before the brand, on a phone (11-09, the owner: the button on a++ // row of its own under the bar wasted the space): the bar's lead slot opens the same drawer.++ var sbBtn = q(box, '.ovt-head [data-ovk-sidebar-open]');++ if (sbBtn) navLead(box, sbBtn, 0);++enhance(box);if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);});}++ // The navbar may draw after this script: try for a few seconds. The slot goes when a page without a thread loads.++ var leadOn = false;++ function navLead(box, btn, n) {++ var nav = root.OpenVibeNavbar;++ if (!nav || typeof nav.lead !== 'function' || !nav.getElement || !nav.getElement()) { if (n < 40) setTimeout(function () { navLead(box, btn, n + 1); }, 125); return; }++ nav.lead({ icon: 'sidebar', label: btn.getAttribute('aria-label') || 'Your conversations', sidebar: btn.getAttribute('aria-controls') || '', phoneOnly: true });++ leadOn = true;++ box.classList.add('has-nav-lead');++ }++ document.addEventListener('ov:boost:load', function () {++ if (leadOn && !document.querySelector('[data-ovt] .ovt-head [data-ovk-sidebar-open]') && root.OpenVibeNavbar && root.OpenVibeNavbar.lead) { root.OpenVibeNavbar.lead(null); leadOn = false; }++ });++if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();document.addEventListener('ov:boost:load', start);})(typeof window !== 'undefined' ? window : globalThis);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.159.0",++ "version": "4.160.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.159.0",++ "version": "4.160.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.159.0",++ "version": "4.160.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -48,6 +48,7 @@"./action-center": "./action-center.js","./dock": "./dock.js","./presence": "./presence.js",++ "./nav-slots": "./nav-slots.js","./overlay": "./overlay.js","./dock-vibe": "./dock-vibe.js","./dock-timer": "./dock-timer.js",@@@ -86,6 +87,8 @@"./shell": "./shell.js","./showcase": "./showcase.js","./showcase-stage": "./showcase-stage.js",++ "./ai-marks": "./ai-marks.js",++ "./ai-marks-data": "./ai-marks-data.js","./kit": "./kit.js","./kit-web": "./kit-web.js","./kit-data-web": "./kit-data-web.js",nnew file mode 100644@@@ -0,0 +1,30 @@++#!/usr/bin/env node++'use strict';++// Writes ai-marks-data.js: the AI makers' monochrome marks, taken from @lobehub/icons-static-svg (MIT, © LobeHub;++// https://github.com/lobehub/lobe-icons), as inner SVG markup on a 24 × 24 box in currentColor. The marks name the makers++// whose models a page lists (nominative use, as every model leaderboard does); each remains its owner's trademark.++// node scripts/build-ai-marks.js <path to an unpacked @lobehub/icons-static-svg package>++const fs = require('fs');++const path = require('path');++++const PKG = process.argv[2];++if (!PKG) { console.error('usage: node scripts/build-ai-marks.js <unpacked @lobehub/icons-static-svg>'); process.exit(1); }++const pkg = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8'));++// Our family key → LobeHub's file (the mono drawing).++const FILES = {++ OpenAI: 'openai', Anthropic: 'claude', Google: 'gemini', Gemma: 'gemma', Meta: 'meta', DeepSeek: 'deepseek', Qwen: 'qwen',++ Mistral: 'mistral', Kimi: 'kimi', GLM: 'zhipu', MiniMax: 'minimax', Microsoft: 'microsoft', xAI: 'grok', NVIDIA: 'nvidia',++ Cohere: 'cohere', Perplexity: 'perplexity',++};++const out = {};++for (const [key, file] of Object.entries(FILES)) {++ const svg = fs.readFileSync(path.join(PKG, 'icons', `${file}.svg`), 'utf8');++ const vb = /viewBox="([^"]+)"/.exec(svg);++ if (!vb || vb[1] !== '0 0 24 24') throw new Error(`${file}: viewBox ${vb && vb[1]}, expected 0 0 24 24`);++ const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '').replace(/<title>[\s\S]*?<\/title>/, '').trim();++ if (/<script|on[a-z]+=|href=|<image|<foreignObject/i.test(inner)) throw new Error(`${file}: unexpected markup`);++ out[key] = inner;++}++const header = `'use strict';\n// Generated by scripts/build-ai-marks.js from @lobehub/icons-static-svg ${pkg.version} (MIT, © LobeHub,\n// https://github.com/lobehub/lobe-icons). Do not edit: re-run the script. Each mark is its owner's trademark, used here to\n// name the maker of a model.\n`;++fs.writeFileSync(path.join(__dirname, '..', 'ai-marks-data.js'), `${header}module.exports = ${JSON.stringify(out, null, 1)};\n`);++console.log('ai-marks-data.js:', Object.keys(out).length, 'marks from', pkg.version);@@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the// owner still saw the old menu hours after the new one shipped (11-07).const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',-- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js'];++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js'];// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },@@@ -556,7 +556,7 @@if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }const li = document.createElement('li'); li.setAttribute('role', 'presentation');let api = null;-- try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : H.timeline)(li, homeOpts()); } catch { return; }++ try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : (H.feed || H.timeline))(li, homeOpts()); } catch { return; }homeViews[g.as] = { li, api };listEl.appendChild(li);return;@@@ -1030,18 +1030,38 @@if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });const live = liveGroup(); if (live) groups.push(live);if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });++ if (wall && feedV2) return groups; // Feed v2: the composer, today, the live bar and the stream// The network's cards: text and links only (never another site's code), checked here again, with an entry's https// picture when it sent one; the person's choice and order, else Network's first two.const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];const chosen = feedPref();// Every feed shows until the person chooses (owner, 11-09: "by default all the feeds show, but you can configure the order").-- const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;++ let netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;++ // Nothing old dressed as new (11-09, the owner: "why is New in the forum first when it's 17 days ago"): a card's age++ // is its newest item's (a timestamp when the card sends one, else its "3d ago" words); one older than 3 days drops++ // the word "New" from its title, and until the person orders them, fresher cards come first and month-old ones last.++ const AGE = { m: 60e3, min: 60e3, h: 3600e3, d: 86400e3, w: 604800e3, mo: 2592e6, y: 31536e6 };++ const ageOf = (c) => {++ let best = Infinity;++ for (const x of c.items || []) {++ const t = Date.parse(x && (x.at || x.updated_at || x.published_at) || '');++ if (t) { best = Math.min(best, Date.now() - t); continue; }++ const m = /(\d+)\s*(min|mo|m|h|d|w|y)\b[^·]*ago/.exec(String(x && x.meta || ''));++ if (m) best = Math.min(best, Number(m[1]) * AGE[m[2]]);++ }++ return best;++ };++ const ages = new Map(netCards.map((c) => [c, ageOf(c)]));++ const later = []; // cards older than two weeks: after What's new++ if (!chosen) netCards = netCards.slice().sort((a, b) => (ages.get(a) === Infinity ? 1e15 : ages.get(a)) - (ages.get(b) === Infinity ? 1e15 : ages.get(b)));netCards.forEach((c) => {const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),image: x.image && /^https:\/\//.test(String(x.image.src || '')) ? String(x.image.src) : null,site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);-- if (rows.length) groups.push({ title: String(c.title).slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });++ const stale = isFinite(ages.get(c)) && ages.get(c) > 3 * 86400e3; // an unknown age is never called old++ const title = stale ? String(c.title).replace(/^New\s+(in|on|from)\s+/i, (m0, w) => `${w[0].toUpperCase()}${w.slice(1)} `).replace(/^New\s+/i, '') : String(c.title);++ if (rows.length) (isFinite(ages.get(c)) && ages.get(c) > 14 * 86400e3 && !chosen ? later : groups).push({ title: title.slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });});// What's new (the owner, 11-07): the newest real change per site, newest first, "New" after this person's last visit.if (Array.isArray(changesData) && changesData.length) {@@@ -1066,6 +1086,7 @@const today = changesData.filter((e) => e && e.service && !HOUSEKEEPING.test(String(e.subject || '')) && Date.parse(at(e) || 0) > dayAgo).length;if (rows.length) groups.push({ title: today ? `What's new (${today} today)` : "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });}++ groups.push(...later);// Two places new to this person, rotating fairly through the network (developer services only for builders).const builder = all.some((x) => x.group === 'build' && useOf(x.id));const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)@@@ -1120,7 +1141,7 @@// ── The home screen and the wall (ov-home.js, 2026-11-09): loaded beside this file the first time You or Feed// paints; until it arrives (or if it cannot), the tabs paint as before.// One view of each per opening: a repaint (history or the catalogue arriving) keeps edit mode, a drag, a half-typed post.-- let homeLoading = false, pulseReady = null, homeViews = {};++ let homeLoading = false, pulseReady = null, homeViews = {}, feedV2 = false;function homeLib() {if (root.OpenVibeHome) return root.OpenVibeHome;if (homeLoading || typeof ctx.sibling !== 'function') return null;@@@ -1148,7 +1169,9 @@}return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, groups: f && Array.isArray(f.groups) ? f.groups : [],token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,-- pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })) };++ pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),++ // The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.++ feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };}function renderTab() {@@@ -52,8 +52,13 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}.sc-note a{color:var(--accent-light,#60a5fa)}--.sc-sec{margin-top:56px;container-type:inline-size}--.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}++.sc-sec{margin-top:48px;padding-top:32px;border-top:1px solid color-mix(in srgb,var(--border,#263244) 70%,transparent);container-type:inline-size}++.sc-hero+.sc-sec,.sc-sec:first-child{border-top:0;padding-top:0}++.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}++.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}++.sc-h-ic svg{display:block}++.sc-sec>h2+.sc-lede{margin-left:48px}++@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}@@@ -181,6 +186,13 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}++.sc-net-now{display:grid;gap:8px;margin:14px 0 0;padding:12px;border-radius:14px;background:color-mix(in srgb,var(--accent,#3b82f6) 6%,var(--bg-secondary,#101828));border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--border,#263244))}++.sc-net-now[hidden]{display:none}++.sc-net-now b{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#8a96a8)}++.sc-net-now a{display:flex;gap:10px;align-items:baseline;min-height:36px;color:inherit;text-decoration:none;font-size:14px;line-height:1.4}++.sc-net-now a span{flex:none;min-width:64px;font-weight:700;color:var(--accent-light,#60a5fa)}++.sc-net-now a .live{color:var(--live,#ef4444)}++.sc-net-all{margin:12px 0 0;font-weight:700}.sc-net-all a{color:var(--accent-light,#60a5fa);text-decoration:none}.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}@@@ -51,7 +51,17 @@ function icon(name, size = 22) {return `<i class="fa-solid ${esc(n.replace(/[^a-z0-9 -]/gi, ''))}" aria-hidden="true"></i>`;}const id = (s) => (s ? ` id="${esc(String(s).replace(/[^a-z0-9_-]/gi, '-'))}"` : '');--const head = (title, lede, hid) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;++// Every section's heading carries an icon tile (the owner, 11-09: "these section titles should have an icon… look nicer++// and more consistent, separate things"): the section's kind gives a default, a site may pass its own (icon: 'name').++let headGlyphs = null;++const headIcon = (name) => {++ if (!name) return '';++ headGlyphs = headGlyphs || require('./glyphs');++ const n = String(name).replace(/^ov:/, '');++ const key = headGlyphs.NAMES.includes(n) ? n : (headGlyphs.fromFa ? headGlyphs.fromFa(n) : null);++ return key && headGlyphs.path(key) ? `<span class="sc-h-ic" aria-hidden="true">${headGlyphs.svg(key, { size: 18 })}</span>` : '';++};++const head = (title, lede, hid, ic) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${headIcon(ic)}<span>${esc(title)}</span></h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;// Heading ids come from the title, so a page renders the same bytes every time (ETags, caches).const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';const labelled = (title, h) => (title ? ` aria-labelledby="${h}"` : '');@@@ -117,10 +127,10 @@ function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html,}/** A grid of what the product does; each card may link to where it starts. */--function features({ id: sid, title, lede, items } = {}) {++function features({ id: sid, title, lede, items, icon: secIcon } = {}) {if (!has(items)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-grid">${items.map((f) => {++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'grid')}<div class="sc-grid">${items.map((f) => {// A ring icon (ov:…) is its own 40 px tile, so it fills the slot instead of sitting small inside a second tile.const ring = f.icon && String(f.icon).startsWith('ov:');const inner = `${f.icon ? `<span class="sc-ic${ring ? ' sc-ic-ov' : ''}">${icon(f.icon, ring ? 40 : 22)}</span>` : ''}<span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;@@@ -129,27 +139,27 @@ function features({ id: sid, title, lede, items } = {}) {}/** A numbered path from nothing to done. */--function steps({ id: sid, title, lede, items } = {}) {++function steps({ id: sid, title, lede, items, icon: secIcon } = {}) {if (!has(items)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-steps">${items.map((s) => `<li><b>${s.href ? `<a href="${esc(s.href)}">${esc(s.title)}</a>` : esc(s.title)}</b>${s.text ? `<span>${esc(s.text)}</span>` : ''}</li>`).join('')}</ol></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'steps')}<ol class="sc-steps">${items.map((s) => `<li><b>${s.href ? `<a href="${esc(s.href)}">${esc(s.title)}</a>` : esc(s.title)}</b>${s.text ? `<span>${esc(s.text)}</span>` : ''}</li>`).join('')}</ol></section>`;}/** Code the reader can copy: one or more samples, each labelled with its language. */--function code({ id: sid, title, lede, samples } = {}) {++function code({ id: sid, title, lede, samples, icon: secIcon } = {}) {if (!has(samples)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre tabindex="0" aria-label="${esc(c.label || c.lang || `Sample ${i + 1}`)}"><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'code')}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre tabindex="0" aria-label="${esc(c.label || c.lang || `Sample ${i + 1}`)}"><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;}/** A live demo: the product itself in a frame, or the caller's own markup. */--function demo({ id: sid, title, lede, iframe, html } = {}) {++function demo({ id: sid, title, lede, iframe, html, icon: secIcon } = {}) {if (!iframe && html == null) return '';const h = `sc-${slug(title)}`;const body = iframe? `<iframe src="${esc(iframe.src)}" title="${esc(iframe.title || title || 'Live demo')}" loading="lazy" style="height:${Math.max(160, Math.min(900, Number(iframe.height) || 420))}px" referrerpolicy="strict-origin-when-cross-origin"${iframe.sandbox ? ` sandbox="${esc(iframe.sandbox)}"` : ''}></iframe>`: html;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'play')}<div class="sc-demo">${body}</div></section>`;}/**@@@ -171,34 +181,34 @@ const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</spconst tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;/** How the product compares, feature by feature. The first column is this product. */--function compare({ id: sid, title, lede, columns, rows } = {}) {++function compare({ id: sid, title, lede, columns, rows, icon: secIcon } = {}) {if (!has(rows) || !has(columns)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'chart')}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;}/** Plans side by side. A price of 0 reads "Free". */--function pricing({ id: sid, title, lede, tiers, note } = {}) {++function pricing({ id: sid, title, lede, tiers, note, icon: secIcon } = {}) {if (!has(tiers)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-tiers">${tiers.map((t) => `<div class="sc-tier${t.highlight ? ' sc-hl' : ''}"><b class="sc-tier-name">${esc(t.name)}</b><p class="sc-price">${t.price === 0 || t.price === '0' ? 'Free' : esc(t.price)}${t.per && !(t.price === 0 || t.price === '0') ? `<small> / ${esc(t.per)}</small>` : ''}</p>${has(t.items) ? `<ul>${t.items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}${t.action ? actions([{ ...t.action, primary: t.highlight }], 'sc-actions sc-tier-act') : ''}</div>`).join('')}</div>${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'coin')}<div class="sc-tiers">${tiers.map((t) => `<div class="sc-tier${t.highlight ? ' sc-hl' : ''}"><b class="sc-tier-name">${esc(t.name)}</b><p class="sc-price">${t.price === 0 || t.price === '0' ? 'Free' : esc(t.price)}${t.per && !(t.price === 0 || t.price === '0') ? `<small> / ${esc(t.per)}</small>` : ''}</p>${has(t.items) ? `<ul>${t.items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}${t.action ? actions([{ ...t.action, primary: t.highlight }], 'sc-actions sc-tier-act') : ''}</div>`).join('')}</div>${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</section>`;}/*** The limits a caller really gets, as a table: pricing that equals the API's limits. `source` names where the numbers* come from (a /limits.json URL), so the page can never promise more than the service enforces.*/--function limits({ id: sid, title, lede, columns, rows, source } = {}) {++function limits({ id: sid, title, lede, columns, rows, source, icon: secIcon } = {}) {if (!has(rows) || !has(columns)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'gauge')}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;}/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */--function stories({ id: sid, title, lede, items } = {}) {++function stories({ id: sid, title, lede, items, icon: secIcon } = {}) {if (!has(items)) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'quote')}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;}// A next step's target names: the service id → the product name the card says it opens ("on OpenVibe.Media").@@@ -241,7 +251,7 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {}/** A strip of real numbers ("12,400 streams", "184 tools"). The page passes what it measured; nothing is invented here. */--function stats({ id: sid, title, items } = {}) {++function stats({ id: sid, title, items, icon: secIcon } = {}) {const usable = (items || []).filter((x) => x && x.value != null && x.label);if (!usable.length) return '';const h = `sc-${slug(title || 'numbers')}`;@@@ -258,13 +268,13 @@ const RATIOS = { '16/9': '16 / 9', '4/3': '4 / 3', '1/1': '1 / 1', '3/4': '3 / 4* the top of a page; 'grid' (the default) wraps. Images are lazy and hold their space (`ratio`), so nothing jumps.* `live: true` adds the red LIVE badge; `badge` is any short label, `meta` a quiet line under the title.*/--function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more } = {}) {++function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more, icon: secIcon } = {}) {const usable = (items || []).filter((c) => c && c.title && safeHref(c.href));if (!usable.length) return '';const h = `sc-${slug(title)}`;const ar = RATIOS[ratio] || RATIOS['16/9'];const moreHref = more && safeHref(more.href);-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h)}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span aria-hidden="true">→</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h, secIcon || 'image')}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span aria-hidden="true">→</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {const img = /^(https:\/\/|\/(?!\/))/.test(c.image || '') ? `<img src="${esc(c.image)}" alt="${esc(c.alt || '')}" loading="lazy" decoding="async">` : '';const badge = c.live ? '<span class="sc-badge sc-live">LIVE</span>' : c.badge ? `<span class="sc-badge">${esc(c.badge)}</span>` : '';return `<li><a class="sc-tile" href="${esc(safeHref(c.href))}"><span class="sc-thumb">${img}${badge}</span><span class="sc-tile-body"><b>${esc(c.title)}</b>${c.meta ? `<small>${esc(c.meta)}</small>` : ''}${c.text ? `<span class="sc-tile-text">${esc(c.text)}</span>` : ''}</span></a></li>`;@@@ -272,11 +282,11 @@ function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', m}/** Features, shown rather than listed: a picture or a short muted loop beside each, alternating sides. */--function spotlight({ id: sid, title, lede, items } = {}) {++function spotlight({ id: sid, title, lede, items, icon: secIcon } = {}) {const usable = (items || []).filter((x) => x && x.title);if (!usable.length) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-spots">${usable.map((x) => {++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'star')}<div class="sc-spots">${usable.map((x) => {const video = /^(https:\/\/|\/(?!\/))/.test(x.video || '') ? `<video src="${esc(x.video)}" muted loop playsinline autoplay preload="none"${x.image ? ` poster="${esc(x.image)}"` : ''} aria-label="${esc(x.alt || x.title)}"></video>` : '';const img = !video && /^(https:\/\/|\/(?!\/))/.test(x.image || '') ? `<img src="${esc(x.image)}" alt="${esc(x.alt || '')}" loading="lazy" decoding="async">` : '';const href = safeHref(x.href);@@@ -285,11 +295,11 @@ function spotlight({ id: sid, title, lede, items } = {}) {}/** Questions people ask, as an accordion that works without JavaScript. Pair it with faqJsonLd(items) in the head. */--function faq({ id: sid, title = 'Questions', lede, items } = {}) {++function faq({ id: sid, title = 'Questions', lede, items, icon: secIcon } = {}) {const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);if (!usable.length) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;}/** A JSON-LD block that cannot close its script early. */const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;@@@ -340,13 +350,13 @@ const DEFAULT_RELATED = RELATED.network;* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same* [to, what, href] or { to, what, href } shape), each a ring icon, its name and why to go there.*/--function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One account across every OpenVibe site. Related places to go next:', items } = {}) {++function network({ id: sid, service, title = 'More on OpenVibe', lede = 'One account across every OpenVibe site.', items, icon: secIcon } = {}) {const list = (items || RELATED[String(service || '').toLowerCase()] || DEFAULT_RELATED).map((x) => (Array.isArray(x) ? { to: x[0], what: x[1], href: x[2] } : x)).filter((x) => x && x.what && safeHref(x.href) && x.to !== service).slice(0, 4);if (!list.length) return '';const h = `sc-${slug(title)}`;-- return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul></section>`;++ return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'spark')}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul><div class="sc-net-now" data-ov-now hidden></div><p class="sc-net-all"><a href="https://openvibe.network/sites">Every OpenVibe site →</a></p></section>`;}/**@@@ -358,7 +368,7 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc* gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from* 960 px), so no tile sits alone on a row. Empty → ''.*/--function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null } = {}) {++function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null, icon: secIcon } = {}) {const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);if (!sets.length) return '';const h = `sc-${slug(title || 'apps')}`;@@@ -367,7 +377,7 @@ function apps({ id: sid, title = '', lede = '', groups = null, items = null, morconst cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : n % 3 === 0 ? 3 : 0);const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';-- return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${body}${end}</section>`;++ return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'grid')}${body}${end}</section>`;}/** The closing call to action. */@@@ -428,6 +438,18 @@ function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq+ `${explore ? features(explore) : ''}${devHTML}${q ? faq(q) : ''}${net ? network(net) : ''}</div>`;}++// More on OpenVibe's live strip: up to four things happening across the network, filled in the browser from Network's++// Frame (the same data the menu shows: who is live, the sites' trending cards). Text only; https links only; nothing++// shows when Network does not answer. A page that renders network() includes this once: `<script>${sc.NETWORK_JS}</script>`.++const NETWORK_JS = "(function(){try{var box=document.querySelector('[data-ov-now]');if(!box||!window.fetch)return;var here=location.hostname;"++ + "function ok(u){try{var x=new URL(u);return x.protocol==='https:'&&x.hostname!==here?x.href:null}catch(e){return null}}"++ + "function row(tag,name,title,href,live){var a=document.createElement('a');a.href=href;var s=document.createElement('span');s.textContent=tag;if(live)s.className='live';a.appendChild(s);var t=document.createElement('em');t.style.fontStyle='normal';t.textContent=title;a.appendChild(t);box.appendChild(a)}"++ + "Promise.all([fetch('https://openvibe.network/api/frame/pulse',{credentials:'omit'}).then(function(r){return r.ok?r.json():null}).catch(function(){return null}),fetch('https://openvibe.network/api/frame?v=2',{credentials:'omit',headers:{accept:'application/json'}}).then(function(r){return r.ok?r.json():null}).catch(function(){return null})]).then(function(v){var n=0,p=v[0],f=v[1];"++ + "var h=document.createElement('b');h.textContent='Happening on OpenVibe';box.appendChild(h);"++ + "if(p&&Number(p.live_now)>0){var names=(p.live||[]).slice(0,3).map(function(c){return c.name}).filter(Boolean).join(', ');row('Live',null,(p.live_now==1?'1 stream live now':p.live_now+' streams live now')+(names?': '+names:''),'https://openvibe.live/',true);n++}"++ + "(f&&f.cards||[]).forEach(function(c){if(n>=4||!c||!c.items||!c.items.length)return;var it=c.items[0],u=ok(it.url);if(!u||!it.title)return;row(String(c.title||c.site||'').slice(0,24),null,String(it.title).slice(0,90),u,false);n++});"++ + "if(n)box.hidden=false})}catch(e){}})();";++const CSS = `/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}@@@ -482,8 +504,13 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}.sc-note a{color:var(--accent-light,#60a5fa)}--.sc-sec{margin-top:56px;container-type:inline-size}--.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}++.sc-sec{margin-top:48px;padding-top:32px;border-top:1px solid color-mix(in srgb,var(--border,#263244) 70%,transparent);container-type:inline-size}++.sc-hero+.sc-sec,.sc-sec:first-child{border-top:0;padding-top:0}++.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}++.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}++.sc-h-ic svg{display:block}++.sc-sec>h2+.sc-lede{margin-left:48px}++@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}@@@ -611,6 +638,13 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}++.sc-net-now{display:grid;gap:8px;margin:14px 0 0;padding:12px;border-radius:14px;background:color-mix(in srgb,var(--accent,#3b82f6) 6%,var(--bg-secondary,#101828));border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--border,#263244))}++.sc-net-now[hidden]{display:none}++.sc-net-now b{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#8a96a8)}++.sc-net-now a{display:flex;gap:10px;align-items:baseline;min-height:36px;color:inherit;text-decoration:none;font-size:14px;line-height:1.4}++.sc-net-now a span{flex:none;min-width:64px;font-weight:700;color:var(--accent-light,#60a5fa)}++.sc-net-now a .live{color:var(--live,#ef4444)}++.sc-net-all{margin:12px 0 0;font-weight:700}.sc-net-all a{color:var(--accent-light,#60a5fa);text-decoration:none}.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}@@@ -646,4 +680,4 @@ html{overflow-x:clip}@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a,.sc-apps a{transition:none}.sc-badge.sc-live::before{animation:none}}`;--module.exports = { icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };++module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++// openvibe-shared/ai-marks: a model id, slug, family or provider name finds its maker's mark; anything else gets the++// neutral spark (never a letter, never another maker's mark); marks carry no markup but drawing; the licence is named.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const m = require('../ai-marks');++const DATA = require('../ai-marks-data');++++const cases = {++ 'deepseek/deepseek-chat': 'DeepSeek', 'claude-opus-5-5': 'Anthropic', 'anthropic': 'Anthropic', 'gpt-5-nano': 'OpenAI', 'openai/o3-mini': 'OpenAI',++ 'gemini-4-carbon': 'Google', 'gemma-3-27b': 'Gemma', 'qwen3.8-max': 'Qwen', 'kimi-k3.1-pro': 'Kimi', 'glm-5.3-flash': 'GLM',++ 'MiniMax-M3.1-Flash': 'MiniMax', 'llama-3.2-1b-instruct': 'Meta', 'mixtral-8x7b': 'Mistral', 'grok-4': 'xAI', 'phi-4': 'Microsoft',++ 'nemotron-70b': 'NVIDIA', 'command-r-plus': 'Cohere', 'sonar-pro': 'Perplexity',++};++for (const [text, key] of Object.entries(cases)) assert.strictEqual(m.family(text), key, text);++for (const text of ['abliterated-model-large-flash', 'openrouter/auto', '', null]) assert.strictEqual(m.family(text), null, String(text));++++const ds = m.mark('deepseek/deepseek-chat', { size: 28 });++assert.match(ds, /^<span class="ov-aim ov-aim--deepseek" style="--ov-aim:28px" role="img" aria-label="DeepSeek" title="DeepSeek"><svg viewBox="0 0 24 24"/);++const other = m.mark('<script>x</script>');++assert.ok(other.includes('ov-aim--other') && other.includes('aria-label="<script>x</script>"') && !other.includes('<script>'), 'unknown: the spark, the name escaped');++assert.ok(!/aria-label/.test(m.mark('qwen', { label: false })) && /aria-hidden="true"/.test(m.mark('qwen', { label: false })), 'decorative when the row names it');++++for (const [k, inner] of Object.entries(DATA)) {++ assert.ok(/^<(path|circle|rect|g|ellipse|polygon)[\s>]/.test(inner), `${k} is drawing markup`);++ assert.ok(!/<script|on[a-z]+=|href=|<image|<foreignObject|<style/i.test(inner), `${k} carries nothing but drawing`);++}++assert.strictEqual(m.FAMILIES.length, 16);++const head = fs.readFileSync(path.join(__dirname, '..', 'ai-marks-data.js'), 'utf8').slice(0, 400);++assert.ok(/@lobehub\/icons-static-svg/.test(head) && /MIT/.test(head), 'the source and its licence are named');++assert.ok(/\.ov-aim\{/.test(m.CSS) && /var\(--text-primary/.test(m.CSS), 'the tile follows the theme');++console.log('ai-marks: makers found from ids, the spark for anyone else, drawing only, the licence named');nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++// The composer's Options, examples and the navbar's lead slot (11-09, the owner on openvibe.codes: "too many options…++// hidden under a cog… less cluttered but still has the power features"; the sidebar button "to the left of the logo"),++// in a real headless Chrome: one button opens the pickers (a sheet on a phone, the page behind stays put), a tap outside++// closes it, a changed picker shows as a chip whose × puts the default back, the placeholder walks through the examples,++// and a kit sidebar's button moves into the navbar on a phone, where the empty head row goes away. Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++const kit = require('../kit');++const thread = require('../thread');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('composer-options-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const OPTIONS = '<label class="ovt-opt">Agent <select name="who" data-ovt-default=""><option value="">Auto</option><option value="codex">Codex</option></select></label>'++ + '<label class="ovt-opt">Effort <select name="effort" data-ovt-default=""><option value="">Effort: auto</option><option value="high">Thorough</option></select></label>';++const composer = thread.composer({ action: '/send', id: 'f', placeholder: 'What should change?', options: { html: OPTIONS }, examples: ['Make the home page faster on phones', 'Add a dark mode toggle'] });++const sidebar = kit.sidebar ? kit.sidebar({ id: 'ch-rail', label: 'Your conversations', sections: [] }) : '<nav id="ch-rail" class="ovk-sidebar" data-ovk-sidebar></nav>';++const view = thread.page({ sidebar, head: thread.head({ sidebar: 'ch-rail', sidebarLabel: 'Your conversations' }), empty: '<h1>What should we build?</h1><p data-ovt-fold>Say it the way you would to a friend.</p>', composer, center: true });++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Composer</title><link rel="icon" href="data:,">++<link rel="stylesheet" href="/kit.css"><link rel="stylesheet" href="/thread.css"></head>++<body><div id="nav"></div>${view}<div style="height:1600px"></div>++<script>document.addEventListener('submit', (e) => e.preventDefault());</script>++<script src="/navbar.js"></script><script>OpenVibeNavbar.init({ mount: '#nav', site: 'codes', siteName: 'Codes', dock: false, messages: false, links: [] });</script>++<script src="/kit-web.js"></script><script src="/ov-thread.js"></script></body></html>`;++const FILES = new Set(['/kit.css', '/thread.css', '/navbar.js', '/nav-slots.js', '/kit-web.js', '/ov-thread.js']);++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }++ if (FILES.has(u.pathname)) { res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }++ res.writeHead(404); return res.end();++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-composer-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (p, expr, what, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v) return v; if (Date.now() > end) throw new Error(`timed out: ${what}`); await wait(50); } };++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.network*', '*openvibe.tools*'] });++ await p.goto(`${base}/`, { maxMs: 6000 });++ await until(p, "!!document.querySelector('[data-ovt-opts]')", 'the Options button');++ // Only the button and Send in the bar; the pickers are behind it.++ assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default');++ // Open: a sheet at the bottom of a phone, the page behind does not scroll.++ await p.evaluate("document.querySelector('.ovt-opts > summary').click()");++ await until(p, "document.querySelector('[data-ovt-opts]').open", 'it opens');++ await wait(320);++ const sheet = await p.evaluate("(() => { const r = document.querySelector('.ovt-opts-panel').getBoundingClientRect(); return { bottom: Math.round(r.bottom), left: Math.round(r.left), width: Math.round(r.width), pos: getComputedStyle(document.querySelector('.ovt-opts-panel')).position, locked: document.documentElement.classList.contains('ovt-sheet-open') }; })()");++ assert.strictEqual(sheet.pos, 'fixed', 'a sheet on a phone');++ assert.ok(sheet.bottom >= 840 && sheet.left === 0 && sheet.width === 390, `along the bottom edge (${JSON.stringify(sheet)})`);++ assert.ok(sheet.locked, 'the page behind stays put');++ // Change a picker: a chip shows beside the button, and the dot.++ await p.evaluate("(() => { const s = document.querySelector('select[name=who]'); s.value = 'codex'; s.dispatchEvent(new Event('change', { bubbles: true })); })()");++ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot');++ // A tap outside closes it and unlocks the page.++ await p.evaluate("(() => { const e = new PointerEvent('pointerdown', { bubbles: true }); document.querySelector('h1').dispatchEvent(e); })()");++ await until(p, "!document.querySelector('[data-ovt-opts]').open", 'a tap outside closes it');++ assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again');++ // The chip's × puts the default back.++ await p.evaluate("document.querySelector('.ovt-opt-chip-x').click()");++ assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back');++ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone');++ // The examples walk through the placeholder while the box is empty.++ await until(p, "document.querySelector('[data-ovt-text]').placeholder !== 'What should change?'", 'the placeholder moves to an example', 6000);++ // The sidebar button: in the navbar before the brand, and the head row with nothing else in it is gone.++ await until(p, "!!document.getElementById('ovnav-lead')", 'the lead slot in the navbar');++ const lead = await p.evaluate("(() => { const b = document.getElementById('ovnav-lead'); const brand = document.querySelector('.openvibe-navbar-brand'); return { first: brand.firstElementChild === b, ctl: b.getAttribute('aria-controls'), head: getComputedStyle(document.querySelector('.ovt-head')).display }; })()");++ assert.ok(lead.first, 'the button comes before the brand');++ assert.strictEqual(lead.ctl, 'ch-rail', 'and opens the conversations');++ assert.strictEqual(lead.head, 'none', 'the empty head row goes');++ await p.evaluate("document.getElementById('ovnav-lead').click()");++ await until(p, "document.getElementById('ch-rail').classList.contains('is-open')", 'the slot opens the drawer');++ assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open');++ assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll');++ // A site's own action on the right, by the API.++ await p.evaluate("OpenVibeNavbar.action({ id: 'help', icon: 'info', label: 'Help', badge: 2, onClick: () => { window.__help = (window.__help || 0) + 1; } })");++ await p.evaluate("document.getElementById('ovnav-act-help').click()");++ assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler');++ assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count');++ // On a computer: the panel opens beside the button, not as a sheet, and the navbar slot is not shown.++ const d = await h.openPage(browser, context, { width: 1280, height: 900, block: ['*openvibe.network*', '*openvibe.tools*'] });++ await d.goto(`${base}/`, { maxMs: 6000 });++ await until(d, "!!document.querySelector('[data-ovt-opts]')", 'the Options button on a computer');++ await d.evaluate("document.querySelector('.ovt-opts > summary').click()");++ await wait(250);++ assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer');++ assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel');++ await until(d, "!!document.getElementById('ovnav-lead')", 'the slot exists');++ assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown');++ console.log('composer-options-chrome: one Options button, a sheet on a phone, chips for changes, examples, the sidebar button in the navbar, actions by API');++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// Feed v2 in the menu (owner, 11-09: "a real feed… you scroll through easy content… a filter system… clean"), in a real++// headless Chrome against a stand-in for Community's /api/v1/feed: chips on top, cards by kind, a chip refetches with its++// kind, the next page loads as the end comes near, the end says so, the Feed tab drops its old sections when the feed++// answers, and what people wrote is text. (Without the API the wall stands: menu-home-screen-chrome.test.js.)++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const ROOT = path.join(__dirname, '..');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-feed-v2-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const SITES = ['live', 'chat', 'community', 'video', 'pics', 'download', 'design', 'games', 'space', 'codes', 'news', 'tools', 'bot', 'fun']++ .map((id, i) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), url: `https://openvibe.${id}/`, icon: id, group: i < 10 ? 'play' : 'more' }))++ .concat([{ id: 'ai', name: 'AI', url: 'https://openvibe.exchange/', icon: 'ai', group: 'build' }, { id: 'run', name: 'Run', url: 'https://openvibe.run/', icon: 'run', group: 'build' }]);++const FRAME = { nav: SITES, groups: [{ id: 'play', name: 'Play' }, { id: 'more', name: 'More' }, { id: 'build', name: 'Build' }], soon: [], footer: {} };++const CATALOG = { families: [], tools: Array.from({ length: 30 }, (_, i) => ({ name: `Tool ${i + 1}`, icon: ['pdf', 'image', 'qr', 'audio'][i % 4], url: `https://openvibe.tools/t${i + 1}`, tagline: 'a tool' })) };++const PULSE = { items: [++ { title: 'ada went live: Coding at night', url: 'https://openvibe.live/ada', occurred_at: new Date(Date.now() - 60e3).toISOString(), ref: { service: 'live' } },++ { title: 'bo went live: Morning stream', url: 'https://openvibe.live/bo', occurred_at: new Date(Date.now() - 120e3).toISOString(), ref: { service: 'live' } },++ { title: 'A new clip: the jump', url: 'https://openvibe.video/c/1', occurred_at: new Date(Date.now() - 3600e3).toISOString(), ref: { service: 'video' } },++ { title: '<img src=x onerror="window.pwned=1"> a post', url: 'https://openvibe.community/p/1', occurred_at: new Date(Date.now() - 7200e3).toISOString(), ref: { service: 'community' } },++], next_cursor: 'c2' };++const TODAY = { people: { today: 1204, series: [800, 900, 1000, 1204] }, streams: { today: 37, series: [20, 30, 37] }, posts: { today: 210 }, videos: { today: 12 } };++++const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home</title><link rel="icon" href="data:,">++<script>++ window.__calls = [];++ const J = (b, ok = true, status = 200) => Promise.resolve({ ok, status, json: async () => b });++ const realFetch = window.fetch;++ window.fetch = (u, o) => {++ const s = String(u); window.__calls.push({ u: s, m: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && (o.headers.Authorization || o.headers.authorization) || '' });++ if (s.startsWith('https://openvibe.network/api/frame?')) return J(${JSON.stringify(FRAME)});++ if (s.startsWith('https://openvibe.network/api/catalog.json')) return J(${JSON.stringify(CATALOG)});++ if (s.startsWith('https://openvibe.network/api/v1/frame/today')) return J(${JSON.stringify(TODAY)});++ if (s.startsWith('https://openvibe.network/api/v1/changelog')) return J({ entries: [++ { service: 'live', subject: 'Go live from a phone: the Dock no longer covers the button (#711)', deployed_at: new Date(Date.now() - 3600e3).toISOString(), major: true },++ { service: 'live', subject: 'openvibe-shared v4.158.0 (was v4.157.0) (#712)', deployed_at: new Date(Date.now() - 600e3).toISOString() },++ { service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });++ if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });++ if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);++ if (s.startsWith('https://openvibe.community/api/v1/feed')) { window.__feedCalls = (window.__feedCalls || []).concat([s]); const u = new URL(s); const kind = u.searchParams.get('kind') || ''; const page = u.searchParams.get('cursor') ? 2 : 1;++ const mk = (i, k) => ({ id: k + i + '-' + page, kind: k, url: 'https://openvibe.video/c/' + i, title: (k === 'post' ? '<b>bold</b> ' : '') + k + ' ' + i + ' page ' + page, text: 'words', at: new Date(Date.now() - i * 60000).toISOString(), site: k === 'pic' ? 'pics' : 'video', site_name: 'Video', actor: { name: 'ada' }, media: k === 'post' || k === 'update' ? {} : { thumb: 'https://openvibe.media/t/x.webp', ratio: 1.7778, length: '0:30' }, counts: { views: 1200, likes: 3 } });++ const kinds = kind ? [kind] : ['live', 'clip', 'pic', 'post', 'update'];++ return J({ items: Array.from({ length: 12 }, (_, i) => mk(i, kinds[i % kinds.length])), next_cursor: page === 1 ? 'c2' : null }); }++ if (s.startsWith('https://openvibe.community/api/v1/pulse')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));++ if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });++ if (s.startsWith('https://')) return J({}, false, 404);++ return realFetch(u, o);++ };++</script>++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>++<script>document.addEventListener('DOMContentLoaded', function () { try { localStorage.clear(); sessionStorage.clear(); } catch (e) {} OpenVibeNavbar.init({ service: 'site', user: ${signed ? "{ id: 'u1', username: 'ada', display_name: 'Ada' }" : 'null'}, token: ${signed ? "'tok-1'" : 'null'}, links: [{ label: 'Rooms', href: '/rooms' }], countViews: false, notifications: false }); });</script>++</body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css|woff2))$/.exec(u.pathname);++ if (m) {++ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }++ res.writeHead(200, { 'content-type': m[1].endsWith('.js') ? 'application/javascript' : 'application/octet-stream' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));++ }++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(u.searchParams.has('signed')));++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feed-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.tools*', '*jsdelivr*', '*openvibe.media*'] });++ await p.goto(`${base}/`, { maxMs: 6000 });++ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');++ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="feed"]')`), 'the menu opened');++ await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);++ assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 12`), 'the feed painted');++ const first = await p.evaluate(`({ chips: [...document.querySelectorAll('.ovf-chip')].map((b) => b.textContent), selected: (document.querySelector('.ovf-chip[aria-selected=true]') || {}).textContent,++ kinds: [...new Set([...document.querySelectorAll('.ovf-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovf-livechip'), media: document.querySelectorAll('.ovf-media img').length,++ bolds: document.querySelectorAll('.ovf-list b b').length, posts: [...document.querySelectorAll('.ovf-post .ovf-title')].map((b) => b.textContent)[0],++ sections: [...document.querySelectorAll('.ovs-group')].map((g) => g.textContent.trim()).filter(Boolean), scroll: document.documentElement.scrollWidth })`);++ assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Clips', 'Videos', 'Pics', 'Posts', 'Memes', 'Games', 'Updates']);++ assert.strictEqual(first.selected, 'For you');++ assert.ok(first.kinds.includes('ovf-card ovf-live') && first.kinds.includes('ovf-update') && first.kinds.includes('ovf-card ovf-pic'), `cards by kind (${first.kinds})`);++ assert.ok(first.live, 'a live card says LIVE');++ assert.ok(first.media >= 6, 'media cards show their picture');++ assert.strictEqual(first.bolds, 0, 'what people wrote is text');++ assert.ok(/^<b>bold<\/b> post/.test(first.posts), 'the markup shows as words');++ assert.ok(!first.sections.some((t) => /New to you|Next from|What's new/.test(t)), `the Feed tab is the stream when the feed answers (${first.sections})`);++ assert.ok(first.scroll <= 390, 'no sideways scroll');++ await p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); l.scrollTop = l.scrollHeight; })()`);++ assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 24`), 'the next page loads as the end comes near');++ assert.ok(await until(p, `!!document.querySelector('.ovf-caught')`), 'the end says so');++ await p.evaluate(`[...document.querySelectorAll('.ovf-chip')].find((b) => b.textContent === 'Clips').click(), true`);++ assert.ok(await until(p, `(window.__feedCalls || []).some((u) => u.includes('kind=clip'))`), 'the chip asks for its kind');++ assert.ok(await until(p, `[...document.querySelectorAll('.ovf-list > a')].every((a) => a.className === 'ovf-card ovf-clip') && document.querySelectorAll('.ovf-list > a').length === 12`), 'only clips, from the first page');++ console.log('menu-feed-v2-chrome: chips, cards by kind, text only, the next page, caught up, a chip by kind, the stream alone ok');++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 46 * 1024; // 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)++const NAVBAR_BROTLI_BUDGET = 47 * 1024; // 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{@@@ -7,7 +7,7 @@ const fs = require('fs');const path = require('path');const src = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');--assert.ok(/root\.OpenVibeHome = \{ home, wallTop, timeline, loadPulse/.test(src), 'it exposes the home screen and the wall');++assert.ok(/root\.OpenVibeHome = \{ home, wallTop, timeline, feed, loadPulse/.test(src), 'it exposes the home screen, the wall and the feed');assert.ok(!/\.innerHTML\s*=/.test(src) && !/insertAdjacentHTML/.test(src), 'it never writes markup');assert.ok(/const safe = \(u\) =>/.test(src) && /x\.protocol === 'https:' \|\| x\.origin === location\.origin/.test(src), 'links are https or this site only');assert.ok(/localStorage\.setItem\(HOME_KEY/.test(src) && /\/api\/v1\/me\/home-screen/.test(src), 'the arrangement is kept here and on Network');@@@ -123,7 +123,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');const net = sc.network({ service: 'live' });-- assert.ok(net.includes('Part of OpenVibe') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');++ assert.ok(net.includes('More on OpenVibe') && net.includes('data-ov-now') && net.includes('https://openvibe.network/sites') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');assert.ok(!net.includes('https://openvibe.live/'), 'never links a product to itself');assert.ok(sc.network({ service: 'unknown-thing' }).includes('OpenVibe.Live'), 'a product without its own list gets the core ones');assert.ok(sc.network({ service: 'x', items: [['tools', 'Tools', 'https://openvibe.tools/'], ['bad', 'Bad', 'ftp://x']] }).includes('OpenVibe.Tools'));@@@ -192,3 +192,14 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha}console.log('showcase: all checks passed');++++// 4.160.0 (owner, 11-09: section titles need an icon, sections should read as separate things): each section's heading++// carries an icon tile, its kind's by default, a site's own when given; the network section's live strip and every site.++{++ const f = sc.features({ title: 'What it does', items: [{ title: 'A', text: 'B' }] });++ assert.ok(/<h2 id="[^"]+"><span class="sc-h-ic" aria-hidden="true"><svg[^>]*>.*<\/svg><\/span><span>What it does<\/span><\/h2>/.test(f), 'a features heading has its icon tile');++ assert.ok(sc.faq({ items: [['Q?', 'A.']] }).includes('class="sc-h-ic"'), 'questions have theirs');++ assert.ok(sc.features({ title: 'X', icon: 'rocket', items: [{ title: 'A', text: 'B' }] }).includes('sc-h-ic'), 'a site may choose the icon');++ assert.ok(/border-top:1px solid/.test(sc.CSS) && /\.sc-hero\+\.sc-sec/.test(sc.CSS), 'sections stand apart, the first after the hero without a rule');++ assert.ok(/api\/frame\/pulse/.test(sc.NETWORK_JS) && /textContent/.test(sc.NETWORK_JS) && !/innerHTML/.test(sc.NETWORK_JS), 'the live strip reads the Frame and writes text only');++}@@@ -46,7 +46,11 @@ assert.strictEqual(t.work({ rows: [] }), '', 'nothing to fold, nothing drawn');const ans = t.answer({ html: '<p>The <b>answer</b></p>', sources: ['https://www.example.com/a', 'not a url'], meta: ['Balanced · DeepSeek', '$0.002'], actions: [{ label: 'Details', href: '/tasks/1?view=details' }] });assert.ok(ans.includes('<div class="ovt-answer" data-ovt-answer><p>The <b>answer</b></p></div>'), 'the answer is the caller\'s safe markup');assert.ok(ans.includes('>example.com</a>') && ans.includes('>not a url</a>'), 'sources by host');--assert.ok(ans.includes('<button type="button" class="ovt-act" data-ovt-copy hidden>'), 'Copy waits for the script');++assert.ok(ans.includes('<button type="button" class="ovt-act" data-ovt-copy title="Copy" aria-label="Copy" hidden>'), 'Copy waits for the script, an icon button named by its word');++assert.ok(ans.includes('title="Details" aria-label="Details"'), 'a site action is an icon button with its word as the name');++const menu = t.composer({ action: '/chat', attach: { menu: true, library: 'https://openvibe.media/me' } });++assert.ok(menu.includes('data-ovt-attach') && menu.includes('Photos and videos') && menu.includes('capture="environment"') && menu.includes('From your OpenVibe files'), 'attach as a menu: files, photos, the camera, your OpenVibe files');++assert.ok(!/accept=/.test(menu.split('Photos and videos')[0]), 'Files takes every file type');assert.ok(ans.includes('<span>Balanced · DeepSeek</span><span>$0.002</span>') && ans.includes('href="/tasks/1?view=details"'));assert.ok(t.card({ tone: 'bad', title: 'Could not finish', text: 'It <ran> too long.' }).includes('<div class="ovt-card is-bad"><p><b>Could not finish</b> It <ran> too long.</p>'));@@@ -217,3 +217,91 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo.ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }.ovt-panel, .ovt-chev { transition: none; }}++++/* ── The composer's Options (11-09, the owner: "too many options… hidden under a cog… less cluttered but still has the power++ features"): one button; the pickers in a panel above it on a computer, a sheet on a phone; what differs from the defaults++ shows as chips beside the button. ── */++.ovt-opts { position: relative; flex: none; }++.ovt-opts > summary { list-style: none; position: relative; }++.ovt-opts > summary::-webkit-details-marker { display: none; }++.ovt-opts-btn { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 14px; cursor: pointer; color: var(--text-secondary, #96a7c2); transition: background-color .15s, color .15s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }++.ovt-opts-btn:hover, .ovt-opts[open] > .ovt-opts-btn { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); color: var(--text-primary, #e6edf7); }++.ovt-opts-btn:active { transform: scale(.92); }++.ovt-opts-btn .ovt-ic { width: 20px; height: 20px; }++.ovt-opts-dot { position: absolute; top: 8px; right: 8px; width: 7px; height: 7px; border-radius: 50%; background: var(--ovt-accent); box-shadow: 0 0 0 2px var(--bg-card, #131c2e); }++.ovt-opts-dot[hidden] { display: none; }++.ovt-opts-panel { position: absolute; z-index: 40; left: -6px; bottom: calc(100% + 10px); width: min(380px, calc(100vw - 32px)); box-sizing: border-box; padding: 10px 10px 12px;++ border-radius: 18px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); transform-origin: 20px 100%;++ animation: ovt-opts-in .2s cubic-bezier(.2, .9, .3, 1) both; }++.ovt.is-start .ovt-opts-panel { bottom: auto; top: calc(100% + 10px); transform-origin: 20px 0; }++@keyframes ovt-opts-in { from { opacity: 0; transform: translateY(6px) scale(.97); } }++.ovt-opts-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 8px 8px; }++.ovt-opts-head b { font-size: 15px; font-weight: 700; }++.ovt-opts-x { width: 36px; height: 36px; }++.ovt-opts-body { display: grid; gap: 6px; }++/* Each picker is a full row: icon, what it is, the value. Low specificity, so a site's own picker styles still win. */++:where(.ovt-opts-body) > :where(label, .ovt-opt) { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 12px; border-radius: 12px; box-sizing: border-box;++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); color: var(--text-primary, #e6edf7); }++:where(.ovt-opts-body) :where(select, input[type="text"], input[type="url"], input:not([type])) { flex: 1; min-width: 0; height: 44px; border: 0; outline: 0; background: none; color: inherit; font: inherit; font-size: 15px; }++.ovt-opts-chips { display: contents; }++.ovt-opt-chip { display: inline-flex; align-items: center; gap: 2px; height: 32px; max-width: 200px; padding: 0 2px 0 12px; border-radius: 999px; box-sizing: border-box; cursor: pointer;++ background: color-mix(in srgb, var(--ovt-accent) 13%, transparent); color: var(--accent-light, #93c5fd); font-size: 13px; font-weight: 600; animation: ovt-chip-in .18s ease both; }++.ovt-opt-chip > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovt-opt-chip-x { flex: none; display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: 50%; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }++.ovt-opt-chip-x:hover { background: color-mix(in srgb, var(--ovt-accent) 20%, transparent); }++@keyframes ovt-chip-in { from { opacity: 0; transform: scale(.9); } }++html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }++@media (max-width: 640px) {++ .ovt-opts[open]::before { content: ''; position: fixed; inset: 0; z-index: 10064; background: rgba(0, 0, 0, .45); animation: ovt-fade .2s ease both; }++ .ovt-opts-panel, .ovt.is-start .ovt-opts-panel { position: fixed; z-index: 10065; left: 0; right: 0; top: auto; bottom: 0; width: auto; max-height: min(80dvh, 640px); overflow-y: auto; overscroll-behavior: contain;++ padding: 8px 14px calc(16px + env(safe-area-inset-bottom, 0px)); border-radius: 22px 22px 0 0; border-width: 1px 0 0; transform-origin: 50% 100%; animation: ovt-sheet-up .26s cubic-bezier(.2, .9, .3, 1) both; }++ .ovt-opts-panel::before { content: ''; display: block; width: 40px; height: 4px; margin: 0 auto 8px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 22%, transparent); }++}++@keyframes ovt-sheet-up { from { transform: translateY(100%); } }++@keyframes ovt-fade { from { opacity: 0; } }++++/* The examples walk through the placeholder with a fade. */++.ovt-composer textarea::placeholder { transition: opacity .22s ease; }++.ovt-composer textarea.is-ph-out::placeholder { opacity: 0; }++++/* The keyboard (navbar.js sets html.ov-kb-open and --ov-kb): a start page folds what it marks data-ovt-fold, so the box++ and what you type stay on the screen above the keyboard. */++:where([data-ovt-fold]) { transition: opacity .2s ease, max-height .28s cubic-bezier(.2, .9, .3, 1), margin .28s ease; max-height: 240px; }++@media (max-width: 760px) {++ html.ov-kb-open .ovt.is-start [data-ovt-fold] { opacity: 0; max-height: 0; margin-top: 0; margin-bottom: 0; overflow: hidden; pointer-events: none; }++ html.ov-kb-open .ovt.is-start .ovt-empty-state { margin-top: 6px; }++ /* A kit sidebar's button lives in the navbar (ov-thread.js); a head with nothing else in it goes. */++ .ovt.has-nav-lead .ovt-head [data-ovk-sidebar-open] { display: none; }++ .ovt.has-nav-lead .ovt-head:not(:has(> :not([data-ovk-sidebar-open], span.ovt-title:empty))) { display: none; }++}++@media (prefers-reduced-motion: reduce) {++ .ovt-opts-panel, .ovt-opt-chip, .ovt-opts[open]::before { animation: none; }++ :where([data-ovt-fold]) { transition: none; }++}++++/* Answers' actions are icon buttons (11-09): 32 px, the word as tooltip; a copy shows a tick for a moment. */++.ovt-acts .ovt-act { width: 32px; padding: 0; justify-content: center; }++.ovt-acts .ovt-act .ovt-ic { width: 17px; height: 17px; }++.ovt-act.is-done { color: var(--success, #22c55e); }++++/* Attach: one + button; with a menu, the choices in a small pop-over above it (a sheet's look on a phone). */++.ovt-attach { display: inline-grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 14px; cursor: pointer; color: var(--text-secondary, #96a7c2); transition: background-color .15s, color .15s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }++.ovt-attach:hover, .ovt-attach-menu[open] > .ovt-attach { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); color: var(--text-primary, #e6edf7); }++.ovt-attach:active { transform: scale(.92); }++.ovt-attach .ovt-ic { width: 20px; height: 20px; }++.ovt-attach-menu { position: relative; flex: none; }++.ovt-attach-menu > summary { list-style: none; }++.ovt-attach-menu > summary::-webkit-details-marker { display: none; }++.ovt-attach-pop { position: absolute; z-index: 40; left: -6px; bottom: calc(100% + 10px); min-width: 230px; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovt-opts-in .18s cubic-bezier(.2, .9, .3, 1) both; transform-origin: 20px 100%; }++.ovt.is-start .ovt-attach-pop { bottom: auto; top: calc(100% + 10px); transform-origin: 20px 0; }++.ovt-attach-row { position: relative; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 11px; color: var(--text-primary, #e6edf7); font-size: 14.5px; text-decoration: none; cursor: pointer; }++.ovt-attach-row:hover, .ovt-attach-row:focus-within { background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }++.ovt-attach-row .ovt-ic { width: 19px; height: 19px; color: var(--text-secondary, #96a7c2); }++.ovt-attach-cam { display: none; }++@media (pointer: coarse) { .ovt-attach-cam { display: flex; } }++++/* The quiet line ("can get things wrong", "Sending…") sits under the box, not inside it. */++.ovt-composer { position: relative; }++.ovt-composer > .ovt-c-note { position: absolute; left: 14px; right: 14px; top: calc(100% + 6px); margin: 0; text-align: center; font-size: 12px; line-height: 1.4; }++.ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); }++.ovt.is-start .ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: max(40px, env(safe-area-inset-bottom, 0px)); }@@@ -31,6 +31,16 @@ const P = {menu: 'M4 7h16M4 12h16M4 17h16',chev: 'M8 10l4 4 4-4',copy: 'M9 9h10v10H9zM5 15V5h10',++ retry: 'M4 12a8 8 0 0 1 14-5.3M20 4v4.5h-4.5M20 12a8 8 0 0 1-14 5.3M4 20v-4.5h4.5',++ like: 'M7 11v9H4v-9zM7 11l4-7c1.5 0 2.5 1 2.5 2.5L13 10h5.5a2 2 0 0 1 2 2.3l-1.2 6A2 2 0 0 1 17.3 20H7',++ dislike: 'M7 13V4H4v9zM7 13l4 7c1.5 0 2.5-1 2.5-2.5L13 14h5.5a2 2 0 0 0 2-2.3l-1.2-6A2 2 0 0 0 17.3 4H7',++ share: 'M12 15V4M8 8l4-4 4 4M5 13v6h14v-6',++ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',++ photo: 'M4 6h16v12H4zM4 15l4.5-4 3.5 3 2.5-2 5.5 4.5M15.5 9.5h.01',++ camera: 'M4 8h3.5L9 5.5h6L16.5 8H20v11H4zM12 11a3 3 0 1 1 0 6 3 3 0 0 1 0-6z',++ file: 'M6 3h8l4 4v14H6zM14 3v4h4',++ cloud: 'M7 18h10.5a3.5 3.5 0 0 0 .3-7 5.5 5.5 0 0 0-10.6-1.5A4 4 0 0 0 7 18z',++ add: 'M12 5v14M5 12h14',more: 'M5 12h.01M12 12h.01M19 12h.01',tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',ok: 'M5 12l5 5 9-10',@@@ -42,6 +52,7 @@ const P = {send: 'M5 12l14-7-5 14-2.5-5.5z',stop: 'M7 7h10v10H7z',attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',++ options: 'M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5',handover: 'M4 8h13l-3-3M20 16H7l3 3',wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',panel: 'M4 4h16v16H4zM15 4v16',@@@ -192,8 +203,10 @@ ${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>`function answer({ html = '', files: att = [], sources = [], meta = [], actions = [], copy = true } = {}) {const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return clip(u, 40); } };const acts = [-- copy ? `<button type="button" class="ovt-act" data-ovt-copy hidden>${icon('copy')}<span>Copy</span></button>` : '',-- ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}">${icon(a.icon || 'more')}<span>${esc(a.label)}</span></a>`),++ // Icon buttons, as every assistant draws them (11-09, the owner: "Copy and Try again should be actual icons"): the++ // word is the tooltip and the accessible name.++ copy ? `<button type="button" class="ovt-act" data-ovt-copy title="Copy" aria-label="Copy" hidden>${icon('copy')}<span class="ovt-sr">Copy</span></button>` : '',++ ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}" title="${esc(a.label)}" aria-label="${esc(a.label)}">${icon(a.icon || (/again|retry/i.test(String(a.label)) ? 'retry' : 'more'))}<span class="ovt-sr">${esc(a.label)}</span></a>`),].join('');return `<div class="ovt-answer" data-ovt-answer>${html}</div>${files(att)}@@@ -230,6 +243,32 @@ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {return `<aside class="ovt-panel" id="${esc(id)}" aria-label="${esc(title)}" data-ovt-panel><div class="ovt-panel-head"><h2>${esc(title)}</h2><button type="button" class="ovt-btn ovt-panel-close" data-ovt-panel-close aria-label="Close">${icon('close')}</button></div>${secs}</aside>`;}++/**++ * Attach (11-09, the owner: "a better file attach button… support all file types"): one + button. Every file type by++ * default (attach.accept narrows it). With attach.menu, a small menu: Files, Photos and videos, Take a photo (a phone's++ * camera), and From your OpenVibe files when the site gives attach.library (an href). All the inputs share one name, so++ * the plain form posts them all.++ */++function attachHtml(attach) {++ const name = esc(attach.name || 'files');++ const input = (accept, extra = '') => `<input type="file" name="${name}"${accept ? ` accept="${esc(accept)}"` : ''} multiple data-ovt-files${extra} hidden>`;++ if (!attach.menu) return `<label class="ovt-btn ovt-attach" title="Attach files">${icon('add')}<span class="ovt-sr">Attach files</span>${input(attach.accept)}</label>`;++ const row = (ic, label, inner) => `<label class="ovt-attach-row" role="menuitem">${icon(ic)}<span>${esc(label)}</span>${inner}</label>`;++ return `<details class="ovt-attach-menu" data-ovt-attach><summary class="ovt-btn ovt-attach" title="Attach" aria-label="Attach">${icon('add')}</summary><div class="ovt-attach-pop" role="menu">`++ + row('file', 'Files', input(attach.accept))++ + row('photo', 'Photos and videos', input('image/*,video/*'))++ + `<label class="ovt-attach-row ovt-attach-cam" role="menuitem">${icon('camera')}<span>Take a photo</span>${input('image/*', ' capture="environment"')}</label>`++ + (attach.library ? `<a class="ovt-attach-row" role="menuitem" href="${esc(attach.library)}">${icon('cloud')}<span>From your OpenVibe files</span></a>` : '')++ + '</div></details>';++}++++/** The composer's Options: one button, the pickers behind it, and the chips of what differs from the defaults. */++function optionsHtml({ html, label = 'Options' }) {++ return `<details class="ovt-opts" data-ovt-opts><summary class="ovt-btn ovt-opts-btn" aria-label="${esc(label)}" title="${esc(label)}">${icon('options')}<i class="ovt-opts-dot" aria-hidden="true"></i></summary>`++ + `<div class="ovt-opts-panel" role="group" aria-label="${esc(label)}"><div class="ovt-opts-head"><b>${esc(label)}</b><button type="button" class="ovt-btn ovt-opts-x" data-ovt-opts-close aria-label="Done">${icon('close')}</button></div><div class="ovt-opts-body">${html}</div></div></details>`++ + '<span class="ovt-opts-chips" data-ovt-opts-chips></span>';++}++/*** The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model* chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script.@@@ -239,15 +278,20 @@ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {* id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes* foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that,* which is also the form's status line (role="status": a site's script says "Sending…" there)++ * options: { html, label } the site's safe pickers behind one Options button (11-09, the owner: "too many options…++ * hidden under a cog"): a panel on a computer, a sheet on a phone, a <details> without script. A picker whose++ * <select> or radio group carries data-ovt-default="<value>" shows as a chip beside the button while it differs++ * (its × puts the default back), and the button wears a dot.++ * examples: ['Make the home page faster on phones', …] the box's placeholder walks through them while it is empty*/--function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '' } = {}) {++function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [] } = {}) {const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>--<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>--<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>++<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''}${examples && examples.length ? ` data-ovt-examples="${esc(JSON.stringify(examples.slice(0, 12).map((x) => String(x).slice(0, 140))))}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>++<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}</div>${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')}</p></form>`;
@@ -1,5 +1,46 @@
@@ -1,5 +1,46 @@
# Changelog
# Changelog
+## 4.160.0 — 2026-11-09
+
+- **Feed v2: the menu's Feed is a real feed** (the owner, 11-09: "a real feed… you scroll through easy content… a filter
+ system… clean, intuitive"). When Community's `/api/v1/feed` answers, the Feed tab is the composer, today's numbers, the
+ live bar and one stream of cards by kind (live, clips and videos with a muted preview that plays only in view and never
+ with reduced motion or data saver, pictures, posts, memes, games, compact updates), chips on top (For you, Live, Clips,
+ Videos, Pics, Posts, Memes, Games, Updates), more as the end comes near, "You're all caught up" at the end; without
+ the API, the wall and its sections stand as before.
+
+- **Every section heading has an icon, and sections stand apart** (the owner, 11-09: "these section titles should have an
+ icon… look nicer and more consistent, separate things… a universal system"). The showcase kit's headings carry an icon
+ tile (features: grid, steps, code, demo, compare, pricing, limits, stories, stats, cards, spotlight, questions, network,
+ apps each have a default; `icon:` picks another); each section has a hairline and room above it.
+- **More on OpenVibe** (was "Part of OpenVibe"): the related sites with their icons, a live "Happening on OpenVibe" strip
+ (who is live and the sites' trending items, from Network's Frame, text only) when the page includes `sc.NETWORK_JS`,
+ and "Every OpenVibe site →" to openvibe.network/sites.
+
+- **`openvibe-shared/ai-marks`: the AI makers' marks, one place** (the AI comparison plan, R3; the owner, 11-09: our
+ hand-drawn family glyphs read as "what is it even supposed to be?"). `family(idOrName)` finds the maker from a model
+ id, slug, family or provider name (16 makers: OpenAI, Anthropic, Google, Gemma, Meta, DeepSeek, Qwen, Mistral,
+ Moonshot, Zhipu, MiniMax, Microsoft, xAI, NVIDIA, Cohere, Perplexity); `mark()` draws it monochrome in a theme tile,
+ labelled with the maker's name; anyone else gets the neutral spark. Drawings from @lobehub/icons-static-svg (MIT),
+ regenerated by `scripts/build-ai-marks.js`.
+- **The composer's Options, the navbar's own buttons, the keyboard** (the owner, 11-09, on openvibe.codes: "too many
+ options… hidden under a cog"; the sidebar button "to the left of the logo… the navbar SDK and API… other people can add
+ icons"; the keyboard "takes up a lot of space"). `thread.composer({ options, examples })`: one Options button with the
+ pickers behind it (a panel on a computer, a sheet on a phone with the page held still, a `<details>` without script),
+ changed pickers as chips with ×, a dot on the button, and a placeholder that walks through examples.
+ `OpenVibeNavbar.lead(spec)` and `.action(spec)`: a site's own buttons in the bar; a kit sidebar's toggle moves there on
+ a phone. `html.ov-kb-open` and `--ov-kb` on every site; start pages fold `data-ovt-fold`; the Dock's phone sheet ends
+ above the keyboard and no longer opens it by itself on a touch screen.
+- **The Dock's pin is an upright pushpin** whose lines never cross (it read as a pin with a line through it).
+- **The composer's attach, answers' actions and the quiet line** (the owner, 11-09, on Vibe: "the image button is broken…
+ support all file types… a better file attach button… Copy and Try again should be actual icons… 'Vibe can get things
+ wrong' should be under"): attach is one + button for every file type, and with `attach: { menu: true, library }` a
+ menu of Files, Photos and videos, Take a photo (phones) and From your OpenVibe files; Copy and a site's actions are
+ icon buttons named by their word (a tick when copied; Try again draws the retry arrows); the composer's note sits
+ under the box.
+- **A phone's drawer holds the page still** (kit.sidebar): the page behind cannot scroll and the drawer never hands its
+ scroll on (the owner: a swipe on the drawer "scrolls the actual page").
+
## 4.159.0 — 2026-11-09
## 4.159.0 — 2026-11-09
- **Homes v2: `openvibe-shared/showcase-stage`** (plan `OpenVibe_Homes_v2_Plan_2026-11-09.md`; the owner, 11-09: "make the
- **Homes v2: `openvibe-shared/showcase-stage`** (plan `OpenVibe_Homes_v2_Plan_2026-11-09.md`; the owner, 11-09: "make the
@@ -11,8 +52,6 @@
@@ -11,8 +52,6 @@
Server-rendered words and numbers; motion only adds, never inside an SVG's children, and stops for reduced motion and
Server-rendered words and numbers; motion only adds, never inside an SVG's children, and stops for reduced motion and
Calm. Its CSS (`CSS`) and starter (`MOTION_JS`) are separate, so homes that do not use it do not grow.
Calm. Its CSS (`CSS`) and starter (`MOTION_JS`) are separate, so homes that do not use it do not grow.
-## Unreleased
-
- **The menu's You tab is a home screen** (owner, 11-09: "make it more like a phone"). `ov-home.js` (a sibling, loaded
- **The menu's You tab is a home screen** (owner, 11-09: "make it more like a phone"). `ov-home.js` (a sibling, loaded
the first time You or Feed opens): your Actor on top (its state and step, or one invitation), Recent (the last five
the first time You or Feed opens): your Actor on top (its state and step, or one invitation), Recent (the last five
places opened), then Home: your places in a grid, the most popular until you arrange it, with Tools (every tool with
places opened), then Home: your places in a grid, the most popular until you arrange it, with Tools (every tool with
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.159.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.160.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.159.0",
+ "version": "4.160.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+// Generated by scripts/build-ai-marks.js from @lobehub/icons-static-svg 1.102.0 (MIT, © LobeHub,
+// https://github.com/lobehub/lobe-icons). Do not edit: re-run the script. Each mark is its owner's trademark, used here to
+// name the maker of a model.
+module.exports = {
+ "OpenAI": "<path d=\"M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z\"></path>",
+ "Anthropic": "<path d=\"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z\"></path>",
+ "Google": "<path d=\"M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z\"></path>",
+ "Gemma": "<path d=\"M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z\"></path>",
+ "Meta": "<path d=\"M6.897 4c1.915 0 3.516.932 5.43 3.376l.282-.373c.19-.246.383-.484.58-.71l.313-.35C14.588 4.788 15.792 4 17.225 4c1.273 0 2.469.557 3.491 1.516l.218.213c1.73 1.765 2.917 4.71 3.053 8.026l.011.392.002.25c0 1.501-.28 2.759-.818 3.7l-.14.23-.108.153c-.301.42-.664.758-1.086 1.009l-.265.142-.087.04a3.493 3.493 0 01-.302.118 4.117 4.117 0 01-1.33.208c-.524 0-.996-.067-1.438-.215-.614-.204-1.163-.56-1.726-1.116l-.227-.235c-.753-.812-1.534-1.976-2.493-3.586l-1.43-2.41-.544-.895-1.766 3.13-.343.592C7.597 19.156 6.227 20 4.356 20c-1.21 0-2.205-.42-2.936-1.182l-.168-.184c-.484-.573-.837-1.311-1.043-2.189l-.067-.32a8.69 8.69 0 01-.136-1.288L0 14.468c.002-.745.06-1.49.174-2.23l.1-.573c.298-1.53.828-2.958 1.536-4.157l.209-.34c1.177-1.83 2.789-3.053 4.615-3.16L6.897 4zm-.033 2.615l-.201.01c-.83.083-1.606.673-2.252 1.577l-.138.199-.01.018c-.67 1.017-1.185 2.378-1.456 3.845l-.004.022a12.591 12.591 0 00-.207 2.254l.002.188c.004.18.017.36.04.54l.043.291c.092.503.257.908.486 1.208l.117.137c.303.323.698.492 1.17.492 1.1 0 1.796-.676 3.696-3.641l2.175-3.4.454-.701-.139-.198C9.11 7.3 8.084 6.616 6.864 6.616zm10.196-.552l-.176.007c-.635.048-1.223.359-1.82.933l-.196.198c-.439.462-.887 1.064-1.367 1.807l.266.398c.18.274.362.56.55.858l.293.475 1.396 2.335.695 1.114c.583.926 1.03 1.6 1.408 2.082l.213.262c.282.326.529.54.777.673l.102.05c.227.1.457.138.718.138.176.002.35-.023.518-.073.338-.104.61-.32.813-.637l.095-.163.077-.162c.194-.459.29-1.06.29-1.785l-.006-.449c-.08-2.871-.938-5.372-2.2-6.798l-.176-.189c-.67-.683-1.444-1.074-2.27-1.074z\"></path>",
+ "DeepSeek": "<path d=\"M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z\"></path>",
+ "Qwen": "<path d=\"M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z\"></path>",
+ "Mistral": "<path clip-rule=\"evenodd\" d=\"M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z\"></path>",
+ "Kimi": "<path d=\"M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z\"></path><path d=\"M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z\"></path>",
+ "GLM": "<path d=\"M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z\"></path>",
+ "MiniMax": "<path d=\"M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z\"></path>",
+ "Microsoft": "<path d=\"M11.49 2H2v9.492h9.492V2h-.002z\"></path><path d=\"M22 2h-9.492v9.492H22V2z\"></path><path d=\"M11.49 12.508H2V22h9.492v-9.492h-.002z\"></path><path d=\"M22 12.508h-9.492V22H22v-9.492z\"></path>",
+ "xAI": "<path d=\"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815\"></path>",
+ "NVIDIA": "<path d=\"M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z\"></path>",
+ "Cohere": "<path clip-rule=\"evenodd\" d=\"M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z\"></path><path clip-rule=\"evenodd\" d=\"M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z\"></path><path d=\"M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z\"></path>",
+ "Perplexity": "<path d=\"M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z\"></path>"
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+/**
+ * openvibe-shared/ai-marks — the AI makers' marks, one place for every site that lists models (Exchange, Report, Chat,
+ * Bench, Design). The owner, 11-09, on Exchange's home: model-family glyphs we drew ourselves read as "what is it even
+ * supposed to be?"; the AI comparison plan asks for the makers' recognisable marks, as every leaderboard shows them.
+ *
+ * const marks = require('openvibe-shared/ai-marks');
+ * marks.family('deepseek/deepseek-chat') → 'DeepSeek' (a model id, a slug, a family or a provider name)
+ * marks.mark('anthropic', { size: 24 }) → <span class="ov-aim ov-aim--anthropic" …><svg …>…</svg></span>
+ * marks.mark('someone-new') → the neutral spark (never a letter in a circle, never another maker's mark)
+ * marks.CSS the few rules (a tile in the theme's tokens), once per page
+ *
+ * Marks are monochrome in currentColor (they follow the theme); the maker's name is the label (label: false when the row
+ * already names it). The drawings come from @lobehub/icons-static-svg (MIT; see ai-marks-data.js and NOTICE in it).
+ */
+const DATA = require('./ai-marks-data');
+
+const NAMES = {
+ OpenAI: 'OpenAI', Anthropic: 'Anthropic', Google: 'Google', Gemma: 'Gemma', Meta: 'Meta', DeepSeek: 'DeepSeek', Qwen: 'Qwen',
+ Mistral: 'Mistral AI', Kimi: 'Moonshot AI', GLM: 'Zhipu AI', MiniMax: 'MiniMax', Microsoft: 'Microsoft', xAI: 'xAI',
+ NVIDIA: 'NVIDIA', Cohere: 'Cohere', Perplexity: 'Perplexity',
+};
+// Order matters: the more specific first (gemma before gemini/google; grok before x).
+const RULES = [
+ [/gemma/i, 'Gemma'], [/gemini|google|palm|bard/i, 'Google'], [/claude|anthropic/i, 'Anthropic'],
+ [/deepseek/i, 'DeepSeek'], [/qwen|qwq|alibaba|tongyi/i, 'Qwen'], [/kimi|moonshot/i, 'Kimi'], [/glm|zhipu|z\.ai|chatglm/i, 'GLM'],
+ [/minimax|abab/i, 'MiniMax'], [/llama|meta\b|^meta|facebook/i, 'Meta'], [/mistral|mixtral|codestral|ministral|magistral|pixtral/i, 'Mistral'],
+ [/\bphi-?\d|microsoft|wizardlm/i, 'Microsoft'], [/grok|x-ai|\bxai\b/i, 'xAI'], [/nvidia|nemotron/i, 'NVIDIA'],
+ [/cohere|command-?r|\baya\b/i, 'Cohere'], [/perplexity|sonar/i, 'Perplexity'],
+ [/openai|gpt|\bo[134](-|\b)|chatgpt|davinci|whisper|dall-?e/i, 'OpenAI'],
+];
+const SPARK = '<path d="M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>';
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** The maker's family key for a model id, slug, family or provider name; null when none matches. */
+function family(text) {
+ const t = String(text || '').trim();
+ if (!t) return null;
+ const exact = Object.keys(DATA).find((k) => k.toLowerCase() === t.toLowerCase());
+ if (exact) return exact;
+ for (const [re, key] of RULES) if (re.test(t)) return key;
+ return null;
+}
+
+/** The mark as HTML: a tile with the maker's drawing (or the neutral spark), labelled with the maker's name. */
+function mark(text, { size = 24, label = true, cls = '' } = {}) {
+ const key = family(text);
+ const px = Math.max(12, Math.min(96, Number(size) || 24));
+ const name = key ? NAMES[key] : String(text || 'Other');
+ const svg = `<svg viewBox="0 0 24 24" width="${Math.round(px * 0.62)}" height="${Math.round(px * 0.62)}" fill="currentColor" fill-rule="evenodd" aria-hidden="true" focusable="false">${key ? DATA[key] : SPARK}</svg>`;
+ return `<span class="ov-aim ov-aim--${key ? key.toLowerCase() : 'other'}${cls ? ` ${esc(cls)}` : ''}" style="--ov-aim:${px}px"${label ? ` role="img" aria-label="${esc(name)}" title="${esc(name)}"` : ' aria-hidden="true"'}>${svg}</span>`;
+}
+
+const CSS = '.ov-aim{display:inline-grid;place-items:center;flex:none;width:var(--ov-aim,24px);height:var(--ov-aim,24px);border-radius:calc(var(--ov-aim,24px) * .28);'
+ + 'background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent);color:var(--text-primary,#e8ecf3);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.1))}'
+ + '.ov-aim svg{display:block}.ov-aim--other{color:var(--accent,#3b82f6)}';
+
+module.exports = { family, mark, CSS, FAMILIES: Object.keys(DATA), NAMES };
@@ -269,7 +269,9 @@
@@ -269,7 +269,9 @@
list.append(h('li', 'ovdk-wait', null));
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
list.firstChild.innerHTML = '<span></span>';
await pull(true);
await pull(true);
- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ // A touch screen does not get the keyboard on opening (11-09, the owner: it "immediately opens the keyboard… you
+ // can't see the chat input or what it did"): the answer and the box show first; a tap on the box types.
+ if (!(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
}
function mount(panel, c) {
function mount(panel, c) {
@@ -35,7 +35,8 @@
@@ -35,7 +35,8 @@
const G = {
const G = {
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
- pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',
+ // An upright pushpin whose lines never cross (owner, 11-09: the old one had "a line going through it").
+ pin: '<path d="M7.5 3.5h9M10 3.5v5.3L6.5 13h11L14 8.8V3.5M12 13v7.5"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
@@ -101,7 +102,7 @@
@@ -101,7 +102,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': 'fa6d48d86c71', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
+ const DOCK_REV = { 'dock-vibe.js': '40a5f8e3970b', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'ad21e7bb4306', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -261,7 +262,7 @@
@@ -261,7 +262,7 @@
.ovdk-err[hidden] { display: none; }
.ovdk-err[hidden] { display: none; }
.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
@media (max-width: 640px) {
@media (max-width: 640px) {
- .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: 0; width: auto !important; height: auto !important; max-width: none; max-height: none;
+ .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: var(--ov-kb, 0px); width: auto !important; height: auto !important; max-width: none; max-height: none;
border: 0; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: none; transform-origin: 50% 100%; }
border: 0; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: none; transform-origin: 50% 100%; }
.ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }
.ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }
.ovdk-head { cursor: default; }
.ovdk-head { cursor: default; }
@@ -698,7 +699,7 @@
@@ -698,7 +699,7 @@
list.append(h('li', 'ovdk-wait', null));
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
list.firstChild.innerHTML = '<span></span>';
await pull(list, scroll, true);
await pull(list, scroll, true);
- if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
+ if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
}
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
async function pull(list, scroll, first) {
async function pull(list, scroll, first) {
@@ -22,7 +22,7 @@ const BROWSER = Object.freeze([
@@ -22,7 +22,7 @@ const BROWSER = Object.freeze([
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
- 'presence.js', 'overlay.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
+ 'presence.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
@@ -31,6 +31,8 @@
@@ -31,6 +31,8 @@
function setSidebar(sb, on) {
function setSidebar(sb, on) {
if (!sb) return;
if (!sb) return;
sb.classList.toggle('is-open', on);
sb.classList.toggle('is-open', on);
+ // The page behind a phone's drawer stays put (11-09, the owner: a swipe on the drawer "scrolls the actual page").
+ document.documentElement.classList.toggle('ovk-drawer-open', !!on);
const wrap = sb.closest('.ovk-with-sidebar') || document;
const wrap = sb.closest('.ovk-with-sidebar') || document;
const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]');
const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]');
if (scrim) scrim.hidden = !on;
if (scrim) scrim.hidden = !on;
@@ -434,3 +434,11 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
@@ -434,3 +434,11 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }
.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }
.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+
+/* A phone's drawer holds the page still: the page cannot scroll behind it, the drawer scrolls on its own and never hands
+ the scroll on to the page, and the scrim takes no scroll gestures (11-09). */
+@media (max-width: 760px) {
+ html.ovk-drawer-open, html.ovk-drawer-open body { overflow: hidden; overscroll-behavior: none; }
+ .ovk-sidebar { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
+ .ovk-sidebar-scrim { touch-action: none; overscroll-behavior: contain; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+/**
+ * nav-slots.js (openvibe-shared 4.160.0): a site's own buttons in the navbar, drawn here so the bar stays small. The
+ * navbar loads this the first time a site calls OpenVibeNavbar.lead(spec) or .action(spec) and calls paint() after each
+ * render. Known icons by name, or 24-unit path data (letters, numbers and path commands only: no markup from a site).
+ */
+(function (root) {
+ 'use strict';
+ const CSS = `.openvibe-navbar .ovnav-slot { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }
+.openvibe-navbar .ovnav-slot:hover, .openvibe-navbar .ovnav-slot:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--text-primary, #e6edf7); }
+.openvibe-navbar .ovnav-slot:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.openvibe-navbar .ovnav-slot:active { transform: scale(.9); }
+.openvibe-navbar .ovnav-slot[aria-expanded="true"], .openvibe-navbar .ovnav-slot[aria-pressed="true"] { color: var(--accent-light, #60a5fa); background: var(--accent-glow, rgba(59,130,246,.14)); }
+.openvibe-navbar .ovnav-slot svg { width: 22px; height: 22px; }
+.openvibe-navbar .ovnav-slot b { position: absolute; top: -1px; right: -3px; min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff; font: 700 10px/16px Inter, system-ui, sans-serif; text-align: center; box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }
+.openvibe-navbar .ovnav-slot b:empty { display: none; }
+.openvibe-navbar .ovnav-lead { margin-left: -6px; animation: ovnav-slot-in .22s cubic-bezier(.2, .9, .3, 1) both; }
+@keyframes ovnav-slot-in { from { opacity: 0; transform: translateX(-6px); } }
+@media (prefers-reduced-motion: reduce) { .openvibe-navbar .ovnav-lead { animation: none; } }
+@media (max-width: 400px) { .openvibe-navbar .ovnav-slot { width: 32px; height: 32px; } }
+@media (min-width: 761px) { .openvibe-navbar .ovnav-slot.ovnav-phone-only { display: none; } }`;
+ const SLOT_ICONS = {
+ sidebar: 'M4 5.5h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1zM9.5 5.5v13',
+ back: 'M15 5l-7 7 7 7',
+ close: 'M6 6l12 12M18 6L6 18',
+ plus: 'M12 5v14M5 12h14',
+ search: 'M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM15.5 15.5L20 20',
+ settings: 'M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zM12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7',
+ filter: 'M4 6h16M7 12h10M10 18h4',
+ share: 'M12 15V4M8 8l4-4 4 4M5 13v6h14v-6',
+ star: 'M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.7l5.9-.9z',
+ info: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM12 11v5.5M12 7.6v.4',
+ };
+ const PATH_OK = /^[MmLlHhVvCcSsQqTtAaZz0-9 ,.\-]{1,2000}$/;
+ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ function html(spec, cls, id) {
+ const label = esc(spec.label || 'Menu');
+ const ctl = spec.sidebar || spec.controls;
+ const state = (spec.sidebar ? ' data-ovk-sidebar-open' : '') + (ctl ? ` aria-controls="${esc(ctl)}" aria-expanded="${spec.expanded ? 'true' : 'false'}"` : spec.pressed != null ? ` aria-pressed="${spec.pressed ? 'true' : 'false'}"` : '');
+ if (spec.phoneOnly) cls += ' ovnav-phone-only';
+ const d = spec.path && PATH_OK.test(spec.path) ? spec.path : SLOT_ICONS[spec.icon] || SLOT_ICONS.info;
+ const svg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
+ const badge = spec.badge != null ? `<b>${esc(String(spec.badge).slice(0, 3))}</b>` : '';
+ const href = spec.href && /^(https?:\/\/|\/)/.test(String(spec.href)) ? spec.href : null;
+ return href ? `<a class="ovnav-slot ${cls}" id="${esc(id)}" href="${esc(href)}" aria-label="${label}" title="${label}"${state}>${svg}${badge}</a>`
+ : `<button type="button" class="ovnav-slot ${cls}" id="${esc(id)}" aria-label="${label}" title="${label}"${state}>${svg}${badge}</button>`;
+ }
+ /** Draw the lead button before the brand and the actions before the bell; clicks go to the site's handlers. */
+ function paint(nav, lead, actions) {
+ if (!nav || !nav.querySelector) return;
+ if (!document.getElementById('ovnav-slots-styles')) { const st = document.createElement('style'); st.id = 'ovnav-slots-styles'; st.textContent = CSS; document.head.appendChild(st); }
+ nav.querySelectorAll('.ovnav-slot').forEach((el) => el.remove());
+ const brand = nav.querySelector('.openvibe-navbar-brand');
+ if (lead && brand) brand.insertAdjacentHTML('afterbegin', html(lead, 'ovnav-lead', 'ovnav-lead'));
+ const bell = nav.querySelector('#openvibe-bell-mount');
+ if (bell && actions) for (const a of actions.values()) bell.insertAdjacentHTML('beforebegin', html(a, 'ovnav-act', `ovnav-act-${a.id}`));
+ nav.__ovSlots = { lead, actions };
+ if (nav.__ovSlotsBound) return;
+ nav.__ovSlotsBound = true;
+ nav.addEventListener('click', (e) => {
+ const b = e.target && e.target.closest ? e.target.closest('.ovnav-slot') : null;
+ if (!b || !nav.__ovSlots) return;
+ const s = b.id === 'ovnav-lead' ? nav.__ovSlots.lead : nav.__ovSlots.actions.get(b.id.replace(/^ovnav-act-/, ''));
+ if (s && typeof s.onClick === 'function') { if (!s.href) e.preventDefault(); try { s.onClick(e, b); } catch (err) { console.error(err); } }
+ });
+ }
+ root.OpenVibeNavSlots = { paint, icons: Object.keys(SLOT_ICONS) };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -13,6 +13,9 @@
@@ -13,6 +13,9 @@
const _runtimeMenu = { before: [], after: [] };
const _runtimeMenu = { before: [], after: [] };
let _runtimeLinks = null;
let _runtimeLinks = null;
let _navEl = null;
let _navEl = null;
+ // A site's own buttons (lead before the brand, actions before the bell): nav-slots.js.
+ let _lead = null;
+ const _actions = new Map();
function injectStyles() {
function injectStyles() {
if (document.getElementById('openvibe-navbar-styles')) return;
if (document.getElementById('openvibe-navbar-styles')) return;
@@ -547,7 +550,7 @@
@@ -547,7 +550,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '04ac8238e9ed', 'dock.js': '45215d0d60f4', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '267bf753169d' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '6c1578a7dfdf', 'dock.js': '4d29a2d5ecb4', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '8229ff459796', 'nav-slots.js': 'a5af310d62e0' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -913,6 +916,40 @@
@@ -913,6 +916,40 @@
</div>`;
</div>`;
}
}
+ // The on-screen keyboard: html.ov-kb-open and --ov-kb (its height, against the tallest view seen without one).
+ let _kbWatch = false;
+ function watchKeyboard() {
+ if (_kbWatch || typeof window === 'undefined' || !window.visualViewport || typeof document === 'undefined') return;
+ _kbWatch = true;
+ const vv = window.visualViewport; const html = document.documentElement;
+ let raf = 0; let base = 0; let turned = typeof screen !== 'undefined' && screen.orientation ? screen.orientation.type : '';
+ const typing = () => { const a = document.activeElement; return !!a && (a.isContentEditable || a.tagName === 'TEXTAREA' || (a.tagName === 'INPUT' && !/^(button|checkbox|radio|range|color|file|submit|reset|image|hidden)$/i.test(a.type || ''))); };
+ const paint = () => {
+ raf = 0;
+ const o = typeof screen !== 'undefined' && screen.orientation ? screen.orientation.type : '';
+ if (o !== turned) { turned = o; base = 0; }
+ const t = typing();
+ if (!t) base = Math.max(base, vv.height);
+ const kb = Math.max(0, Math.round(Math.max(base, window.innerHeight) - vv.height - vv.offsetTop));
+ const on = t && kb > 120;
+ if (html.classList.contains('ov-kb-open') !== on) html.classList.toggle('ov-kb-open', on);
+ html.style.setProperty('--ov-kb', `${on ? kb : 0}px`);
+ };
+ const later = () => { if (!raf) raf = requestAnimationFrame(paint); };
+ vv.addEventListener('resize', later); vv.addEventListener('scroll', later);
+ document.addEventListener('focusin', later); document.addEventListener('focusout', () => setTimeout(later, 60));
+ later();
+ }
+
+ // A site's buttons: nav-slots.js draws them, loaded on first use; each render paints them again.
+ function paintSlots() {
+ if (!_navEl || typeof document === 'undefined') return;
+ if (root.OpenVibeNavSlots) { root.OpenVibeNavSlots.paint(_navEl, _lead, _actions); return; }
+ if (!_lead && !_actions.size) return;
+ if (document.getElementById('ov-slots-loader')) return;
+ try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ }
+ }
+
// The OpenVibe menu's button: nine dots as a diamond (the old launcher's, which the owner liked), the centre in the
// The OpenVibe menu's button: nine dots as a diamond (the old launcher's, which the owner liked), the centre in the
// accent. Rings: 0 the centre, 1 the edges, 2 the corners (the ripple's order; corners and centre make the ×).
// accent. Rings: 0 the centre, 1 the edges, 2 the corners (the ripple's order; corners and centre make the ×).
const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];
const ORB_DOTS = [[12, 12, 0], [12, 5, 1], [19, 12, 1], [12, 19, 1], [5, 12, 1], [5, 5, 2], [19, 5, 2], [19, 19, 2], [5, 19, 2]];
@@ -2189,6 +2226,8 @@
@@ -2189,6 +2226,8 @@
document.body.prepend(nav);
document.body.prepend(nav);
}
}
_navEl = nav;
_navEl = nav;
+ paintSlots();
+ watchKeyboard();
attachAvatarFallbacks(nav);
attachAvatarFallbacks(nav);
hookTop();
hookTop();
loadWalletChips();
loadWalletChips();
@@ -2552,6 +2591,20 @@
@@ -2552,6 +2591,20 @@
* settles; a page that draws such a box later may call this). Returns how many it marked. */
* settles; a page that draws such a box later may call this). Returns how many it marked. */
focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
+ /** A button before the brand: { icon | path, label, controls, sidebar, phoneOnly, pressed, href, onClick }; null removes it. README: Navbar slots. */
+ lead(spec) {
+ _lead = spec ? { ...spec } : null;
+ paintSlots();
+ return { set(patch) { if (_lead) { Object.assign(_lead, patch || {}); paintSlots(); } }, remove() { _lead = null; paintSlots(); } };
+ },
+ /** An icon button before the bell: lead()'s spec plus id and badge. */
+ action(spec) {
+ if (!spec || !spec.id || !/^[a-z0-9-]{1,40}$/.test(spec.id)) throw new Error('OpenVibeNavbar.action needs an id: a-z, 0-9 and -');
+ _actions.set(spec.id, { ...spec });
+ paintSlots();
+ return { set(patch) { const a = _actions.get(spec.id); if (a) { Object.assign(a, patch || {}); paintSlots(); } }, remove() { _actions.delete(spec.id); paintSlots(); } };
+ },
+
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
askAgent(text) { askAgent(text); },
askAgent(text) { askAgent(text); },
@@ -75,6 +75,7 @@
@@ -75,6 +75,7 @@
background: var(--bg-secondary, #141a24); box-shadow: 0 12px 30px rgba(0,0,0,.35), inset 0 0 0 1px var(--border, rgba(255,255,255,.14)); transform: scale(1.08); }
background: var(--bg-secondary, #141a24); box-shadow: 0 12px 30px rgba(0,0,0,.35), inset 0 0 0 1px var(--border, rgba(255,255,255,.14)); transform: scale(1.08); }
@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }
@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }
.ovh-hint { margin: 2px 6px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-hint { margin: 2px 6px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
+.ovh-all[hidden], .ovh[hidden] { display: none !important; }
.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));
.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));
border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }
.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }
@@ -578,7 +579,10 @@
@@ -578,7 +579,10 @@
const draw = (j, append) => {
const draw = (j, append) => {
if (!append) list.textContent = '';
if (!append) list.textContent = '';
const items = (j && Array.isArray(j.items) ? j.items : []).filter((x) => x && typeof x.title === 'string' && safe(x.url));
const items = (j && Array.isArray(j.items) ? j.items : []).filter((x) => x && typeof x.title === 'string' && safe(x.url));
- if (!items.length && !append) { list.append(h('p', 'ovh-note', 'Nothing new on the network yet today.')); return; }
+ // Nothing to show: the section goes, not a line saying so (11-09, the owner: "why does it say show more if there's
+ // nothing new").
+ if (!items.length && !append) { el.hidden = true; more.hidden = true; return; }
+ el.hidden = false;
// Went live, back to back: one row of names.
// Went live, back to back: one row of names.
let lives = null;
let lives = null;
items.forEach((x) => {
items.forEach((x) => {
@@ -612,5 +616,164 @@
@@ -612,5 +616,164 @@
more.addEventListener('click', async () => { more.disabled = true; const j = await loadPulse(o, next); more.disabled = false; if (j) draw(j, true); });
more.addEventListener('click', async () => { more.disabled = true; const j = await loadPulse(o, next); more.disabled = false; if (j) draw(j, true); });
}
}
- root.OpenVibeHome = { home, wallTop, timeline, loadPulse, _defaultHome: defaultHome };
+
+ // ── Feed v2: a real feed (owner, 11-09: "a real feed… you scroll through easy content… sections or a filter system…
+ // clean, intuitive"). Community's GET /api/v1/feed (plan "Feed v2"): one stream of cards by kind, chips on top, more as
+ // you near the end. When the API is not there yet, the pulse wall above stands (timeline()).
+ const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', 'Videos'], ['pic', 'Pics'], ['post', 'Posts'], ['meme', 'Memes'], ['game', 'Games'], ['update', 'Updates']];
+ function feedUrl(o, kind, cursor) {
+ const p = new URLSearchParams({ limit: '12' });
+ if (kind) p.set('kind', kind);
+ if (cursor) p.set('cursor', cursor);
+ return `${o.communityBase || COMMUNITY}/api/v1/feed?${p}`;
+ }
+ function loadFeed(o, kind, cursor) {
+ return fetch(feedUrl(o, kind, cursor), { credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {}) })
+ .then((r) => (r.ok ? r.json() : null)).then((j) => (j && Array.isArray(j.items) ? j : null)).catch(() => null);
+ }
+ const still = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced' || !!(navigator.connection && navigator.connection.saveData); } catch { return false; } };
+ const count = (n) => { const v = Number(n) || 0; return v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v); };
+ let player = null; // one IntersectionObserver plays the preview in view and pauses the rest
+ function watchPreview(box, c) {
+ const media = c.media || {};
+ const preview = safe(media.preview);
+ if (!preview || still() || typeof IntersectionObserver !== 'function') return;
+ player = player || new IntersectionObserver((es) => es.forEach((x) => {
+ const v = x.target.querySelector('video');
+ if (x.isIntersecting && x.intersectionRatio >= 0.6) {
+ if (!v) { const nv = document.createElement('video'); nv.muted = true; nv.loop = true; nv.playsInline = true; nv.setAttribute('playsinline', ''); nv.preload = 'none'; nv.src = x.target.getAttribute('data-preview'); nv.className = 'ovf-video'; x.target.append(nv); nv.play().catch(() => {}); }
+ else v.play().catch(() => {});
+ } else if (v) v.pause();
+ }), { threshold: [0, 0.6] });
+ box.setAttribute('data-preview', preview);
+ player.observe(box);
+ }
+ function feedCard(c) {
+ const kind = String(c.kind || 'post');
+ const url = safe(c.url);
+ if (!url) return null;
+ if (kind === 'update') {
+ const a = h('a', 'ovf-update'); a.href = url;
+ const ic = h('span', 'ovh-ic'); ic.append(siteIcon(c.site || 'ov', 20));
+ const t = h('span', 'ovf-update-txt'); t.append(h('b', '', String(c.title || '').slice(0, 160)), h('small', '', [c.site_name || c.site, ago(c.at)].filter(Boolean).join(' · ')));
+ a.append(ic, t); return a;
+ }
+ const a = h('a', `ovf-card ovf-${kind.replace(/[^a-z]/g, '')}`); a.href = url;
+ const head = h('span', 'ovf-head');
+ const face = h('span', 'ovf-face');
+ const av = c.actor && safe(c.actor.avatar);
+ if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); } else face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase();
+ const who = h('span', 'ovf-who');
+ who.append(h('b', '', String((c.actor && c.actor.name) || c.site_name || '').slice(0, 60)));
+ const meta = h('small', '');
+ meta.append(siteIcon(c.site || 'ov', 14), document.createTextNode(` ${[c.site_name || '', ago(c.at)].filter(Boolean).join(' · ')}`));
+ who.append(meta);
+ head.append(face, who);
+ if (kind === 'live') head.append(h('span', 'ovf-livechip', 'LIVE'));
+ a.append(head);
+ const media = c.media || {};
+ const thumb = safe(media.thumb);
+ if (thumb && /^https:/.test(thumb)) {
+ const box = h('span', 'ovf-media');
+ const r = Number(media.ratio) > 0 ? Math.max(0.8, Math.min(1.91, Number(media.ratio))) : (kind === 'pic' || kind === 'meme' ? 1 : 16 / 9);
+ box.style.aspectRatio = String(r);
+ const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);
+ if (media.length) box.append(h('span', 'ovf-len', String(media.length).slice(0, 10)));
+ if (kind === 'live' && c.counts && c.counts.viewers) box.append(h('span', 'ovf-len ovf-viewers', `${count(c.counts.viewers)} watching`));
+ a.append(box);
+ if (kind === 'clip' || kind === 'video' || kind === 'live') watchPreview(box, c);
+ }
+ if (c.title) a.append(h('b', 'ovf-title', String(c.title).slice(0, 200)));
+ if (c.text && (kind === 'post' || !thumb)) a.append(h('p', 'ovf-text', String(c.text).slice(0, 500)));
+ const n = c.counts || {};
+ const bits = [n.views ? `${count(n.views)} views` : '', n.likes ? `♥ ${count(n.likes)}` : '', n.comments ? `${count(n.comments)} replies` : ''].filter(Boolean);
+ if (bits.length) a.append(h('small', 'ovf-counts', bits.join(' · ')));
+ return a;
+ }
+ function feed(el, o) {
+ styles(); feedStyles();
+ el.classList.add('ovh', 'ovf');
+ el.setAttribute('data-sec', 'wall');
+ el.textContent = '';
+ const chips = h('div', 'ovf-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');
+ const list = h('div', 'ovf-list');
+ const sentinel = h('div', 'ovf-more');
+ el.append(chips, list, sentinel);
+ let kind = '', cursor = null, busy = false, done = false, seq = 0;
+ const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovf-skel')); };
+ const clearSkels = () => list.querySelectorAll('.ovf-skel').forEach((x) => x.remove());
+ async function more(first) {
+ if (busy || done) return;
+ busy = true; const my = ++seq;
+ if (first) { list.textContent = ''; skeletons(); } else skeletons();
+ const j = await loadFeed(o, kind, cursor);
+ if (my !== seq) return;
+ clearSkels(); busy = false;
+ if (!j) {
+ if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall stands
+ done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
+ }
+ if (first && o.feedLive) o.feedLive();
+ j.items.map(feedCard).filter(Boolean).forEach((c) => list.append(c));
+ if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
+ cursor = j.next_cursor || null;
+ done = !cursor;
+ sentinel.textContent = '';
+ if (done) sentinel.append(h('p', 'ovh-note ovf-caught', list.childNodes.length ? "You're all caught up." : 'Nothing here yet.'));
+ }
+ KINDS.forEach(([k, label]) => {
+ const b = h('button', 'ovf-chip', label); b.type = 'button'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', k === kind ? 'true' : 'false');
+ b.addEventListener('click', () => {
+ if (k === kind) return;
+ kind = k; cursor = null; done = false; busy = false;
+ chips.querySelectorAll('.ovf-chip').forEach((x) => x.setAttribute('aria-selected', x === b ? 'true' : 'false'));
+ more(true);
+ });
+ chips.append(b);
+ });
+ if (typeof IntersectionObserver === 'function') {
+ const io = new IntersectionObserver((es) => { if (es.some((x) => x.isIntersecting)) more(false); }, { root: el.closest('.ovs-list') || null, rootMargin: '600px 0px' });
+ io.observe(sentinel);
+ }
+ more(true);
+ return { update() {} };
+ }
+ function feedStyles() {
+ if (document.getElementById('ovf-styles')) return;
+ const st = document.createElement('style'); st.id = 'ovf-styles';
+ st.textContent = `
+.ovf-chips{position:sticky;top:0;z-index:2;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:8px 4px;margin:0 -4px;background:var(--bg-secondary,#141a24)}
+.ovf-chips::-webkit-scrollbar{display:none}
+.ovf-chip{flex:none;min-height:36px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}
+.ovf-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}
+.ovf-list{display:grid;gap:10px;padding:4px 0}
+@media (min-width:861px){.ovf-list{grid-template-columns:repeat(2,minmax(0,1fr))}}
+.ovf-card{display:grid;gap:8px;padding:10px;border-radius:16px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none;min-width:0}
+@media (hover:hover){.ovf-card:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 7%,transparent)}}
+.ovf-head{display:flex;align-items:center;gap:8px;min-width:0}
+.ovf-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}
+.ovf-face img{width:100%;height:100%;object-fit:cover}
+.ovf-who{min-width:0;display:grid}
+.ovf-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ovf-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}
+.ovf-livechip{margin-left:auto;padding:2px 8px;border-radius:6px;background:var(--live,#ef4444);color:#fff;font-size:11px;font-weight:800;letter-spacing:.06em}
+.ovf-media{position:relative;display:block;border-radius:12px;overflow:hidden;background:var(--bg-tertiary,#1f2633)}
+.ovf-media img,.ovf-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
+.ovf-len{position:absolute;right:8px;bottom:8px;padding:2px 7px;border-radius:6px;background:rgba(0,0,0,.72);color:#fff;font-size:12px;font-weight:700}
+.ovf-viewers{left:8px;right:auto}
+.ovf-title{font-size:14.5px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.ovf-text{margin:0;font-size:14px;line-height:1.5;color:var(--text-secondary,#a8b3c4);display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
+.ovf-counts{font-size:12px;color:var(--text-muted,#93a0b4)}
+.ovf-update{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:12px;color:inherit;text-decoration:none}
+.ovf-update .ovh-ic{width:34px;height:34px;border-radius:10px;flex:none}
+.ovf-update-txt{min-width:0;display:grid;gap:2px}.ovf-update-txt b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovf-update-txt small{font-size:12px;color:var(--text-muted,#93a0b4)}
+.ovf-skel{display:block;height:180px;border-radius:16px;background:linear-gradient(90deg,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 0,color-mix(in srgb,var(--text-primary,#e8ecf3) 9%,transparent) 50%,color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent) 100%);background-size:200% 100%;animation:ovf-shimmer 1.4s ease-in-out infinite}
+.ovf-more{min-height:1px}.ovf-caught{text-align:center;padding:10px 0}
+@keyframes ovf-shimmer{to{background-position:-200% 0}}
+@media (prefers-reduced-motion:reduce){.ovf-skel{animation:none}}
+`;
+ document.head.appendChild(st);
+ }
+
+ root.OpenVibeHome = { home, wallTop, timeline, feed, loadPulse, _defaultHome: defaultHome };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -41,7 +41,11 @@
@@ -41,7 +41,11 @@
var label = btn.querySelector('span') || btn;
var label = btn.querySelector('span') || btn;
var was = label.textContent;
var was = label.textContent;
label.textContent = 'Copied';
label.textContent = 'Copied';
- setTimeout(function () { label.textContent = was; }, 1400);
+ // An icon button shows a tick for a moment (its word is only for screen readers and the tooltip).
+ var path = btn.querySelector('svg path'); var d = path && path.getAttribute('d');
+ if (path) path.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5');
+ btn.classList.add('is-done'); if (btn.title) btn.title = 'Copied';
+ setTimeout(function () { label.textContent = was; if (path) path.setAttribute('d', d); btn.classList.remove('is-done'); if (btn.title) btn.title = was; }, 1400);
}).catch(function () { /* a refused clipboard changes nothing */ });
}).catch(function () { /* a refused clipboard changes nothing */ });
}
}
@@ -303,11 +307,101 @@
@@ -303,11 +307,101 @@
b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });
b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });
});
});
+ // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).
+ if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {
+ text.__ovtEx = true;
+ var exI = 0;
+ var exTimer = setInterval(function () {
+ if (!text.isConnected) { clearInterval(exTimer); return; }
+ var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }
+ if (!list.length || text.value || document.activeElement === text || document.hidden) return;
+ exI = (exI + 1) % list.length;
+ text.classList.add('is-ph-out');
+ setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);
+ }, 3600);
+ }
+
+ // Options: one button, the pickers behind it (a <details>, so it opens without script too). A tap outside,
+ // Escape or Done closes it; on a phone it is a sheet and the page behind stays put; a changed picker shows as
+ // a chip beside the button, and its × puts the default back.
+ qa(box, '[data-ovt-opts]').forEach(function (d) {
+ if (d.__ovt) return; d.__ovt = true;
+ var chips = d.parentNode ? q(d.parentNode, '[data-ovt-opts-chips]') : null;
+ var dot = q(d, '.ovt-opts-dot');
+ function phone() { return root.matchMedia && root.matchMedia('(max-width: 640px)').matches; }
+ function lock(on) { document.documentElement.classList.toggle('ovt-sheet-open', !!on && phone()); }
+ function close() { if (d.open) { d.open = false; } }
+ d.addEventListener('toggle', function () {
+ lock(d.open);
+ if (d.open) { var f = q(d, '.ovt-opts-body select, .ovt-opts-body input, .ovt-opts-body button'); if (f && !phone()) f.focus({ preventScroll: true }); }
+ });
+ document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) close(); }, true);
+ d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); close(); var s = q(d, 'summary'); if (s) s.focus(); } });
+ var x = q(d, '[data-ovt-opts-close]'); if (x) x.addEventListener('click', function (e) { e.preventDefault(); close(); });
+ function label(el) {
+ if (el.tagName === 'SELECT') { var o = el.options[el.selectedIndex]; return o ? o.textContent.trim() : el.value; }
+ var l = el.closest('label'); return l ? l.textContent.trim() : el.value;
+ }
+ function paint() {
+ if (!chips) return;
+ chips.textContent = '';
+ var any = false;
+ qa(d, '[data-ovt-default]').forEach(function (el) {
+ if (el.closest('[hidden]') || (el.offsetParent === null && !d.open && getComputedStyle(el.closest('label, .ovt-opt') || el).display === 'none')) return;
+ var def = el.getAttribute('data-ovt-default');
+ var cur = el.tagName === 'SELECT' ? el : q(el, 'input:checked');
+ var val = cur ? cur.value : def;
+ if (val === def) return;
+ any = true;
+ var chip = document.createElement('span'); chip.className = 'ovt-opt-chip';
+ var t = document.createElement('span'); t.textContent = label(cur).slice(0, 40); chip.appendChild(t);
+ var b = document.createElement('button'); b.type = 'button'; b.className = 'ovt-opt-chip-x'; b.setAttribute('aria-label', 'Back to the default: ' + t.textContent); b.textContent = '×';
+ b.addEventListener('click', function () {
+ if (el.tagName === 'SELECT') el.value = def; else { var r = q(el, 'input[value="' + (root.CSS && CSS.escape ? CSS.escape(def) : def) + '"]'); if (r) r.checked = true; }
+ (el.tagName === 'SELECT' ? el : q(el, 'input:checked') || el).dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ chip.appendChild(b);
+ chip.addEventListener('click', function (e) { if (e.target === b) return; d.open = true; });
+ chips.appendChild(chip);
+ });
+ if (dot) dot.hidden = !any;
+ }
+ d.addEventListener('change', paint);
+ if (form) form.addEventListener('ovt:options', paint);
+ paint();
+ });
+
+ // Attach's menu: a tap outside or a chosen file closes it.
+ qa(box, '[data-ovt-attach]').forEach(function (d) {
+ if (d.__ovt) return; d.__ovt = true;
+ document.addEventListener('pointerdown', function (e) { if (d.open && !d.contains(e.target)) d.open = false; }, true);
+ d.addEventListener('change', function () { d.open = false; });
+ d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && d.open) { e.stopPropagation(); d.open = false; var s = q(d, 'summary'); if (s) s.focus(); } });
+ });
+
+ // A kit sidebar's button goes into the navbar, before the brand, on a phone (11-09, the owner: the button on a
+ // row of its own under the bar wasted the space): the bar's lead slot opens the same drawer.
+ var sbBtn = q(box, '.ovt-head [data-ovk-sidebar-open]');
+ if (sbBtn) navLead(box, sbBtn, 0);
+
enhance(box);
enhance(box);
if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);
if (!location.hash && q(box, '[data-ovt-thread] > li')) toBottom(false);
});
});
}
}
+ // The navbar may draw after this script: try for a few seconds. The slot goes when a page without a thread loads.
+ var leadOn = false;
+ function navLead(box, btn, n) {
+ var nav = root.OpenVibeNavbar;
+ if (!nav || typeof nav.lead !== 'function' || !nav.getElement || !nav.getElement()) { if (n < 40) setTimeout(function () { navLead(box, btn, n + 1); }, 125); return; }
+ nav.lead({ icon: 'sidebar', label: btn.getAttribute('aria-label') || 'Your conversations', sidebar: btn.getAttribute('aria-controls') || '', phoneOnly: true });
+ leadOn = true;
+ box.classList.add('has-nav-lead');
+ }
+ document.addEventListener('ov:boost:load', function () {
+ if (leadOn && !document.querySelector('[data-ovt] .ovt-head [data-ovk-sidebar-open]') && root.OpenVibeNavbar && root.OpenVibeNavbar.lead) { root.OpenVibeNavbar.lead(null); leadOn = false; }
+ });
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); else start();
document.addEventListener('ov:boost:load', start);
document.addEventListener('ov:boost:load', start);
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.159.0",
+ "version": "4.160.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.159.0",
+ "version": "4.160.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.159.0",
+ "version": "4.160.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -48,6 +48,7 @@
@@ -48,6 +48,7 @@
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock": "./dock.js",
"./presence": "./presence.js",
"./presence": "./presence.js",
+ "./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
@@ -86,6 +87,8 @@
@@ -86,6 +87,8 @@
"./shell": "./shell.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
"./showcase": "./showcase.js",
"./showcase-stage": "./showcase-stage.js",
"./showcase-stage": "./showcase-stage.js",
+ "./ai-marks": "./ai-marks.js",
+ "./ai-marks-data": "./ai-marks-data.js",
"./kit": "./kit.js",
"./kit": "./kit.js",
"./kit-web": "./kit-web.js",
"./kit-web": "./kit-web.js",
"./kit-data-web": "./kit-data-web.js",
"./kit-data-web": "./kit-data-web.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+'use strict';
+// Writes ai-marks-data.js: the AI makers' monochrome marks, taken from @lobehub/icons-static-svg (MIT, © LobeHub;
+// https://github.com/lobehub/lobe-icons), as inner SVG markup on a 24 × 24 box in currentColor. The marks name the makers
+// whose models a page lists (nominative use, as every model leaderboard does); each remains its owner's trademark.
+// node scripts/build-ai-marks.js <path to an unpacked @lobehub/icons-static-svg package>
+const fs = require('fs');
+const path = require('path');
+
+const PKG = process.argv[2];
+if (!PKG) { console.error('usage: node scripts/build-ai-marks.js <unpacked @lobehub/icons-static-svg>'); process.exit(1); }
+const pkg = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8'));
+// Our family key → LobeHub's file (the mono drawing).
+const FILES = {
+ OpenAI: 'openai', Anthropic: 'claude', Google: 'gemini', Gemma: 'gemma', Meta: 'meta', DeepSeek: 'deepseek', Qwen: 'qwen',
+ Mistral: 'mistral', Kimi: 'kimi', GLM: 'zhipu', MiniMax: 'minimax', Microsoft: 'microsoft', xAI: 'grok', NVIDIA: 'nvidia',
+ Cohere: 'cohere', Perplexity: 'perplexity',
+};
+const out = {};
+for (const [key, file] of Object.entries(FILES)) {
+ const svg = fs.readFileSync(path.join(PKG, 'icons', `${file}.svg`), 'utf8');
+ const vb = /viewBox="([^"]+)"/.exec(svg);
+ if (!vb || vb[1] !== '0 0 24 24') throw new Error(`${file}: viewBox ${vb && vb[1]}, expected 0 0 24 24`);
+ const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '').replace(/<title>[\s\S]*?<\/title>/, '').trim();
+ if (/<script|on[a-z]+=|href=|<image|<foreignObject/i.test(inner)) throw new Error(`${file}: unexpected markup`);
+ out[key] = inner;
+}
+const header = `'use strict';\n// Generated by scripts/build-ai-marks.js from @lobehub/icons-static-svg ${pkg.version} (MIT, © LobeHub,\n// https://github.com/lobehub/lobe-icons). Do not edit: re-run the script. Each mark is its owner's trademark, used here to\n// name the maker of a model.\n`;
+fs.writeFileSync(path.join(__dirname, '..', 'ai-marks-data.js'), `${header}module.exports = ${JSON.stringify(out, null, 1)};\n`);
+console.log('ai-marks-data.js:', Object.keys(out).length, 'marks from', pkg.version);
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// owner still saw the old menu hours after the new one shipped (11-07).
// owner still saw the old menu hours after the new one shipped (11-07).
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js'];
+ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
@@ -556,7 +556,7 @@
@@ -556,7 +556,7 @@
if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }
if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }
const li = document.createElement('li'); li.setAttribute('role', 'presentation');
const li = document.createElement('li'); li.setAttribute('role', 'presentation');
let api = null;
let api = null;
- try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : H.timeline)(li, homeOpts()); } catch { return; }
+ try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : (H.feed || H.timeline))(li, homeOpts()); } catch { return; }
homeViews[g.as] = { li, api };
homeViews[g.as] = { li, api };
listEl.appendChild(li);
listEl.appendChild(li);
return;
return;
@@ -1030,18 +1030,38 @@
@@ -1030,18 +1030,38 @@
if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });
if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });
const live = liveGroup(); if (live) groups.push(live);
const live = liveGroup(); if (live) groups.push(live);
if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });
if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });
+ if (wall && feedV2) return groups; // Feed v2: the composer, today, the live bar and the stream
// The network's cards: text and links only (never another site's code), checked here again, with an entry's https
// The network's cards: text and links only (never another site's code), checked here again, with an entry's https
// picture when it sent one; the person's choice and order, else Network's first two.
// picture when it sent one; the person's choice and order, else Network's first two.
const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
const chosen = feedPref();
const chosen = feedPref();
// Every feed shows until the person chooses (owner, 11-09: "by default all the feeds show, but you can configure the order").
// Every feed shows until the person chooses (owner, 11-09: "by default all the feeds show, but you can configure the order").
- const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;
+ let netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;
+ // Nothing old dressed as new (11-09, the owner: "why is New in the forum first when it's 17 days ago"): a card's age
+ // is its newest item's (a timestamp when the card sends one, else its "3d ago" words); one older than 3 days drops
+ // the word "New" from its title, and until the person orders them, fresher cards come first and month-old ones last.
+ const AGE = { m: 60e3, min: 60e3, h: 3600e3, d: 86400e3, w: 604800e3, mo: 2592e6, y: 31536e6 };
+ const ageOf = (c) => {
+ let best = Infinity;
+ for (const x of c.items || []) {
+ const t = Date.parse(x && (x.at || x.updated_at || x.published_at) || '');
+ if (t) { best = Math.min(best, Date.now() - t); continue; }
+ const m = /(\d+)\s*(min|mo|m|h|d|w|y)\b[^·]*ago/.exec(String(x && x.meta || ''));
+ if (m) best = Math.min(best, Number(m[1]) * AGE[m[2]]);
+ }
+ return best;
+ };
+ const ages = new Map(netCards.map((c) => [c, ageOf(c)]));
+ const later = []; // cards older than two weeks: after What's new
+ if (!chosen) netCards = netCards.slice().sort((a, b) => (ages.get(a) === Infinity ? 1e15 : ages.get(a)) - (ages.get(b) === Infinity ? 1e15 : ages.get(b)));
netCards.forEach((c) => {
netCards.forEach((c) => {
const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),
const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),
image: x.image && /^https:\/\//.test(String(x.image.src || '')) ? String(x.image.src) : null,
image: x.image && /^https:\/\//.test(String(x.image.src || '')) ? String(x.image.src) : null,
site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
- if (rows.length) groups.push({ title: String(c.title).slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });
+ const stale = isFinite(ages.get(c)) && ages.get(c) > 3 * 86400e3; // an unknown age is never called old
+ const title = stale ? String(c.title).replace(/^New\s+(in|on|from)\s+/i, (m0, w) => `${w[0].toUpperCase()}${w.slice(1)} `).replace(/^New\s+/i, '') : String(c.title);
+ if (rows.length) (isFinite(ages.get(c)) && ages.get(c) > 14 * 86400e3 && !chosen ? later : groups).push({ title: title.slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });
});
});
// What's new (the owner, 11-07): the newest real change per site, newest first, "New" after this person's last visit.
// What's new (the owner, 11-07): the newest real change per site, newest first, "New" after this person's last visit.
if (Array.isArray(changesData) && changesData.length) {
if (Array.isArray(changesData) && changesData.length) {
@@ -1066,6 +1086,7 @@
@@ -1066,6 +1086,7 @@
const today = changesData.filter((e) => e && e.service && !HOUSEKEEPING.test(String(e.subject || '')) && Date.parse(at(e) || 0) > dayAgo).length;
const today = changesData.filter((e) => e && e.service && !HOUSEKEEPING.test(String(e.subject || '')) && Date.parse(at(e) || 0) > dayAgo).length;
if (rows.length) groups.push({ title: today ? `What's new (${today} today)` : "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
if (rows.length) groups.push({ title: today ? `What's new (${today} today)` : "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
}
}
+ groups.push(...later);
// Two places new to this person, rotating fairly through the network (developer services only for builders).
// Two places new to this person, rotating fairly through the network (developer services only for builders).
const builder = all.some((x) => x.group === 'build' && useOf(x.id));
const builder = all.some((x) => x.group === 'build' && useOf(x.id));
const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)
const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)
@@ -1120,7 +1141,7 @@
@@ -1120,7 +1141,7 @@
// ── The home screen and the wall (ov-home.js, 2026-11-09): loaded beside this file the first time You or Feed
// ── The home screen and the wall (ov-home.js, 2026-11-09): loaded beside this file the first time You or Feed
// paints; until it arrives (or if it cannot), the tabs paint as before.
// paints; until it arrives (or if it cannot), the tabs paint as before.
// One view of each per opening: a repaint (history or the catalogue arriving) keeps edit mode, a drag, a half-typed post.
// One view of each per opening: a repaint (history or the catalogue arriving) keeps edit mode, a drag, a half-typed post.
- let homeLoading = false, pulseReady = null, homeViews = {};
+ let homeLoading = false, pulseReady = null, homeViews = {}, feedV2 = false;
function homeLib() {
function homeLib() {
if (root.OpenVibeHome) return root.OpenVibeHome;
if (root.OpenVibeHome) return root.OpenVibeHome;
if (homeLoading || typeof ctx.sibling !== 'function') return null;
if (homeLoading || typeof ctx.sibling !== 'function') return null;
@@ -1148,7 +1169,9 @@
@@ -1148,7 +1169,9 @@
}
}
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, groups: f && Array.isArray(f.groups) ? f.groups : [],
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, groups: f && Array.isArray(f.groups) ? f.groups : [],
token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
- pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })) };
+ pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
+ // The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
+ feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
}
}
function renderTab() {
function renderTab() {
@@ -52,8 +52,13 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
@@ -52,8 +52,13 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
.sc-note a{color:var(--accent-light,#60a5fa)}
.sc-note a{color:var(--accent-light,#60a5fa)}
-.sc-sec{margin-top:56px;container-type:inline-size}
-.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
+.sc-sec{margin-top:48px;padding-top:32px;border-top:1px solid color-mix(in srgb,var(--border,#263244) 70%,transparent);container-type:inline-size}
+.sc-hero+.sc-sec,.sc-sec:first-child{border-top:0;padding-top:0}
+.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
+.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
+.sc-h-ic svg{display:block}
+.sc-sec>h2+.sc-lede{margin-left:48px}
+@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}
@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}
@@ -181,6 +186,13 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -181,6 +186,13 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
}
}
@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
+.sc-net-now{display:grid;gap:8px;margin:14px 0 0;padding:12px;border-radius:14px;background:color-mix(in srgb,var(--accent,#3b82f6) 6%,var(--bg-secondary,#101828));border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--border,#263244))}
+.sc-net-now[hidden]{display:none}
+.sc-net-now b{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#8a96a8)}
+.sc-net-now a{display:flex;gap:10px;align-items:baseline;min-height:36px;color:inherit;text-decoration:none;font-size:14px;line-height:1.4}
+.sc-net-now a span{flex:none;min-width:64px;font-weight:700;color:var(--accent-light,#60a5fa)}
+.sc-net-now a .live{color:var(--live,#ef4444)}
+.sc-net-all{margin:12px 0 0;font-weight:700}.sc-net-all a{color:var(--accent-light,#60a5fa);text-decoration:none}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@ -51,7 +51,17 @@ function icon(name, size = 22) {
@@ -51,7 +51,17 @@ function icon(name, size = 22) {
return `<i class="fa-solid ${esc(n.replace(/[^a-z0-9 -]/gi, ''))}" aria-hidden="true"></i>`;
return `<i class="fa-solid ${esc(n.replace(/[^a-z0-9 -]/gi, ''))}" aria-hidden="true"></i>`;
}
}
const id = (s) => (s ? ` id="${esc(String(s).replace(/[^a-z0-9_-]/gi, '-'))}"` : '');
const id = (s) => (s ? ` id="${esc(String(s).replace(/[^a-z0-9_-]/gi, '-'))}"` : '');
-const head = (title, lede, hid) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;
+// Every section's heading carries an icon tile (the owner, 11-09: "these section titles should have an icon… look nicer
+// and more consistent, separate things"): the section's kind gives a default, a site may pass its own (icon: 'name').
+let headGlyphs = null;
+const headIcon = (name) => {
+ if (!name) return '';
+ headGlyphs = headGlyphs || require('./glyphs');
+ const n = String(name).replace(/^ov:/, '');
+ const key = headGlyphs.NAMES.includes(n) ? n : (headGlyphs.fromFa ? headGlyphs.fromFa(n) : null);
+ return key && headGlyphs.path(key) ? `<span class="sc-h-ic" aria-hidden="true">${headGlyphs.svg(key, { size: 18 })}</span>` : '';
+};
+const head = (title, lede, hid, ic) => `${title ? `<h2${hid ? ` id="${hid}"` : ''}>${headIcon(ic)}<span>${esc(title)}</span></h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`;
// Heading ids come from the title, so a page renders the same bytes every time (ETags, caches).
// Heading ids come from the title, so a page renders the same bytes every time (ETags, caches).
const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';
const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';
const labelled = (title, h) => (title ? ` aria-labelledby="${h}"` : '');
const labelled = (title, h) => (title ? ` aria-labelledby="${h}"` : '');
@@ -117,10 +127,10 @@ function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html,
@@ -117,10 +127,10 @@ function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html,
}
}
/** A grid of what the product does; each card may link to where it starts. */
/** A grid of what the product does; each card may link to where it starts. */
-function features({ id: sid, title, lede, items } = {}) {
+function features({ id: sid, title, lede, items, icon: secIcon } = {}) {
if (!has(items)) return '';
if (!has(items)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-grid">${items.map((f) => {
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'grid')}<div class="sc-grid">${items.map((f) => {
// A ring icon (ov:…) is its own 40 px tile, so it fills the slot instead of sitting small inside a second tile.
// A ring icon (ov:…) is its own 40 px tile, so it fills the slot instead of sitting small inside a second tile.
const ring = f.icon && String(f.icon).startsWith('ov:');
const ring = f.icon && String(f.icon).startsWith('ov:');
const inner = `${f.icon ? `<span class="sc-ic${ring ? ' sc-ic-ov' : ''}">${icon(f.icon, ring ? 40 : 22)}</span>` : ''}<span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;
const inner = `${f.icon ? `<span class="sc-ic${ring ? ' sc-ic-ov' : ''}">${icon(f.icon, ring ? 40 : 22)}</span>` : ''}<span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;
@@ -129,27 +139,27 @@ function features({ id: sid, title, lede, items } = {}) {
@@ -129,27 +139,27 @@ function features({ id: sid, title, lede, items } = {}) {
}
}
/** A numbered path from nothing to done. */
/** A numbered path from nothing to done. */
-function steps({ id: sid, title, lede, items } = {}) {
+function steps({ id: sid, title, lede, items, icon: secIcon } = {}) {
if (!has(items)) return '';
if (!has(items)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ol class="sc-steps">${items.map((s) => `<li><b>${s.href ? `<a href="${esc(s.href)}">${esc(s.title)}</a>` : esc(s.title)}</b>${s.text ? `<span>${esc(s.text)}</span>` : ''}</li>`).join('')}</ol></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'steps')}<ol class="sc-steps">${items.map((s) => `<li><b>${s.href ? `<a href="${esc(s.href)}">${esc(s.title)}</a>` : esc(s.title)}</b>${s.text ? `<span>${esc(s.text)}</span>` : ''}</li>`).join('')}</ol></section>`;
}
}
/** Code the reader can copy: one or more samples, each labelled with its language. */
/** Code the reader can copy: one or more samples, each labelled with its language. */
-function code({ id: sid, title, lede, samples } = {}) {
+function code({ id: sid, title, lede, samples, icon: secIcon } = {}) {
if (!has(samples)) return '';
if (!has(samples)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre tabindex="0" aria-label="${esc(c.label || c.lang || `Sample ${i + 1}`)}"><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'code')}<div class="sc-code">${samples.map((c, i) => `<figure><figcaption>${esc(c.label || c.lang || `Sample ${i + 1}`)}</figcaption><pre tabindex="0" aria-label="${esc(c.label || c.lang || `Sample ${i + 1}`)}"><code${c.lang ? ` class="language-${esc(c.lang)}"` : ''}>${esc(String(c.code || '').replace(/^\n+|\s+$/g, ''))}</code></pre></figure>`).join('')}</div></section>`;
}
}
/** A live demo: the product itself in a frame, or the caller's own markup. */
/** A live demo: the product itself in a frame, or the caller's own markup. */
-function demo({ id: sid, title, lede, iframe, html } = {}) {
+function demo({ id: sid, title, lede, iframe, html, icon: secIcon } = {}) {
if (!iframe && html == null) return '';
if (!iframe && html == null) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
const body = iframe
const body = iframe
? `<iframe src="${esc(iframe.src)}" title="${esc(iframe.title || title || 'Live demo')}" loading="lazy" style="height:${Math.max(160, Math.min(900, Number(iframe.height) || 420))}px" referrerpolicy="strict-origin-when-cross-origin"${iframe.sandbox ? ` sandbox="${esc(iframe.sandbox)}"` : ''}></iframe>`
? `<iframe src="${esc(iframe.src)}" title="${esc(iframe.title || title || 'Live demo')}" loading="lazy" style="height:${Math.max(160, Math.min(900, Number(iframe.height) || 420))}px" referrerpolicy="strict-origin-when-cross-origin"${iframe.sandbox ? ` sandbox="${esc(iframe.sandbox)}"` : ''}></iframe>`
: html;
: html;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-demo">${body}</div></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'play')}<div class="sc-demo">${body}</div></section>`;
}
}
/**
/**
@@ -171,34 +181,34 @@ const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</sp
@@ -171,34 +181,34 @@ const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</sp
const tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;
const tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;
/** How the product compares, feature by feature. The first column is this product. */
/** How the product compares, feature by feature. The first column is this product. */
-function compare({ id: sid, title, lede, columns, rows } = {}) {
+function compare({ id: sid, title, lede, columns, rows, icon: secIcon } = {}) {
if (!has(rows) || !has(columns)) return '';
if (!has(rows) || !has(columns)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'chart')}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
}
}
/** Plans side by side. A price of 0 reads "Free". */
/** Plans side by side. A price of 0 reads "Free". */
-function pricing({ id: sid, title, lede, tiers, note } = {}) {
+function pricing({ id: sid, title, lede, tiers, note, icon: secIcon } = {}) {
if (!has(tiers)) return '';
if (!has(tiers)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-tiers">${tiers.map((t) => `<div class="sc-tier${t.highlight ? ' sc-hl' : ''}"><b class="sc-tier-name">${esc(t.name)}</b><p class="sc-price">${t.price === 0 || t.price === '0' ? 'Free' : esc(t.price)}${t.per && !(t.price === 0 || t.price === '0') ? `<small> / ${esc(t.per)}</small>` : ''}</p>${has(t.items) ? `<ul>${t.items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}${t.action ? actions([{ ...t.action, primary: t.highlight }], 'sc-actions sc-tier-act') : ''}</div>`).join('')}</div>${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'coin')}<div class="sc-tiers">${tiers.map((t) => `<div class="sc-tier${t.highlight ? ' sc-hl' : ''}"><b class="sc-tier-name">${esc(t.name)}</b><p class="sc-price">${t.price === 0 || t.price === '0' ? 'Free' : esc(t.price)}${t.per && !(t.price === 0 || t.price === '0') ? `<small> / ${esc(t.per)}</small>` : ''}</p>${has(t.items) ? `<ul>${t.items.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}${t.action ? actions([{ ...t.action, primary: t.highlight }], 'sc-actions sc-tier-act') : ''}</div>`).join('')}</div>${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</section>`;
}
}
/**
/**
* The limits a caller really gets, as a table: pricing that equals the API's limits. `source` names where the numbers
* The limits a caller really gets, as a table: pricing that equals the API's limits. `source` names where the numbers
* come from (a /limits.json URL), so the page can never promise more than the service enforces.
* come from (a /limits.json URL), so the page can never promise more than the service enforces.
*/
*/
-function limits({ id: sid, title, lede, columns, rows, source } = {}) {
+function limits({ id: sid, title, lede, columns, rows, source, icon: secIcon } = {}) {
if (!has(rows) || !has(columns)) return '';
if (!has(rows) || !has(columns)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'gauge')}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
}
}
/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
-function stories({ id: sid, title, lede, items } = {}) {
+function stories({ id: sid, title, lede, items, icon: secIcon } = {}) {
if (!has(items)) return '';
if (!has(items)) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'quote')}<div class="sc-stories">${items.map((s) => `<figure class="sc-story"><blockquote>${esc(s.text)}</blockquote><figcaption>${esc(s.who)}${s.outcome ? `<span>${esc(s.outcome)}</span>` : ''}</figcaption></figure>`).join('')}</div></section>`;
}
}
// A next step's target names: the service id → the product name the card says it opens ("on OpenVibe.Media").
// A next step's target names: the service id → the product name the card says it opens ("on OpenVibe.Media").
@@ -241,7 +251,7 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
@@ -241,7 +251,7 @@ function nextSteps({ id: sid, title = 'Next steps', lede, items } = {}) {
}
}
/** A strip of real numbers ("12,400 streams", "184 tools"). The page passes what it measured; nothing is invented here. */
/** A strip of real numbers ("12,400 streams", "184 tools"). The page passes what it measured; nothing is invented here. */
-function stats({ id: sid, title, items } = {}) {
+function stats({ id: sid, title, items, icon: secIcon } = {}) {
const usable = (items || []).filter((x) => x && x.value != null && x.label);
const usable = (items || []).filter((x) => x && x.value != null && x.label);
if (!usable.length) return '';
if (!usable.length) return '';
const h = `sc-${slug(title || 'numbers')}`;
const h = `sc-${slug(title || 'numbers')}`;
@@ -258,13 +268,13 @@ const RATIOS = { '16/9': '16 / 9', '4/3': '4 / 3', '1/1': '1 / 1', '3/4': '3 / 4
@@ -258,13 +268,13 @@ const RATIOS = { '16/9': '16 / 9', '4/3': '4 / 3', '1/1': '1 / 1', '3/4': '3 / 4
* the top of a page; 'grid' (the default) wraps. Images are lazy and hold their space (`ratio`), so nothing jumps.
* the top of a page; 'grid' (the default) wraps. Images are lazy and hold their space (`ratio`), so nothing jumps.
* `live: true` adds the red LIVE badge; `badge` is any short label, `meta` a quiet line under the title.
* `live: true` adds the red LIVE badge; `badge` is any short label, `meta` a quiet line under the title.
*/
*/
-function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more } = {}) {
+function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', more, icon: secIcon } = {}) {
const usable = (items || []).filter((c) => c && c.title && safeHref(c.href));
const usable = (items || []).filter((c) => c && c.title && safeHref(c.href));
if (!usable.length) return '';
if (!usable.length) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
const ar = RATIOS[ratio] || RATIOS['16/9'];
const ar = RATIOS[ratio] || RATIOS['16/9'];
const moreHref = more && safeHref(more.href);
const moreHref = more && safeHref(more.href);
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h)}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span aria-hidden="true">→</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${title || moreHref ? `<div class="sc-head-row"><div>${head(title, lede, h, secIcon || 'image')}</div>${moreHref ? `<a class="sc-more" href="${esc(moreHref)}">${esc(more.label || 'See all')} <span aria-hidden="true">→</span></a>` : ''}</div>` : ''}<ul class="sc-cards sc-${layout === 'rail' ? 'rail' : 'wrap'}" style="--sc-ar:${ar}">${usable.map((c) => {
const img = /^(https:\/\/|\/(?!\/))/.test(c.image || '') ? `<img src="${esc(c.image)}" alt="${esc(c.alt || '')}" loading="lazy" decoding="async">` : '';
const img = /^(https:\/\/|\/(?!\/))/.test(c.image || '') ? `<img src="${esc(c.image)}" alt="${esc(c.alt || '')}" loading="lazy" decoding="async">` : '';
const badge = c.live ? '<span class="sc-badge sc-live">LIVE</span>' : c.badge ? `<span class="sc-badge">${esc(c.badge)}</span>` : '';
const badge = c.live ? '<span class="sc-badge sc-live">LIVE</span>' : c.badge ? `<span class="sc-badge">${esc(c.badge)}</span>` : '';
return `<li><a class="sc-tile" href="${esc(safeHref(c.href))}"><span class="sc-thumb">${img}${badge}</span><span class="sc-tile-body"><b>${esc(c.title)}</b>${c.meta ? `<small>${esc(c.meta)}</small>` : ''}${c.text ? `<span class="sc-tile-text">${esc(c.text)}</span>` : ''}</span></a></li>`;
return `<li><a class="sc-tile" href="${esc(safeHref(c.href))}"><span class="sc-thumb">${img}${badge}</span><span class="sc-tile-body"><b>${esc(c.title)}</b>${c.meta ? `<small>${esc(c.meta)}</small>` : ''}${c.text ? `<span class="sc-tile-text">${esc(c.text)}</span>` : ''}</span></a></li>`;
@@ -272,11 +282,11 @@ function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', m
@@ -272,11 +282,11 @@ function cards({ id: sid, title, lede, items, layout = 'grid', ratio = '16/9', m
}
}
/** Features, shown rather than listed: a picture or a short muted loop beside each, alternating sides. */
/** Features, shown rather than listed: a picture or a short muted loop beside each, alternating sides. */
-function spotlight({ id: sid, title, lede, items } = {}) {
+function spotlight({ id: sid, title, lede, items, icon: secIcon } = {}) {
const usable = (items || []).filter((x) => x && x.title);
const usable = (items || []).filter((x) => x && x.title);
if (!usable.length) return '';
if (!usable.length) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-spots">${usable.map((x) => {
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'star')}<div class="sc-spots">${usable.map((x) => {
const video = /^(https:\/\/|\/(?!\/))/.test(x.video || '') ? `<video src="${esc(x.video)}" muted loop playsinline autoplay preload="none"${x.image ? ` poster="${esc(x.image)}"` : ''} aria-label="${esc(x.alt || x.title)}"></video>` : '';
const video = /^(https:\/\/|\/(?!\/))/.test(x.video || '') ? `<video src="${esc(x.video)}" muted loop playsinline autoplay preload="none"${x.image ? ` poster="${esc(x.image)}"` : ''} aria-label="${esc(x.alt || x.title)}"></video>` : '';
const img = !video && /^(https:\/\/|\/(?!\/))/.test(x.image || '') ? `<img src="${esc(x.image)}" alt="${esc(x.alt || '')}" loading="lazy" decoding="async">` : '';
const img = !video && /^(https:\/\/|\/(?!\/))/.test(x.image || '') ? `<img src="${esc(x.image)}" alt="${esc(x.alt || '')}" loading="lazy" decoding="async">` : '';
const href = safeHref(x.href);
const href = safeHref(x.href);
@@ -285,11 +295,11 @@ function spotlight({ id: sid, title, lede, items } = {}) {
@@ -285,11 +295,11 @@ function spotlight({ id: sid, title, lede, items } = {}) {
}
}
/** Questions people ask, as an accordion that works without JavaScript. Pair it with faqJsonLd(items) in the head. */
/** Questions people ask, as an accordion that works without JavaScript. Pair it with faqJsonLd(items) in the head. */
-function faq({ id: sid, title = 'Questions', lede, items } = {}) {
+function faq({ id: sid, title = 'Questions', lede, items, icon: secIcon } = {}) {
const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);
const usable = (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]);
if (!usable.length) return '';
if (!usable.length) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;
+ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'help')}<div class="sc-faq">${usable.map(([q, a], i) => `<details${i === 0 ? ' open' : ''}><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div></section>`;
}
}
/** A JSON-LD block that cannot close its script early. */
/** A JSON-LD block that cannot close its script early. */
const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
@@ -340,13 +350,13 @@ const DEFAULT_RELATED = RELATED.network;
@@ -340,13 +350,13 @@ const DEFAULT_RELATED = RELATED.network;
* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
* [to, what, href] or { to, what, href } shape), each a ring icon, its name and why to go there.
* [to, what, href] or { to, what, href } shape), each a ring icon, its name and why to go there.
*/
*/
-function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One account across every OpenVibe site. Related places to go next:', items } = {}) {
+function network({ id: sid, service, title = 'More on OpenVibe', lede = 'One account across every OpenVibe site.', items, icon: secIcon } = {}) {
const list = (items || RELATED[String(service || '').toLowerCase()] || DEFAULT_RELATED)
const list = (items || RELATED[String(service || '').toLowerCase()] || DEFAULT_RELATED)
.map((x) => (Array.isArray(x) ? { to: x[0], what: x[1], href: x[2] } : x))
.map((x) => (Array.isArray(x) ? { to: x[0], what: x[1], href: x[2] } : x))
.filter((x) => x && x.what && safeHref(x.href) && x.to !== service).slice(0, 4);
.filter((x) => x && x.what && safeHref(x.href) && x.to !== service).slice(0, 4);
if (!list.length) return '';
if (!list.length) return '';
const h = `sc-${slug(title)}`;
const h = `sc-${slug(title)}`;
- return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul></section>`;
+ return `<section class="sc-sec sc-network"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'spark')}<ul class="sc-net">${list.map((x) => `<li><a href="${esc(withFrom(safeHref(x.href), service))}"><span class="sc-ic sc-ic-ov">${icon(`ov:${String(x.to).toLowerCase()}`, 40)}</span><span><b>${esc(SERVICE_NAME(x.to) || x.to)}</b><small>${esc(x.what)}</small></span></a></li>`).join('')}</ul><div class="sc-net-now" data-ov-now hidden></div><p class="sc-net-all"><a href="https://openvibe.network/sites">Every OpenVibe site →</a></p></section>`;
}
}
/**
/**
@@ -358,7 +368,7 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc
@@ -358,7 +368,7 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc
* gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from
* gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from
* 960 px), so no tile sits alone on a row. Empty → ''.
* 960 px), so no tile sits alone on a row. Empty → ''.
*/
*/
-function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null } = {}) {
+function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null, icon: secIcon } = {}) {
const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
if (!sets.length) return '';
if (!sets.length) return '';
const h = `sc-${slug(title || 'apps')}`;
const h = `sc-${slug(title || 'apps')}`;
@@ -367,7 +377,7 @@ function apps({ id: sid, title = '', lede = '', groups = null, items = null, mor
@@ -367,7 +377,7 @@ function apps({ id: sid, title = '', lede = '', groups = null, items = null, mor
const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : n % 3 === 0 ? 3 : 0);
const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : n % 3 === 0 ? 3 : 0);
const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');
const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');
const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';
const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';
- return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${body}${end}</section>`;
+ return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h, secIcon || 'grid')}${body}${end}</section>`;
}
}
/** The closing call to action. */
/** The closing call to action. */
@@ -428,6 +438,18 @@ function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq
@@ -428,6 +438,18 @@ function room({ room: key, hero: h = {}, proof, rails, scenes, explore, dev, faq
+ `${explore ? features(explore) : ''}${devHTML}${q ? faq(q) : ''}${net ? network(net) : ''}</div>`;
+ `${explore ? features(explore) : ''}${devHTML}${q ? faq(q) : ''}${net ? network(net) : ''}</div>`;
}
}
+// More on OpenVibe's live strip: up to four things happening across the network, filled in the browser from Network's
+// Frame (the same data the menu shows: who is live, the sites' trending cards). Text only; https links only; nothing
+// shows when Network does not answer. A page that renders network() includes this once: `<script>${sc.NETWORK_JS}</script>`.
+const NETWORK_JS = "(function(){try{var box=document.querySelector('[data-ov-now]');if(!box||!window.fetch)return;var here=location.hostname;"
+ + "function ok(u){try{var x=new URL(u);return x.protocol==='https:'&&x.hostname!==here?x.href:null}catch(e){return null}}"
+ + "function row(tag,name,title,href,live){var a=document.createElement('a');a.href=href;var s=document.createElement('span');s.textContent=tag;if(live)s.className='live';a.appendChild(s);var t=document.createElement('em');t.style.fontStyle='normal';t.textContent=title;a.appendChild(t);box.appendChild(a)}"
+ + "Promise.all([fetch('https://openvibe.network/api/frame/pulse',{credentials:'omit'}).then(function(r){return r.ok?r.json():null}).catch(function(){return null}),fetch('https://openvibe.network/api/frame?v=2',{credentials:'omit',headers:{accept:'application/json'}}).then(function(r){return r.ok?r.json():null}).catch(function(){return null})]).then(function(v){var n=0,p=v[0],f=v[1];"
+ + "var h=document.createElement('b');h.textContent='Happening on OpenVibe';box.appendChild(h);"
+ + "if(p&&Number(p.live_now)>0){var names=(p.live||[]).slice(0,3).map(function(c){return c.name}).filter(Boolean).join(', ');row('Live',null,(p.live_now==1?'1 stream live now':p.live_now+' streams live now')+(names?': '+names:''),'https://openvibe.live/',true);n++}"
+ + "(f&&f.cards||[]).forEach(function(c){if(n>=4||!c||!c.items||!c.items.length)return;var it=c.items[0],u=ok(it.url);if(!u||!it.title)return;row(String(c.title||c.site||'').slice(0,24),null,String(it.title).slice(0,90),u,false);n++});"
+ + "if(n)box.hidden=false})}catch(e){}})();";
+
const CSS = `
const CSS = `
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
@@ -482,8 +504,13 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
@@ -482,8 +504,13 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
.sc-btn.sc-primary:hover{transform:translateY(-1px);filter:brightness(1.06)}
.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
.sc-note{margin:14px 0 0;font-size:13.5px;color:var(--text-muted,#7386a3)}
.sc-note a{color:var(--accent-light,#60a5fa)}
.sc-note a{color:var(--accent-light,#60a5fa)}
-.sc-sec{margin-top:56px;container-type:inline-size}
-.sc-sec>h2,.sc-cta>h2{font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
+.sc-sec{margin-top:48px;padding-top:32px;border-top:1px solid color-mix(in srgb,var(--border,#263244) 70%,transparent);container-type:inline-size}
+.sc-hero+.sc-sec,.sc-sec:first-child{border-top:0;padding-top:0}
+.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
+.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
+.sc-h-ic svg{display:block}
+.sc-sec>h2+.sc-lede{margin-left:48px}
+@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}
@container (min-width:520px) and (max-width:1035px){.sc-grid:has(>:nth-child(4):last-child){grid-template-columns:repeat(2,minmax(0,1fr))}}
@@ -611,6 +638,13 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -611,6 +638,13 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
}
}
@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
+.sc-net-now{display:grid;gap:8px;margin:14px 0 0;padding:12px;border-radius:14px;background:color-mix(in srgb,var(--accent,#3b82f6) 6%,var(--bg-secondary,#101828));border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,var(--border,#263244))}
+.sc-net-now[hidden]{display:none}
+.sc-net-now b{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#8a96a8)}
+.sc-net-now a{display:flex;gap:10px;align-items:baseline;min-height:36px;color:inherit;text-decoration:none;font-size:14px;line-height:1.4}
+.sc-net-now a span{flex:none;min-width:64px;font-weight:700;color:var(--accent-light,#60a5fa)}
+.sc-net-now a .live{color:var(--live,#ef4444)}
+.sc-net-all{margin:12px 0 0;font-weight:700}.sc-net-all a{color:var(--accent-light,#60a5fa);text-decoration:none}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@ -646,4 +680,4 @@ html{overflow-x:clip}
@@ -646,4 +680,4 @@ html{overflow-x:clip}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a,.sc-apps a{transition:none}.sc-badge.sc-live::before{animation:none}}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a,.sc-apps a{transition:none}.sc-badge.sc-live::before{animation:none}}
`;
`;
-module.exports = { icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
+module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+// openvibe-shared/ai-marks: a model id, slug, family or provider name finds its maker's mark; anything else gets the
+// neutral spark (never a letter, never another maker's mark); marks carry no markup but drawing; the licence is named.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const m = require('../ai-marks');
+const DATA = require('../ai-marks-data');
+
+const cases = {
+ 'deepseek/deepseek-chat': 'DeepSeek', 'claude-opus-5-5': 'Anthropic', 'anthropic': 'Anthropic', 'gpt-5-nano': 'OpenAI', 'openai/o3-mini': 'OpenAI',
+ 'gemini-4-carbon': 'Google', 'gemma-3-27b': 'Gemma', 'qwen3.8-max': 'Qwen', 'kimi-k3.1-pro': 'Kimi', 'glm-5.3-flash': 'GLM',
+ 'MiniMax-M3.1-Flash': 'MiniMax', 'llama-3.2-1b-instruct': 'Meta', 'mixtral-8x7b': 'Mistral', 'grok-4': 'xAI', 'phi-4': 'Microsoft',
+ 'nemotron-70b': 'NVIDIA', 'command-r-plus': 'Cohere', 'sonar-pro': 'Perplexity',
+};
+for (const [text, key] of Object.entries(cases)) assert.strictEqual(m.family(text), key, text);
+for (const text of ['abliterated-model-large-flash', 'openrouter/auto', '', null]) assert.strictEqual(m.family(text), null, String(text));
+
+const ds = m.mark('deepseek/deepseek-chat', { size: 28 });
+assert.match(ds, /^<span class="ov-aim ov-aim--deepseek" style="--ov-aim:28px" role="img" aria-label="DeepSeek" title="DeepSeek"><svg viewBox="0 0 24 24"/);
+const other = m.mark('<script>x</script>');
+assert.ok(other.includes('ov-aim--other') && other.includes('aria-label="<script>x</script>"') && !other.includes('<script>'), 'unknown: the spark, the name escaped');
+assert.ok(!/aria-label/.test(m.mark('qwen', { label: false })) && /aria-hidden="true"/.test(m.mark('qwen', { label: false })), 'decorative when the row names it');
+
+for (const [k, inner] of Object.entries(DATA)) {
+ assert.ok(/^<(path|circle|rect|g|ellipse|polygon)[\s>]/.test(inner), `${k} is drawing markup`);
+ assert.ok(!/<script|on[a-z]+=|href=|<image|<foreignObject|<style/i.test(inner), `${k} carries nothing but drawing`);
+}
+assert.strictEqual(m.FAMILIES.length, 16);
+const head = fs.readFileSync(path.join(__dirname, '..', 'ai-marks-data.js'), 'utf8').slice(0, 400);
+assert.ok(/@lobehub\/icons-static-svg/.test(head) && /MIT/.test(head), 'the source and its licence are named');
+assert.ok(/\.ov-aim\{/.test(m.CSS) && /var\(--text-primary/.test(m.CSS), 'the tile follows the theme');
+console.log('ai-marks: makers found from ids, the spark for anyone else, drawing only, the licence named');
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+// The composer's Options, examples and the navbar's lead slot (11-09, the owner on openvibe.codes: "too many options…
+// hidden under a cog… less cluttered but still has the power features"; the sidebar button "to the left of the logo"),
+// in a real headless Chrome: one button opens the pickers (a sheet on a phone, the page behind stays put), a tap outside
+// closes it, a changed picker shows as a chip whose × puts the default back, the placeholder walks through the examples,
+// and a kit sidebar's button moves into the navbar on a phone, where the empty head row goes away. Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+const kit = require('../kit');
+const thread = require('../thread');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('composer-options-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const OPTIONS = '<label class="ovt-opt">Agent <select name="who" data-ovt-default=""><option value="">Auto</option><option value="codex">Codex</option></select></label>'
+ + '<label class="ovt-opt">Effort <select name="effort" data-ovt-default=""><option value="">Effort: auto</option><option value="high">Thorough</option></select></label>';
+const composer = thread.composer({ action: '/send', id: 'f', placeholder: 'What should change?', options: { html: OPTIONS }, examples: ['Make the home page faster on phones', 'Add a dark mode toggle'] });
+const sidebar = kit.sidebar ? kit.sidebar({ id: 'ch-rail', label: 'Your conversations', sections: [] }) : '<nav id="ch-rail" class="ovk-sidebar" data-ovk-sidebar></nav>';
+const view = thread.page({ sidebar, head: thread.head({ sidebar: 'ch-rail', sidebarLabel: 'Your conversations' }), empty: '<h1>What should we build?</h1><p data-ovt-fold>Say it the way you would to a friend.</p>', composer, center: true });
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Composer</title><link rel="icon" href="data:,">
+<link rel="stylesheet" href="/kit.css"><link rel="stylesheet" href="/thread.css"></head>
+<body><div id="nav"></div>${view}<div style="height:1600px"></div>
+<script>document.addEventListener('submit', (e) => e.preventDefault());</script>
+<script src="/navbar.js"></script><script>OpenVibeNavbar.init({ mount: '#nav', site: 'codes', siteName: 'Codes', dock: false, messages: false, links: [] });</script>
+<script src="/kit-web.js"></script><script src="/ov-thread.js"></script></body></html>`;
+const FILES = new Set(['/kit.css', '/thread.css', '/navbar.js', '/nav-slots.js', '/kit-web.js', '/ov-thread.js']);
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
+ if (FILES.has(u.pathname)) { res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
+ res.writeHead(404); return res.end();
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-composer-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (p, expr, what, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v) return v; if (Date.now() > end) throw new Error(`timed out: ${what}`); await wait(50); } };
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.network*', '*openvibe.tools*'] });
+ await p.goto(`${base}/`, { maxMs: 6000 });
+ await until(p, "!!document.querySelector('[data-ovt-opts]')", 'the Options button');
+ // Only the button and Send in the bar; the pickers are behind it.
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-panel').checkVisibility()"), false, 'the pickers start closed');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), true, 'no dot while everything is the default');
+ // Open: a sheet at the bottom of a phone, the page behind does not scroll.
+ await p.evaluate("document.querySelector('.ovt-opts > summary').click()");
+ await until(p, "document.querySelector('[data-ovt-opts]').open", 'it opens');
+ await wait(320);
+ const sheet = await p.evaluate("(() => { const r = document.querySelector('.ovt-opts-panel').getBoundingClientRect(); return { bottom: Math.round(r.bottom), left: Math.round(r.left), width: Math.round(r.width), pos: getComputedStyle(document.querySelector('.ovt-opts-panel')).position, locked: document.documentElement.classList.contains('ovt-sheet-open') }; })()");
+ assert.strictEqual(sheet.pos, 'fixed', 'a sheet on a phone');
+ assert.ok(sheet.bottom >= 840 && sheet.left === 0 && sheet.width === 390, `along the bottom edge (${JSON.stringify(sheet)})`);
+ assert.ok(sheet.locked, 'the page behind stays put');
+ // Change a picker: a chip shows beside the button, and the dot.
+ await p.evaluate("(() => { const s = document.querySelector('select[name=who]'); s.value = 'codex'; s.dispatchEvent(new Event('change', { bubbles: true })); })()");
+ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovt-opt-chip > span')].map((s) => s.textContent).join()"), 'Codex', 'the changed one is a chip');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovt-opts-dot').hidden"), false, 'and the button wears a dot');
+ // A tap outside closes it and unlocks the page.
+ await p.evaluate("(() => { const e = new PointerEvent('pointerdown', { bubbles: true }); document.querySelector('h1').dispatchEvent(e); })()");
+ await until(p, "!document.querySelector('[data-ovt-opts]').open", 'a tap outside closes it');
+ assert.strictEqual(await p.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page scrolls again');
+ // The chip's × puts the default back.
+ await p.evaluate("document.querySelector('.ovt-opt-chip-x').click()");
+ assert.strictEqual(await p.evaluate("document.querySelector('select[name=who]').value"), '', 'the default is back');
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovt-opt-chip').length"), 0, 'and the chip is gone');
+ // The examples walk through the placeholder while the box is empty.
+ await until(p, "document.querySelector('[data-ovt-text]').placeholder !== 'What should change?'", 'the placeholder moves to an example', 6000);
+ // The sidebar button: in the navbar before the brand, and the head row with nothing else in it is gone.
+ await until(p, "!!document.getElementById('ovnav-lead')", 'the lead slot in the navbar');
+ const lead = await p.evaluate("(() => { const b = document.getElementById('ovnav-lead'); const brand = document.querySelector('.openvibe-navbar-brand'); return { first: brand.firstElementChild === b, ctl: b.getAttribute('aria-controls'), head: getComputedStyle(document.querySelector('.ovt-head')).display }; })()");
+ assert.ok(lead.first, 'the button comes before the brand');
+ assert.strictEqual(lead.ctl, 'ch-rail', 'and opens the conversations');
+ assert.strictEqual(lead.head, 'none', 'the empty head row goes');
+ await p.evaluate("document.getElementById('ovnav-lead').click()");
+ await until(p, "document.getElementById('ch-rail').classList.contains('is-open')", 'the slot opens the drawer');
+ assert.strictEqual(await p.evaluate("document.getElementById('ovnav-lead').getAttribute('aria-expanded')"), 'true', 'and says it is open');
+ assert.ok(await p.evaluate('document.documentElement.scrollWidth <= 390'), 'no sideways scroll');
+ // A site's own action on the right, by the API.
+ await p.evaluate("OpenVibeNavbar.action({ id: 'help', icon: 'info', label: 'Help', badge: 2, onClick: () => { window.__help = (window.__help || 0) + 1; } })");
+ await p.evaluate("document.getElementById('ovnav-act-help').click()");
+ assert.strictEqual(await p.evaluate('window.__help'), 1, 'an action button calls its handler');
+ assert.strictEqual(await p.evaluate("document.querySelector('#ovnav-act-help b').textContent"), '2', 'and shows its count');
+ // On a computer: the panel opens beside the button, not as a sheet, and the navbar slot is not shown.
+ const d = await h.openPage(browser, context, { width: 1280, height: 900, block: ['*openvibe.network*', '*openvibe.tools*'] });
+ await d.goto(`${base}/`, { maxMs: 6000 });
+ await until(d, "!!document.querySelector('[data-ovt-opts]')", 'the Options button on a computer');
+ await d.evaluate("document.querySelector('.ovt-opts > summary').click()");
+ await wait(250);
+ assert.strictEqual(await d.evaluate("getComputedStyle(document.querySelector('.ovt-opts-panel')).position"), 'absolute', 'a panel on a computer');
+ assert.strictEqual(await d.evaluate("document.documentElement.classList.contains('ovt-sheet-open')"), false, 'the page is not locked for a panel');
+ await until(d, "!!document.getElementById('ovnav-lead')", 'the slot exists');
+ assert.strictEqual(await d.evaluate("getComputedStyle(document.getElementById('ovnav-lead')).display"), 'none', 'but hides where the sidebar is always shown');
+ console.log('composer-options-chrome: one Options button, a sheet on a phone, chips for changes, examples, the sidebar button in the navbar, actions by API');
+ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// Feed v2 in the menu (owner, 11-09: "a real feed… you scroll through easy content… a filter system… clean"), in a real
+// headless Chrome against a stand-in for Community's /api/v1/feed: chips on top, cards by kind, a chip refetches with its
+// kind, the next page loads as the end comes near, the end says so, the Feed tab drops its old sections when the feed
+// answers, and what people wrote is text. (Without the API the wall stands: menu-home-screen-chrome.test.js.)
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const ROOT = path.join(__dirname, '..');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-feed-v2-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const SITES = ['live', 'chat', 'community', 'video', 'pics', 'download', 'design', 'games', 'space', 'codes', 'news', 'tools', 'bot', 'fun']
+ .map((id, i) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), url: `https://openvibe.${id}/`, icon: id, group: i < 10 ? 'play' : 'more' }))
+ .concat([{ id: 'ai', name: 'AI', url: 'https://openvibe.exchange/', icon: 'ai', group: 'build' }, { id: 'run', name: 'Run', url: 'https://openvibe.run/', icon: 'run', group: 'build' }]);
+const FRAME = { nav: SITES, groups: [{ id: 'play', name: 'Play' }, { id: 'more', name: 'More' }, { id: 'build', name: 'Build' }], soon: [], footer: {} };
+const CATALOG = { families: [], tools: Array.from({ length: 30 }, (_, i) => ({ name: `Tool ${i + 1}`, icon: ['pdf', 'image', 'qr', 'audio'][i % 4], url: `https://openvibe.tools/t${i + 1}`, tagline: 'a tool' })) };
+const PULSE = { items: [
+ { title: 'ada went live: Coding at night', url: 'https://openvibe.live/ada', occurred_at: new Date(Date.now() - 60e3).toISOString(), ref: { service: 'live' } },
+ { title: 'bo went live: Morning stream', url: 'https://openvibe.live/bo', occurred_at: new Date(Date.now() - 120e3).toISOString(), ref: { service: 'live' } },
+ { title: 'A new clip: the jump', url: 'https://openvibe.video/c/1', occurred_at: new Date(Date.now() - 3600e3).toISOString(), ref: { service: 'video' } },
+ { title: '<img src=x onerror="window.pwned=1"> a post', url: 'https://openvibe.community/p/1', occurred_at: new Date(Date.now() - 7200e3).toISOString(), ref: { service: 'community' } },
+], next_cursor: 'c2' };
+const TODAY = { people: { today: 1204, series: [800, 900, 1000, 1204] }, streams: { today: 37, series: [20, 30, 37] }, posts: { today: 210 }, videos: { today: 12 } };
+
+const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home</title><link rel="icon" href="data:,">
+<script>
+ window.__calls = [];
+ const J = (b, ok = true, status = 200) => Promise.resolve({ ok, status, json: async () => b });
+ const realFetch = window.fetch;
+ window.fetch = (u, o) => {
+ const s = String(u); window.__calls.push({ u: s, m: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && (o.headers.Authorization || o.headers.authorization) || '' });
+ if (s.startsWith('https://openvibe.network/api/frame?')) return J(${JSON.stringify(FRAME)});
+ if (s.startsWith('https://openvibe.network/api/catalog.json')) return J(${JSON.stringify(CATALOG)});
+ if (s.startsWith('https://openvibe.network/api/v1/frame/today')) return J(${JSON.stringify(TODAY)});
+ if (s.startsWith('https://openvibe.network/api/v1/changelog')) return J({ entries: [
+ { service: 'live', subject: 'Go live from a phone: the Dock no longer covers the button (#711)', deployed_at: new Date(Date.now() - 3600e3).toISOString(), major: true },
+ { service: 'live', subject: 'openvibe-shared v4.158.0 (was v4.157.0) (#712)', deployed_at: new Date(Date.now() - 600e3).toISOString() },
+ { service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });
+ if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
+ if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
+ if (s.startsWith('https://openvibe.community/api/v1/feed')) { window.__feedCalls = (window.__feedCalls || []).concat([s]); const u = new URL(s); const kind = u.searchParams.get('kind') || ''; const page = u.searchParams.get('cursor') ? 2 : 1;
+ const mk = (i, k) => ({ id: k + i + '-' + page, kind: k, url: 'https://openvibe.video/c/' + i, title: (k === 'post' ? '<b>bold</b> ' : '') + k + ' ' + i + ' page ' + page, text: 'words', at: new Date(Date.now() - i * 60000).toISOString(), site: k === 'pic' ? 'pics' : 'video', site_name: 'Video', actor: { name: 'ada' }, media: k === 'post' || k === 'update' ? {} : { thumb: 'https://openvibe.media/t/x.webp', ratio: 1.7778, length: '0:30' }, counts: { views: 1200, likes: 3 } });
+ const kinds = kind ? [kind] : ['live', 'clip', 'pic', 'post', 'update'];
+ return J({ items: Array.from({ length: 12 }, (_, i) => mk(i, kinds[i % kinds.length])), next_cursor: page === 1 ? 'c2' : null }); }
+ if (s.startsWith('https://openvibe.community/api/v1/pulse')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
+ if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
+ if (s.startsWith('https://')) return J({}, false, 404);
+ return realFetch(u, o);
+ };
+</script>
+<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
+<script>document.addEventListener('DOMContentLoaded', function () { try { localStorage.clear(); sessionStorage.clear(); } catch (e) {} OpenVibeNavbar.init({ service: 'site', user: ${signed ? "{ id: 'u1', username: 'ada', display_name: 'Ada' }" : 'null'}, token: ${signed ? "'tok-1'" : 'null'}, links: [{ label: 'Rooms', href: '/rooms' }], countViews: false, notifications: false }); });</script>
+</body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css|woff2))$/.exec(u.pathname);
+ if (m) {
+ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }
+ res.writeHead(200, { 'content-type': m[1].endsWith('.js') ? 'application/javascript' : 'application/octet-stream' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));
+ }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(u.searchParams.has('signed')));
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feed-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.tools*', '*jsdelivr*', '*openvibe.media*'] });
+ await p.goto(`${base}/`, { maxMs: 6000 });
+ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
+ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="feed"]')`), 'the menu opened');
+ await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
+ assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 12`), 'the feed painted');
+ const first = await p.evaluate(`({ chips: [...document.querySelectorAll('.ovf-chip')].map((b) => b.textContent), selected: (document.querySelector('.ovf-chip[aria-selected=true]') || {}).textContent,
+ kinds: [...new Set([...document.querySelectorAll('.ovf-list > a')].map((a) => a.className))], live: !!document.querySelector('.ovf-livechip'), media: document.querySelectorAll('.ovf-media img').length,
+ bolds: document.querySelectorAll('.ovf-list b b').length, posts: [...document.querySelectorAll('.ovf-post .ovf-title')].map((b) => b.textContent)[0],
+ sections: [...document.querySelectorAll('.ovs-group')].map((g) => g.textContent.trim()).filter(Boolean), scroll: document.documentElement.scrollWidth })`);
+ assert.deepStrictEqual(first.chips, ['For you', 'Live', 'Clips', 'Videos', 'Pics', 'Posts', 'Memes', 'Games', 'Updates']);
+ assert.strictEqual(first.selected, 'For you');
+ assert.ok(first.kinds.includes('ovf-card ovf-live') && first.kinds.includes('ovf-update') && first.kinds.includes('ovf-card ovf-pic'), `cards by kind (${first.kinds})`);
+ assert.ok(first.live, 'a live card says LIVE');
+ assert.ok(first.media >= 6, 'media cards show their picture');
+ assert.strictEqual(first.bolds, 0, 'what people wrote is text');
+ assert.ok(/^<b>bold<\/b> post/.test(first.posts), 'the markup shows as words');
+ assert.ok(!first.sections.some((t) => /New to you|Next from|What's new/.test(t)), `the Feed tab is the stream when the feed answers (${first.sections})`);
+ assert.ok(first.scroll <= 390, 'no sideways scroll');
+ await p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); l.scrollTop = l.scrollHeight; })()`);
+ assert.ok(await until(p, `document.querySelectorAll('.ovf-list > a').length >= 24`), 'the next page loads as the end comes near');
+ assert.ok(await until(p, `!!document.querySelector('.ovf-caught')`), 'the end says so');
+ await p.evaluate(`[...document.querySelectorAll('.ovf-chip')].find((b) => b.textContent === 'Clips').click(), true`);
+ assert.ok(await until(p, `(window.__feedCalls || []).some((u) => u.includes('kind=clip'))`), 'the chip asks for its kind');
+ assert.ok(await until(p, `[...document.querySelectorAll('.ovf-list > a')].every((a) => a.className === 'ovf-card ovf-clip') && document.querySelectorAll('.ovf-list > a').length === 12`), 'only clips, from the first page');
+ console.log('menu-feed-v2-chrome: chips, cards by kind, text only, the next page, caught up, a chip by kind, the stream alone ok');
+ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 46 * 1024; // 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
+const NAVBAR_BROTLI_BUDGET = 47 * 1024; // 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
@@ -7,7 +7,7 @@ const fs = require('fs');
@@ -7,7 +7,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');
-assert.ok(/root\.OpenVibeHome = \{ home, wallTop, timeline, loadPulse/.test(src), 'it exposes the home screen and the wall');
+assert.ok(/root\.OpenVibeHome = \{ home, wallTop, timeline, feed, loadPulse/.test(src), 'it exposes the home screen, the wall and the feed');
assert.ok(!/\.innerHTML\s*=/.test(src) && !/insertAdjacentHTML/.test(src), 'it never writes markup');
assert.ok(!/\.innerHTML\s*=/.test(src) && !/insertAdjacentHTML/.test(src), 'it never writes markup');
assert.ok(/const safe = \(u\) =>/.test(src) && /x\.protocol === 'https:' \|\| x\.origin === location\.origin/.test(src), 'links are https or this site only');
assert.ok(/const safe = \(u\) =>/.test(src) && /x\.protocol === 'https:' \|\| x\.origin === location\.origin/.test(src), 'links are https or this site only');
assert.ok(/localStorage\.setItem\(HOME_KEY/.test(src) && /\/api\/v1\/me\/home-screen/.test(src), 'the arrangement is kept here and on Network');
assert.ok(/localStorage\.setItem\(HOME_KEY/.test(src) && /\/api\/v1\/me\/home-screen/.test(src), 'the arrangement is kept here and on Network');
@@ -123,7 +123,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -123,7 +123,7 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');
assert.ok(ld.startsWith('<script type="application/ld+json">') && !ld.slice(35, -9).includes('<'), 'JSON-LD cannot close its script early');
assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');
assert.ok(JSON.parse(ld.slice(35, -9).replace(/\\u003c/g, '<')).mainEntity[0].name === 'Q </script>');
const net = sc.network({ service: 'live' });
const net = sc.network({ service: 'live' });
- assert.ok(net.includes('Part of OpenVibe') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');
+ assert.ok(net.includes('More on OpenVibe') && net.includes('data-ov-now') && net.includes('https://openvibe.network/sites') && net.includes('https://openre.stream/?ov_from=live') && net.includes('data-icon="openre"'), 'related products, with the referral tag and ring icons');
assert.ok(!net.includes('https://openvibe.live/'), 'never links a product to itself');
assert.ok(!net.includes('https://openvibe.live/'), 'never links a product to itself');
assert.ok(sc.network({ service: 'unknown-thing' }).includes('OpenVibe.Live'), 'a product without its own list gets the core ones');
assert.ok(sc.network({ service: 'unknown-thing' }).includes('OpenVibe.Live'), 'a product without its own list gets the core ones');
assert.ok(sc.network({ service: 'x', items: [['tools', 'Tools', 'https://openvibe.tools/'], ['bad', 'Bad', 'ftp://x']] }).includes('OpenVibe.Tools'));
assert.ok(sc.network({ service: 'x', items: [['tools', 'Tools', 'https://openvibe.tools/'], ['bad', 'Bad', 'ftp://x']] }).includes('OpenVibe.Tools'));
@@ -192,3 +192,14 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -192,3 +192,14 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
}
}
console.log('showcase: all checks passed');
console.log('showcase: all checks passed');
+
+// 4.160.0 (owner, 11-09: section titles need an icon, sections should read as separate things): each section's heading
+// carries an icon tile, its kind's by default, a site's own when given; the network section's live strip and every site.
+{
+ const f = sc.features({ title: 'What it does', items: [{ title: 'A', text: 'B' }] });
+ assert.ok(/<h2 id="[^"]+"><span class="sc-h-ic" aria-hidden="true"><svg[^>]*>.*<\/svg><\/span><span>What it does<\/span><\/h2>/.test(f), 'a features heading has its icon tile');
+ assert.ok(sc.faq({ items: [['Q?', 'A.']] }).includes('class="sc-h-ic"'), 'questions have theirs');
+ assert.ok(sc.features({ title: 'X', icon: 'rocket', items: [{ title: 'A', text: 'B' }] }).includes('sc-h-ic'), 'a site may choose the icon');
+ assert.ok(/border-top:1px solid/.test(sc.CSS) && /\.sc-hero\+\.sc-sec/.test(sc.CSS), 'sections stand apart, the first after the hero without a rule');
+ assert.ok(/api\/frame\/pulse/.test(sc.NETWORK_JS) && /textContent/.test(sc.NETWORK_JS) && !/innerHTML/.test(sc.NETWORK_JS), 'the live strip reads the Frame and writes text only');
+}
@@ -46,7 +46,11 @@ assert.strictEqual(t.work({ rows: [] }), '', 'nothing to fold, nothing drawn');
@@ -46,7 +46,11 @@ assert.strictEqual(t.work({ rows: [] }), '', 'nothing to fold, nothing drawn');
const ans = t.answer({ html: '<p>The <b>answer</b></p>', sources: ['https://www.example.com/a', 'not a url'], meta: ['Balanced · DeepSeek', '$0.002'], actions: [{ label: 'Details', href: '/tasks/1?view=details' }] });
const ans = t.answer({ html: '<p>The <b>answer</b></p>', sources: ['https://www.example.com/a', 'not a url'], meta: ['Balanced · DeepSeek', '$0.002'], actions: [{ label: 'Details', href: '/tasks/1?view=details' }] });
assert.ok(ans.includes('<div class="ovt-answer" data-ovt-answer><p>The <b>answer</b></p></div>'), 'the answer is the caller\'s safe markup');
assert.ok(ans.includes('<div class="ovt-answer" data-ovt-answer><p>The <b>answer</b></p></div>'), 'the answer is the caller\'s safe markup');
assert.ok(ans.includes('>example.com</a>') && ans.includes('>not a url</a>'), 'sources by host');
assert.ok(ans.includes('>example.com</a>') && ans.includes('>not a url</a>'), 'sources by host');
-assert.ok(ans.includes('<button type="button" class="ovt-act" data-ovt-copy hidden>'), 'Copy waits for the script');
+assert.ok(ans.includes('<button type="button" class="ovt-act" data-ovt-copy title="Copy" aria-label="Copy" hidden>'), 'Copy waits for the script, an icon button named by its word');
+assert.ok(ans.includes('title="Details" aria-label="Details"'), 'a site action is an icon button with its word as the name');
+const menu = t.composer({ action: '/chat', attach: { menu: true, library: 'https://openvibe.media/me' } });
+assert.ok(menu.includes('data-ovt-attach') && menu.includes('Photos and videos') && menu.includes('capture="environment"') && menu.includes('From your OpenVibe files'), 'attach as a menu: files, photos, the camera, your OpenVibe files');
+assert.ok(!/accept=/.test(menu.split('Photos and videos')[0]), 'Files takes every file type');
assert.ok(ans.includes('<span>Balanced · DeepSeek</span><span>$0.002</span>') && ans.includes('href="/tasks/1?view=details"'));
assert.ok(ans.includes('<span>Balanced · DeepSeek</span><span>$0.002</span>') && ans.includes('href="/tasks/1?view=details"'));
assert.ok(t.card({ tone: 'bad', title: 'Could not finish', text: 'It <ran> too long.' }).includes('<div class="ovt-card is-bad"><p><b>Could not finish</b> It <ran> too long.</p>'));
assert.ok(t.card({ tone: 'bad', title: 'Could not finish', text: 'It <ran> too long.' }).includes('<div class="ovt-card is-bad"><p><b>Could not finish</b> It <ran> too long.</p>'));
@@ -217,3 +217,91 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo
@@ -217,3 +217,91 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo
.ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }
.ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }
.ovt-panel, .ovt-chev { transition: none; }
.ovt-panel, .ovt-chev { transition: none; }
}
}
+
+/* ── The composer's Options (11-09, the owner: "too many options… hidden under a cog… less cluttered but still has the power
+ features"): one button; the pickers in a panel above it on a computer, a sheet on a phone; what differs from the defaults
+ shows as chips beside the button. ── */
+.ovt-opts { position: relative; flex: none; }
+.ovt-opts > summary { list-style: none; position: relative; }
+.ovt-opts > summary::-webkit-details-marker { display: none; }
+.ovt-opts-btn { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 14px; cursor: pointer; color: var(--text-secondary, #96a7c2); transition: background-color .15s, color .15s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }
+.ovt-opts-btn:hover, .ovt-opts[open] > .ovt-opts-btn { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); color: var(--text-primary, #e6edf7); }
+.ovt-opts-btn:active { transform: scale(.92); }
+.ovt-opts-btn .ovt-ic { width: 20px; height: 20px; }
+.ovt-opts-dot { position: absolute; top: 8px; right: 8px; width: 7px; height: 7px; border-radius: 50%; background: var(--ovt-accent); box-shadow: 0 0 0 2px var(--bg-card, #131c2e); }
+.ovt-opts-dot[hidden] { display: none; }
+.ovt-opts-panel { position: absolute; z-index: 40; left: -6px; bottom: calc(100% + 10px); width: min(380px, calc(100vw - 32px)); box-sizing: border-box; padding: 10px 10px 12px;
+ border-radius: 18px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); transform-origin: 20px 100%;
+ animation: ovt-opts-in .2s cubic-bezier(.2, .9, .3, 1) both; }
+.ovt.is-start .ovt-opts-panel { bottom: auto; top: calc(100% + 10px); transform-origin: 20px 0; }
+@keyframes ovt-opts-in { from { opacity: 0; transform: translateY(6px) scale(.97); } }
+.ovt-opts-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 8px 8px; }
+.ovt-opts-head b { font-size: 15px; font-weight: 700; }
+.ovt-opts-x { width: 36px; height: 36px; }
+.ovt-opts-body { display: grid; gap: 6px; }
+/* Each picker is a full row: icon, what it is, the value. Low specificity, so a site's own picker styles still win. */
+:where(.ovt-opts-body) > :where(label, .ovt-opt) { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 12px; border-radius: 12px; box-sizing: border-box;
+ background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); color: var(--text-primary, #e6edf7); }
+:where(.ovt-opts-body) :where(select, input[type="text"], input[type="url"], input:not([type])) { flex: 1; min-width: 0; height: 44px; border: 0; outline: 0; background: none; color: inherit; font: inherit; font-size: 15px; }
+.ovt-opts-chips { display: contents; }
+.ovt-opt-chip { display: inline-flex; align-items: center; gap: 2px; height: 32px; max-width: 200px; padding: 0 2px 0 12px; border-radius: 999px; box-sizing: border-box; cursor: pointer;
+ background: color-mix(in srgb, var(--ovt-accent) 13%, transparent); color: var(--accent-light, #93c5fd); font-size: 13px; font-weight: 600; animation: ovt-chip-in .18s ease both; }
+.ovt-opt-chip > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovt-opt-chip-x { flex: none; display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: 50%; background: none; color: inherit; font-size: 17px; line-height: 1; cursor: pointer; }
+.ovt-opt-chip-x:hover { background: color-mix(in srgb, var(--ovt-accent) 20%, transparent); }
+@keyframes ovt-chip-in { from { opacity: 0; transform: scale(.9); } }
+html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
+@media (max-width: 640px) {
+ .ovt-opts[open]::before { content: ''; position: fixed; inset: 0; z-index: 10064; background: rgba(0, 0, 0, .45); animation: ovt-fade .2s ease both; }
+ .ovt-opts-panel, .ovt.is-start .ovt-opts-panel { position: fixed; z-index: 10065; left: 0; right: 0; top: auto; bottom: 0; width: auto; max-height: min(80dvh, 640px); overflow-y: auto; overscroll-behavior: contain;
+ padding: 8px 14px calc(16px + env(safe-area-inset-bottom, 0px)); border-radius: 22px 22px 0 0; border-width: 1px 0 0; transform-origin: 50% 100%; animation: ovt-sheet-up .26s cubic-bezier(.2, .9, .3, 1) both; }
+ .ovt-opts-panel::before { content: ''; display: block; width: 40px; height: 4px; margin: 0 auto 8px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 22%, transparent); }
+}
+@keyframes ovt-sheet-up { from { transform: translateY(100%); } }
+@keyframes ovt-fade { from { opacity: 0; } }
+
+/* The examples walk through the placeholder with a fade. */
+.ovt-composer textarea::placeholder { transition: opacity .22s ease; }
+.ovt-composer textarea.is-ph-out::placeholder { opacity: 0; }
+
+/* The keyboard (navbar.js sets html.ov-kb-open and --ov-kb): a start page folds what it marks data-ovt-fold, so the box
+ and what you type stay on the screen above the keyboard. */
+:where([data-ovt-fold]) { transition: opacity .2s ease, max-height .28s cubic-bezier(.2, .9, .3, 1), margin .28s ease; max-height: 240px; }
+@media (max-width: 760px) {
+ html.ov-kb-open .ovt.is-start [data-ovt-fold] { opacity: 0; max-height: 0; margin-top: 0; margin-bottom: 0; overflow: hidden; pointer-events: none; }
+ html.ov-kb-open .ovt.is-start .ovt-empty-state { margin-top: 6px; }
+ /* A kit sidebar's button lives in the navbar (ov-thread.js); a head with nothing else in it goes. */
+ .ovt.has-nav-lead .ovt-head [data-ovk-sidebar-open] { display: none; }
+ .ovt.has-nav-lead .ovt-head:not(:has(> :not([data-ovk-sidebar-open], span.ovt-title:empty))) { display: none; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ovt-opts-panel, .ovt-opt-chip, .ovt-opts[open]::before { animation: none; }
+ :where([data-ovt-fold]) { transition: none; }
+}
+
+/* Answers' actions are icon buttons (11-09): 32 px, the word as tooltip; a copy shows a tick for a moment. */
+.ovt-acts .ovt-act { width: 32px; padding: 0; justify-content: center; }
+.ovt-acts .ovt-act .ovt-ic { width: 17px; height: 17px; }
+.ovt-act.is-done { color: var(--success, #22c55e); }
+
+/* Attach: one + button; with a menu, the choices in a small pop-over above it (a sheet's look on a phone). */
+.ovt-attach { display: inline-grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 14px; cursor: pointer; color: var(--text-secondary, #96a7c2); transition: background-color .15s, color .15s, transform .18s cubic-bezier(.2, 1.4, .3, 1); }
+.ovt-attach:hover, .ovt-attach-menu[open] > .ovt-attach { background: color-mix(in srgb, var(--ovt-accent) 14%, transparent); color: var(--text-primary, #e6edf7); }
+.ovt-attach:active { transform: scale(.92); }
+.ovt-attach .ovt-ic { width: 20px; height: 20px; }
+.ovt-attach-menu { position: relative; flex: none; }
+.ovt-attach-menu > summary { list-style: none; }
+.ovt-attach-menu > summary::-webkit-details-marker { display: none; }
+.ovt-attach-pop { position: absolute; z-index: 40; left: -6px; bottom: calc(100% + 10px); min-width: 230px; padding: 6px; border-radius: 16px; border: 1px solid var(--ovt-line); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovt-opts-in .18s cubic-bezier(.2, .9, .3, 1) both; transform-origin: 20px 100%; }
+.ovt.is-start .ovt-attach-pop { bottom: auto; top: calc(100% + 10px); transform-origin: 20px 0; }
+.ovt-attach-row { position: relative; display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 11px; color: var(--text-primary, #e6edf7); font-size: 14.5px; text-decoration: none; cursor: pointer; }
+.ovt-attach-row:hover, .ovt-attach-row:focus-within { background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
+.ovt-attach-row .ovt-ic { width: 19px; height: 19px; color: var(--text-secondary, #96a7c2); }
+.ovt-attach-cam { display: none; }
+@media (pointer: coarse) { .ovt-attach-cam { display: flex; } }
+
+/* The quiet line ("can get things wrong", "Sending…") sits under the box, not inside it. */
+.ovt-composer { position: relative; }
+.ovt-composer > .ovt-c-note { position: absolute; left: 14px; right: 14px; top: calc(100% + 6px); margin: 0; text-align: center; font-size: 12px; line-height: 1.4; }
+.ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); }
+.ovt.is-start .ovt-dock:has(.ovt-c-note:not(:empty)) { padding-bottom: max(40px, env(safe-area-inset-bottom, 0px)); }
@@ -31,6 +31,16 @@ const P = {
@@ -31,6 +31,16 @@ const P = {
menu: 'M4 7h16M4 12h16M4 17h16',
menu: 'M4 7h16M4 12h16M4 17h16',
chev: 'M8 10l4 4 4-4',
chev: 'M8 10l4 4 4-4',
copy: 'M9 9h10v10H9zM5 15V5h10',
copy: 'M9 9h10v10H9zM5 15V5h10',
+ retry: 'M4 12a8 8 0 0 1 14-5.3M20 4v4.5h-4.5M20 12a8 8 0 0 1-14 5.3M4 20v-4.5h4.5',
+ like: 'M7 11v9H4v-9zM7 11l4-7c1.5 0 2.5 1 2.5 2.5L13 10h5.5a2 2 0 0 1 2 2.3l-1.2 6A2 2 0 0 1 17.3 20H7',
+ dislike: 'M7 13V4H4v9zM7 13l4 7c1.5 0 2.5-1 2.5-2.5L13 14h5.5a2 2 0 0 0 2-2.3l-1.2-6A2 2 0 0 0 17.3 4H7',
+ share: 'M12 15V4M8 8l4-4 4 4M5 13v6h14v-6',
+ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
+ photo: 'M4 6h16v12H4zM4 15l4.5-4 3.5 3 2.5-2 5.5 4.5M15.5 9.5h.01',
+ camera: 'M4 8h3.5L9 5.5h6L16.5 8H20v11H4zM12 11a3 3 0 1 1 0 6 3 3 0 0 1 0-6z',
+ file: 'M6 3h8l4 4v14H6zM14 3v4h4',
+ cloud: 'M7 18h10.5a3.5 3.5 0 0 0 .3-7 5.5 5.5 0 0 0-10.6-1.5A4 4 0 0 0 7 18z',
+ add: 'M12 5v14M5 12h14',
more: 'M5 12h.01M12 12h.01M19 12h.01',
more: 'M5 12h.01M12 12h.01M19 12h.01',
tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',
tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',
ok: 'M5 12l5 5 9-10',
ok: 'M5 12l5 5 9-10',
@@ -42,6 +52,7 @@ const P = {
@@ -42,6 +52,7 @@ const P = {
send: 'M5 12l14-7-5 14-2.5-5.5z',
send: 'M5 12l14-7-5 14-2.5-5.5z',
stop: 'M7 7h10v10H7z',
stop: 'M7 7h10v10H7z',
attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',
attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',
+ options: 'M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5',
handover: 'M4 8h13l-3-3M20 16H7l3 3',
handover: 'M4 8h13l-3-3M20 16H7l3 3',
wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
panel: 'M4 4h16v16H4zM15 4v16',
panel: 'M4 4h16v16H4zM15 4v16',
@@ -192,8 +203,10 @@ ${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>`
@@ -192,8 +203,10 @@ ${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>`
function answer({ html = '', files: att = [], sources = [], meta = [], actions = [], copy = true } = {}) {
function answer({ html = '', files: att = [], sources = [], meta = [], actions = [], copy = true } = {}) {
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return clip(u, 40); } };
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return clip(u, 40); } };
const acts = [
const acts = [
- copy ? `<button type="button" class="ovt-act" data-ovt-copy hidden>${icon('copy')}<span>Copy</span></button>` : '',
- ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}">${icon(a.icon || 'more')}<span>${esc(a.label)}</span></a>`),
+ // Icon buttons, as every assistant draws them (11-09, the owner: "Copy and Try again should be actual icons"): the
+ // word is the tooltip and the accessible name.
+ copy ? `<button type="button" class="ovt-act" data-ovt-copy title="Copy" aria-label="Copy" hidden>${icon('copy')}<span class="ovt-sr">Copy</span></button>` : '',
+ ...actions.map((a) => `<a class="ovt-act" href="${esc(a.href)}" title="${esc(a.label)}" aria-label="${esc(a.label)}">${icon(a.icon || (/again|retry/i.test(String(a.label)) ? 'retry' : 'more'))}<span class="ovt-sr">${esc(a.label)}</span></a>`),
].join('');
].join('');
return `<div class="ovt-answer" data-ovt-answer>${html}</div>
return `<div class="ovt-answer" data-ovt-answer>${html}</div>
${files(att)}
${files(att)}
@@ -230,6 +243,32 @@ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {
@@ -230,6 +243,32 @@ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {
return `<aside class="ovt-panel" id="${esc(id)}" aria-label="${esc(title)}" data-ovt-panel><div class="ovt-panel-head"><h2>${esc(title)}</h2><button type="button" class="ovt-btn ovt-panel-close" data-ovt-panel-close aria-label="Close">${icon('close')}</button></div>${secs}</aside>`;
return `<aside class="ovt-panel" id="${esc(id)}" aria-label="${esc(title)}" data-ovt-panel><div class="ovt-panel-head"><h2>${esc(title)}</h2><button type="button" class="ovt-btn ovt-panel-close" data-ovt-panel-close aria-label="Close">${icon('close')}</button></div>${secs}</aside>`;
}
}
+/**
+ * Attach (11-09, the owner: "a better file attach button… support all file types"): one + button. Every file type by
+ * default (attach.accept narrows it). With attach.menu, a small menu: Files, Photos and videos, Take a photo (a phone's
+ * camera), and From your OpenVibe files when the site gives attach.library (an href). All the inputs share one name, so
+ * the plain form posts them all.
+ */
+function attachHtml(attach) {
+ const name = esc(attach.name || 'files');
+ const input = (accept, extra = '') => `<input type="file" name="${name}"${accept ? ` accept="${esc(accept)}"` : ''} multiple data-ovt-files${extra} hidden>`;
+ if (!attach.menu) return `<label class="ovt-btn ovt-attach" title="Attach files">${icon('add')}<span class="ovt-sr">Attach files</span>${input(attach.accept)}</label>`;
+ const row = (ic, label, inner) => `<label class="ovt-attach-row" role="menuitem">${icon(ic)}<span>${esc(label)}</span>${inner}</label>`;
+ return `<details class="ovt-attach-menu" data-ovt-attach><summary class="ovt-btn ovt-attach" title="Attach" aria-label="Attach">${icon('add')}</summary><div class="ovt-attach-pop" role="menu">`
+ + row('file', 'Files', input(attach.accept))
+ + row('photo', 'Photos and videos', input('image/*,video/*'))
+ + `<label class="ovt-attach-row ovt-attach-cam" role="menuitem">${icon('camera')}<span>Take a photo</span>${input('image/*', ' capture="environment"')}</label>`
+ + (attach.library ? `<a class="ovt-attach-row" role="menuitem" href="${esc(attach.library)}">${icon('cloud')}<span>From your OpenVibe files</span></a>` : '')
+ + '</div></details>';
+}
+
+/** The composer's Options: one button, the pickers behind it, and the chips of what differs from the defaults. */
+function optionsHtml({ html, label = 'Options' }) {
+ return `<details class="ovt-opts" data-ovt-opts><summary class="ovt-btn ovt-opts-btn" aria-label="${esc(label)}" title="${esc(label)}">${icon('options')}<i class="ovt-opts-dot" aria-hidden="true"></i></summary>`
+ + `<div class="ovt-opts-panel" role="group" aria-label="${esc(label)}"><div class="ovt-opts-head"><b>${esc(label)}</b><button type="button" class="ovt-btn ovt-opts-x" data-ovt-opts-close aria-label="Done">${icon('close')}</button></div><div class="ovt-opts-body">${html}</div></div></details>`
+ + '<span class="ovt-opts-chips" data-ovt-opts-chips></span>';
+}
+
/**
/**
* The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model
* The composer, at the bottom of the screen: a box that grows as you type, the caller's tools (a mode chip, a model
* chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script.
* chip, attach), and Send, which becomes Stop while a turn works (busy). Works as a plain form without script.
@@ -239,15 +278,20 @@ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {
@@ -239,15 +278,20 @@ function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {
* id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes
* id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes
* foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that,
* foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that,
* which is also the form's status line (role="status": a site's script says "Sending…" there)
* which is also the form's status line (role="status": a site's script says "Sending…" there)
+ * options: { html, label } the site's safe pickers behind one Options button (11-09, the owner: "too many options…
+ * hidden under a cog"): a panel on a computer, a sheet on a phone, a <details> without script. A picker whose
+ * <select> or radio group carries data-ovt-default="<value>" shows as a chip beside the button while it differs
+ * (its × puts the default back), and the button wears a dot.
+ * examples: ['Make the home page faster on phones', …] the box's placeholder walks through them while it is empty
*/
*/
-function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '' } = {}) {
+function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [] } = {}) {
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}
<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}
${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
-<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
-<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>
+<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''}${examples && examples.length ? ` data-ovt-examples="${esc(JSON.stringify(examples.slice(0, 12).map((x) => String(x).slice(0, 140))))}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
+<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}</div>
${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>
${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>
${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')}</p>
${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')}</p>
</form>`;
</form>`;