openvibers/openvibe.shared Commits

4.160.0: Feed v2, section icons, composer Options, navbar slots, the keyboard, attach (#368)

OP OpenVibers committed 4 days ago · f8090f3a25 · 30 files, +1065 −65

* ai-marks: the AI makers' marks in one place

Exchange, Report, Chat, Bench and Design list models; their family glyphs were our own drawings and read as stray
shapes (owner, 11-09). ai-marks.js resolves the maker from a model id or name (16 makers) and draws its monochrome mark
in a theme tile, labelled; unknown makers get the neutral spark. Drawings from @lobehub/icons-static-svg 1.102.0 (MIT),
vendored by scripts/build-ai-marks.js with the source and licence in the file header.

* Section headings with icons; More on OpenVibe with a live strip and every site

The owner, 11-09 (Pics' plain "More on OpenVibe" cards; section titles without icons): every showcase section heading
carries an icon tile (a default per kind, icon: overrides) and sections stand apart with a hairline; the network section
is "More on OpenVibe" with the sites' icons, a live strip of what is happening across the network (NETWORK_JS reads the
Frame, text only) and "Every OpenVibe site".

* The Dock's pin: an upright pushpin whose lines never cross

The old drawing was two outlines crossing, with the needle running through the head (owner, 11-09: "why does this pin
icon look a little off… a line going through it"). Now a cap, a body that widens to a flat base, and the needle.

* sibling revs after rebase

* Feed v2: the menu's Feed is a real feed

The owner, 11-09 (screenshots of the old Feed: a forum list 16 days old on top, What's new, Next from, the chooser):
"a real feed… you scroll through easy content… a filter system… clean". ov-home.js feed(): Community's /api/v1/feed as
one stream of cards by kind with chips on top, muted previews that play only in view, more as the end comes near, a
caught-up line; when the feed answers the Feed tab drops its old sections; without it the wall stands.
Test: menu-feed-v2-chrome.

* sibling revs after rebase

* Composer Options, the navbar's own buttons, the keyboard

- thread.composer({ options, examples }): one Options button holds the pickers (a panel on a computer, a sheet on a
  phone with the page held still, a <details> without script); a picker that differs from its data-ovt-default shows
  as a chip whose x puts the default back, and the button wears a dot. The placeholder walks through the examples.
- OpenVibeNavbar.lead(spec) and .action(spec): a site's own button before the brand (a kit sidebar's toggle on a
  phone, a back arrow) and icon buttons before the bell, with known icons or path data, counts and handlers; handles
  set() and remove(). ov-thread.js moves a kit sidebar's button there, and an empty head row goes.
- The keyboard: html.ov-kb-open and --ov-kb (visualViewport), set by the navbar on every site; a start page folds
  what it marks data-ovt-fold, and the Dock's phone sheet ends above the keyboard.
- On a touch screen the Dock's Vibe and chats no longer open the keyboard by themselves.

* Merge ai-marks

* Merge dock-pin-icon

* Merge feed-v2-tab

# Conflicts:
#	navbar.js

* Merge more-on-openvibe

# Conflicts:
#	CHANGELOG.md

* Merge composer-options

# Conflicts:
#	navbar.js

* 4.160.0: ai-marks, the pin, Feed v2, section icons and More on OpenVibe, composer Options, navbar slots, the keyboard, attach, icon actions, the drawer

* 4.160.0: the slots load as nav-slots.js; ai-marks-data exported; the Feed hides an empty wall and old news stops saying New

- The navbar's lead and action buttons are drawn by a sibling, nav-slots.js, loaded the first time a site asks; the
  navbar keeps only the keyboard watcher every site needs (budget 46 -> 47 KB, dated).
- ai-marks-data.js is in the package's exports.
- Feed (the owner, 11-09: "why does it say show more if there's nothing new, and why is New in the forum first when
  it's 17 days ago"): an empty wall is no section at all and Show more hides for real; a card older than 3 days drops
  "New" from its title, fresher cards come first until the person orders them, cards older than 2 weeks go after
  What's new.

* 4.160.0: nav-slots.js is served; a feed card with no known age is never called old

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1f12582..101c801 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 and
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 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
ddiff --git a/README.md b/README.md
iindex 1869ed6..0010d82 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 3b37f8d..bd6b794 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/ai-marks-data.js b/ai-marks-data.js
nnew file mode 100644
iindex 0000000..0a4079e
---- /dev/null
++++ b/ai-marks-data.js
@@@ -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>"
++};
ddiff --git a/ai-marks.js b/ai-marks.js
nnew file mode 100644
iindex 0000000..695b376
---- /dev/null
++++ b/ai-marks.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex d8ca1c4..367b45e 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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) {
ddiff --git a/dock.js b/dock.js
iindex 5061d1c..bf77404 100644
---- a/dock.js
++++ b/dock.js
@@@ -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) {
ddiff --git a/files.js b/files.js
iindex e3aa10d..6503434 100644
---- a/files.js
++++ b/files.js
@@@ -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
ddiff --git a/kit-web.js b/kit-web.js
iindex 69e8c32..583be67 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -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;
ddiff --git a/kit.css b/kit.css
iindex 867c933..c1e7784 100644
---- a/kit.css
++++ b/kit.css
@@@ -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; }
++}
ddiff --git a/nav-slots.js b/nav-slots.js
nnew file mode 100644
iindex 0000000..86d49d3
---- /dev/null
++++ b/nav-slots.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
ddiff --git a/navbar.js b/navbar.js
iindex 47b89e7..d5605f8 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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); },
ddiff --git a/ov-home.js b/ov-home.js
iindex 3ad667b..40616e1 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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);
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 2cccaa5..f1bee44 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex ce140a9..b2415a3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 99001f8..ed13927 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/build-ai-marks.js b/scripts/build-ai-marks.js
nnew file mode 100644
iindex 0000000..0924613
---- /dev/null
++++ b/scripts/build-ai-marks.js
@@@ -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);
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex e7a95be..28c129d 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -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'] },
ddiff --git a/search.js b/search.js
iindex 6477022..33e79ff 100644
---- a/search.js
++++ b/search.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() {
ddiff --git a/showcase.css b/showcase.css
iindex 8be1aff..f90486e 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -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}
ddiff --git a/showcase.js b/showcase.js
iindex 514bd26..816a734 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -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">✓</sp
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. */
--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, mor
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 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 };
ddiff --git a/test/ai-marks.test.js b/test/ai-marks.test.js
nnew file mode 100644
iindex 0000000..ce6049f
---- /dev/null
++++ b/test/ai-marks.test.js
@@@ -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="&lt;script&gt;x&lt;/script&gt;"') && !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');
ddiff --git a/test/composer-options-chrome.test.js b/test/composer-options-chrome.test.js
nnew file mode 100644
iindex 0000000..e7ff711
---- /dev/null
++++ b/test/composer-options-chrome.test.js
@@@ -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); });
ddiff --git a/test/menu-feed-v2-chrome.test.js b/test/menu-feed-v2-chrome.test.js
nnew file mode 100644
iindex 0000000..e90eb41
---- /dev/null
++++ b/test/menu-feed-v2-chrome.test.js
@@@ -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); });
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 387d123..9041355 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{
ddiff --git a/test/ov-home.test.js b/test/ov-home.test.js
iindex e9b6db7..95271ac 100644
---- a/test/ov-home.test.js
++++ b/test/ov-home.test.js
@@@ -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');
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 874a395..5645be7 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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(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');
++}
ddiff --git a/test/thread.test.js b/test/thread.test.js
iindex 8bb352b..a5d43a6 100644
---- a/test/thread.test.js
++++ b/test/thread.test.js
@@@ -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 &lt;ran&gt; too long.</p>'));
ddiff --git a/thread.css b/thread.css
iindex 8a2478b..b38bec3 100644
---- a/thread.css
++++ b/thread.css
@@@ -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)); }
ddiff --git a/thread.js b/thread.js
iindex 7093b48..06ada76 100644
---- a/thread.js
++++ b/thread.js
@@@ -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>`;