openvibers/openvibe.shared Commits

4.163.0: You redone, smart folders, drag lands where you drop (#376)

OP OpenVibers committed 4 days ago · 9c6d14872d · 10 files, +252 −42

* You: smart folders by domain; Jump back in keeps time; the video player's bar matches Live's

- Until a person arranges their home, the sites past the first page sit in folders made from the network's groups:
  Watch, People, Make, Read, Play and Shop (the owner, 11-09: "better organization based on all domains, dynamic").
- Jump back in's times update every minute while the menu is open.
- openvibe-shared player: 40 px square buttons (44 on touch) with centred 22 px icons and the time as a glass chip,
  the same bar as Live's.

* You: you on top with Display and Settings, Jump back in as cards, Most used, a drag that lands where you drop

The owner, 11-09: "the U tab still sucks, and you didn't do all the improvements".
- You on top: your picture (online), name and @handle linking to your profile; Display opens the Look panel (theme,
  icons, appearance, text size, motion) in place under your name (navbar's bindLook, passed as look); Settings goes to
  Account. Display leaves the foot of the tab for signed-in people.
- Jump back in: the last eight places as cards you swipe (hue icons, two-line titles, times that keep up, LIVE with a
  breathing ring for a stream that is on now), replacing Recent, which repeated the home grid.
- Most used: the pages you opened per site as one bar and its key, in five colours that tell apart.
- Your own AI: one compact row (mark, words, Set it up, an x), a third of the height.
- Folders show four different icons (Quick tools showed </> four times).
- Drag: the drop is found by the grid's slots, not the sliding apps' drawn boxes, so an app passing under the finger
  no longer takes the drop (it landed one place off now and then).
- Calm keeps the cards' fade and a slower live breath.

* 4.163.0: You redone (you on top, Display beside your name, Jump back in cards, Most used), smart folders, drag lands where you drop

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 952f2b7..a0fa58d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,22 @@
# Changelog
++## 4.163.0 — 2026-11-09
++
++- **You, redone** (the owner, 11-09: "the U tab still sucks, and you didn't do all the improvements"). You on top: your
++ picture (online), name and @handle linking to your profile, with **Display** beside it (the Look panel: theme, icons,
++ appearance, text size, motion, opening in place; navbar passes `look` to the menu) and **Settings** (Account). Display
++ leaves the foot of the tab for signed-in people.
++- **Jump back in** is a row of cards you swipe: each place's own hue icon, a two-line title, a time that keeps up while the
++ menu is open, and LIVE with a breathing ring for a stream that is on now. It replaces Recent, which repeated the grid.
++- **Most used**: the pages you opened per site, as one bar and its key (five colours that tell apart in dark and light).
++- **Your own AI** is one compact row (mark, words, Set it up, an ×): a third of the height.
++- **Smart folders**: until you arrange your home, the sites past the first page sit in folders from the network's groups
++ (Watch, People, Make, Read, Play, Shop). A folder shows four different icons (Quick tools showed `</>` four times).
++- **Drag lands where you drop**: the drop is found by the grid's slots, not the sliding apps' drawn boxes (an app sliding
++ under the finger took the drop, so it landed one place off now and then).
++- **The video player's bar matches Live's**: 40 px buttons (44 on touch), centred icons.
++- Calm keeps the cards' fade and a slower live breath.
++
## 4.162.0 — 2026-11-09
- `kit.head` accepts an icon tile; `kit.section` accepts an icon and an action row that wraps on phones.
ddiff --git a/README.md b/README.md
iindex 19cf1fd..3bf8c16 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.162.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.163.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 26767aa..54c5b29 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.162.0",
++ "version": "4.163.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/navbar.js b/navbar.js
iindex e9a1a7c..33cbabc 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // 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': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '185fc639597f', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', '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': 'd18308ab8a63', 'ov-home.js': 'b85341c89bd9', 'nav-slots.js': 'a5af310d62e0' };
++ const SIBLING_REV = { 'ov-mark.js': 'd7e87def8fff', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': '8d00547b413c', 'dock.js': '496a40c534ce', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', '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': 'd18308ab8a63', 'ov-home.js': 'd0baedb2ce11', '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">.
@@@ -2322,6 +2322,7 @@
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
++ look: bindLook,
onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true');
};
ddiff --git a/ov-home.js b/ov-home.js
iindex ac48f89..ed4e0fa 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -61,7 +61,7 @@
box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1); }
.ovh-recent .ovh-ic { width: 48px; height: 48px; border-radius: 15px; }
/* App icons: the ring goes, the glyph fills the tile, each app keeps its own hue (11-09). */
--.ovh-grid .ovh-ic, .ovh-recent .ovh-ic { background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .34), hsl(var(--ovh-h, 215) 70% 38% / .18)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 64% / .28), 0 4px 14px hsl(var(--ovh-h, 215) 70% 30% / .18); overflow: hidden; }
++.ovh-grid .ovh-ic, .ovh-recent .ovh-ic, .ovh-card .ovh-ic { background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .34), hsl(var(--ovh-h, 215) 70% 38% / .18)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 64% / .28), 0 4px 14px hsl(var(--ovh-h, 215) 70% 30% / .18); overflow: hidden; }
.ovh-ic .ovi-ring, .ovh-ic .ovi-bg, .ovh-ic .ovi-comet, .ovh-ic .ovi-prog { display: none; }
.ovh-ic .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 90% 82%); color: hsl(var(--ovh-h, 215) 90% 82%); }
:root[data-theme="light"] .ovh-ic .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 70% 32%); color: hsl(var(--ovh-h, 215) 70% 32%); }
@@@ -142,6 +142,71 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-lives a { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; background: color-mix(in srgb, var(--live, #ef4444) 12%, transparent); color: inherit; text-decoration: none; font-size: 13px; font-weight: 650; }
.ovh-lives a::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--live, #ef4444); }
@media (prefers-reduced-motion: reduce) { .ovh.is-edit .ovh-grid .ovh-ic { animation-name: ovh-wiggle-calm !important; } .ovh-chip.is-working .ovh-dot { animation: none !important; } }
++
++/* You (11-09, the owner: "the U tab still sucks"): you on top, with Display and Settings beside your name; what you were
++ doing as a rail of cards; what you use most as one bar; then your home. */
++.ovh-me { display: flex; align-items: center; gap: 10px; margin: 4px 4px 8px; padding: 10px 10px 10px 12px; border-radius: 20px;
++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), transparent 60%), color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--border, rgba(255,255,255,.08))); }
++.ovh-who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; border-radius: 14px; color: inherit; text-decoration: none; }
++.ovh-av { position: relative; flex: none; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 50%, #a855f7)); color: #fff; font-size: 19px; font-weight: 800; }
++.ovh-av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
++.ovh-av::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 12px; height: 12px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 3px var(--bg-secondary, #141a24); }
++.ovh-who-txt { min-width: 0; display: grid; gap: 2px; }
++.ovh-who-txt b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 800; letter-spacing: -.01em; }
++.ovh-who-txt small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
++.ovh-ib { flex: none; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; cursor: pointer; text-decoration: none;
++ background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); transition: background .16s, color .16s, transform .16s cubic-bezier(.2,1.4,.4,1); }
++.ovh-ib svg { display: block; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
++.ovh-ib[aria-expanded="true"] { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
++.ovh-ib:active { transform: scale(.9); }
++.ovh-ib:focus-visible, .ovh-who:focus-visible, .ovh-card:focus-visible, .ovh-key:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++@media (hover: hover) { .ovh-ib:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 14%, transparent); } .ovh-ib[aria-expanded="true"]:hover { background: var(--accent-strong, var(--accent, #2563eb)); } }
++.ovh-me-look { margin: -2px 4px 8px; }
++.ovh-rail { display: flex; gap: 8px; margin: 0 -2px; padding: 2px 6px 8px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding: 0 6px; scrollbar-width: none;
++ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
++.ovh-rail::-webkit-scrollbar { display: none; }
++.ovh-rail.is-end { -webkit-mask-image: none; mask-image: none; }
++.ovh-card { position: relative; flex: 0 0 auto; display: grid; grid-template-columns: 40px minmax(0, 1fr); grid-template-rows: auto auto; align-content: center; column-gap: 10px; row-gap: 3px; align-items: center;
++ box-sizing: border-box; width: min(74%, 240px); min-height: 68px; padding: 10px 12px 10px 10px; border-radius: 16px; scroll-snap-align: start; color: inherit; text-decoration: none;
++ background: color-mix(in srgb, var(--text-primary, #e8ecf3) 5%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.08));
++ animation: ovh-rise .38s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 40ms); }
++.ovh-card .ovh-ic { grid-row: 1 / 3; width: 40px; height: 40px; border-radius: 12px; }
++.ovh-card b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-size: 13.5px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
++.ovh-card small { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 11.5px; color: var(--text-muted, #93a0b4); }
++.ovh-card small > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovh-card.is-live { background: color-mix(in srgb, var(--live, #ef4444) 9%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--live, #ef4444) 40%, transparent); }
++.ovh-card.is-live .ovh-ic { animation: ovh-onair 2.4s ease-in-out infinite; }
++.ovh-live { flex: none; padding: 2px 6px; border-radius: 6px; background: var(--live, #ef4444); color: #fff; font-size: 10px; font-weight: 800; letter-spacing: .05em; line-height: 1.2; }
++@media (hover: hover) { .ovh-card:hover { background: var(--bg-hover, rgba(255,255,255,.08)); } }
++@media (min-width: 861px) { .ovh-card { width: 232px; } }
++.ovh-use { margin: 0 4px 8px; padding: 12px 12px 6px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
++.ovh-use-bar { display: flex; gap: 3px; height: 12px; border-radius: 999px; overflow: hidden; }
++.ovh-use-bar > span { flex: var(--n, 1) 1 0; min-width: 8px; background: var(--c, #3b82f6); transform-origin: left center; animation: ovh-grow .7s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 60ms); }
++.ovh-use-keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0 4px; margin: 6px -6px 0; }
++.ovh-key { display: flex; align-items: center; gap: 7px; min-width: 0; min-height: 38px; padding: 0 8px; border-radius: 10px; color: inherit; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; }
++.ovh-key::before { content: ""; flex: none; width: 10px; height: 10px; border-radius: 3px; background: var(--c, #3b82f6); }
++.ovh-key small { color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
++@media (hover: hover) { .ovh-key:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } }
++.ovh-sec small { flex: none; font-size: 11px; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted, #93a0b4); }
++@keyframes ovh-rise { from { opacity: 0; transform: translateY(6px) scale(.98); } }
++@keyframes ovh-grow { from { transform: scaleX(0); } }
++@keyframes ovh-onair { 50% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--live, #ef4444) 55%, transparent), 0 0 18px color-mix(in srgb, var(--live, #ef4444) 35%, transparent); } }
++/* Calm is gentle, not still (the owner, 11-09): shorter travel, slower breath. */
++html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-duration: .5s; }
++html[data-ov-motion=reduced] .ovh-card.is-live .ovh-ic { animation-duration: 4s; }
++@keyframes ovh-fade { from { opacity: 0; } }
++@media (prefers-reduced-motion: reduce) { .ovh-card { animation-name: ovh-fade; } .ovh-use-bar > span { animation-duration: .4s; } }
++/* The invitation, one compact row (11-09): mark, words, Set it up, and an × to put it away. */
++.ovh-actor.is-invite { grid-template-columns: 44px minmax(0, 1fr) auto auto; grid-template-rows: auto auto; gap: 2px 10px; align-items: center; min-height: 0; padding: 10px 6px 12px 12px; }
++.ovh-actor.is-invite .ovh-face { grid-row: 1 / 3; align-self: start; width: 44px; height: 44px; border-radius: 14px; }
++.ovh-actor.is-invite .ovh-actor-txt { display: contents; }
++.ovh-actor.is-invite .ovh-actor-txt b { grid-column: 2; grid-row: 1; font-size: 15px; }
++.ovh-actor.is-invite .ovh-actor-txt small { grid-column: 2 / 5; grid-row: 2; -webkit-line-clamp: 3; }
++.ovh-actor.is-invite .ovh-acts { display: contents; }
++.ovh-actor.is-invite .ovh-go { grid-column: 3; grid-row: 1; }
++.ovh-actor.is-invite .ovh-x { grid-column: 4; grid-row: 1; display: grid; place-items: center; width: 36px; min-width: 36px; padding: 0; border-radius: 50%; }
++.ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
`;
document.head.appendChild(st);
}
@@@ -179,19 +244,28 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
(o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
return byId;
}
++ // The network's groups, as folders for the sites past the first page (until the person arranges their home).
++ const GROUP_FOLDERS = [['streams', 'Watch'], ['people', 'People'], ['make', 'Make'], ['answers', 'Read'], ['games', 'Play'], ['shop', 'Shop']];
++ function topSites(o) {
++ const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id));
++ const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);
++ return sites.slice(0, Math.max(6, 12 - 2 - mineFirst.length));
++ }
function folderDefs(o) {
const apps = o.apps || [];
-- return [
++ const top = new Set(topSites(o));
++ const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: `g-${g}`, name, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))
++ .filter((f) => f.apps.length >= 2);
++ return groups.concat([
{ id: 'tools', name: 'Quick tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
{ id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },
-- ].filter((f) => f.apps.length);
++ ]).filter((f) => f.apps.length);
}
/** Until the person arranges it: their own places first (signed in), then the most used and popular sites. */
function defaultHome(o) {
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);
-- const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);
-- const room = 12 - 2 - mineFirst.length;
-- return mineFirst.concat(sites.slice(0, Math.max(6, room)), ['folder:tools', 'folder:build']);
++ const groupFolders = folderDefs(o).filter((f) => f.id.startsWith('g-')).map((f) => `folder:${f.id}`);
++ return mineFirst.concat(topSites(o), groupFolders, ['folder:tools', 'folder:build']);
}
function layoutOf(o) {
const byId = universe(o);
@@@ -202,6 +276,20 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
return { home, folders, arranged: !!l, byId };
}
++ // Line icons for the buttons beside your name (24-unit boxes, optically centred), built as nodes.
++ const ICON = {
++ look: [['path', { d: 'M12 3a9 9 0 1 0 0 18c1 0 1.7-.8 1.7-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.4-.7-.4-1.1 0-1 .8-1.7 1.7-1.7h2.1A4.4 4.4 0 0 0 21 11c0-4.4-4-8-9-8Z' }],
++ ['circle', { cx: 7.6, cy: 11.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 10.4, cy: 7.4, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 15, cy: 7.6, r: 1.1, fill: 'currentColor', stroke: 'none' }]],
++ gear: [['circle', { cx: 12, cy: 12, r: 3 }], ['path', { d: 'M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z' }]],
++ x: [['path', { d: 'M6 6l12 12M18 6 6 18' }]],
++ };
++ function svgIcon(name) {
++ const NS = 'http://www.w3.org/2000/svg';
++ const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
++ ICON[name].forEach(([tag, at]) => { const n = document.createElementNS(NS, tag); Object.keys(at).forEach((k) => n.setAttribute(k, String(at[k]))); s.append(n); });
++ return s;
++ }
++
// ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────
function siteIcon(site, size) {
const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');
@@@ -229,7 +317,11 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);
const ic = h('span', 'ovh-ic');
// Each app's own icon (a tool's own glyph, not the folder's: four identical </> was the owner's complaint, 11-09).
-- f.apps.slice(0, 4).forEach((id) => { const a = byId.get(id) || (o.tools || []).find((t) => t.id === id); const cell = h('span'); const g = a ? (a.site || a.icon || f.glyph) : f.glyph; cell.style.setProperty('--ovh-h', String(hueOf(g + id))); cell.append(siteIcon(g, 26)); ic.append(cell); });
++ const glyphOf = (id) => { const a = byId.get(id) || (o.tools || []).find((t) => t.id === id); return a ? (a.site || a.icon || f.glyph) : f.glyph; };
++ const seenG = new Set(), cells = [];
++ f.apps.forEach((id) => { const g = glyphOf(id); if (cells.length < 4 && !seenG.has(g)) { seenG.add(g); cells.push([id, g]); } });
++ f.apps.forEach((id) => { if (cells.length < 4 && !cells.some((c) => c[0] === id)) cells.push([id, glyphOf(id)]); });
++ cells.forEach(([id, g]) => { const cell = h('span'); cell.style.setProperty('--ovh-h', String(hueOf(g + id))); cell.append(siteIcon(g, 26)); ic.append(cell); });
el.append(ic, h('span', 'ovh-name', f.name));
return el;
}
@@@ -243,20 +335,92 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
let L = layoutOf(o);
const save = () => { const keep = { home: L.home.slice(0, HOME_MAX), folders: L.folders.map((f) => ({ id: f.id, name: f.name, apps: f.apps.slice(0, 400) })) }; writeLocal(keep); pushRemote(o, keep); L.arranged = true; };
const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };
-- let actorBox = null;
++ let actorBox = null, meBox = null;
++ // The Look panel (theme, text size, motion, icons) opens under your name, drawn by the navbar (the account menu's).
++ if (typeof o.look === 'function') { try { o.look(el); } catch { /* the button stays a link to openvibe.design/look */ } }
++
++ /** You: your picture (online), your name, and Display and Settings beside it. Made once per opening, so an open
++ * Look panel survives the repaints that come as history and the catalogue arrive. */
++ function me() {
++ if (meBox) return meBox;
++ const u = o.user || {};
++ meBox = h('div');
++ const bar = h('div', 'ovh-me');
++ const who = h('a', 'ovh-who'); who.href = u.username ? `https://openvibe.network/@${encodeURIComponent(u.username)}` : 'https://openvibe.network/my';
++ const av = h('span', 'ovh-av');
++ const pic = /^https:\/\//.test(String(u.avatar_url || '')) ? u.avatar_url : '';
++ if (pic) { const img = h('img'); img.alt = ''; img.src = pic; img.decoding = 'async'; av.append(img); } else av.textContent = String(u.display_name || u.username || '?').trim().charAt(0).toUpperCase();
++ const txt = h('span', 'ovh-who-txt');
++ txt.append(h('b', '', u.display_name || u.username || 'You'), h('small', '', [u.username ? `@${u.username}` : '', 'Online'].filter(Boolean).join(' · ')));
++ who.append(av, txt);
++ const look = h('a', 'ovh-ib'); look.append(svgIcon('look')); look.href = 'https://openvibe.design/look'; look.setAttribute('data-ov-look-open', '');
++ look.setAttribute('aria-expanded', 'false'); look.setAttribute('aria-label', 'Display: theme, text size, motion'); look.title = 'Display';
++ const gear = h('a', 'ovh-ib'); gear.append(svgIcon('gear')); gear.href = 'https://openvibe.network/my'; gear.setAttribute('aria-label', 'Account and settings'); gear.title = 'Account and settings';
++ bar.append(who, look, gear);
++ const panel = h('div', 'ud-look ovh-me-look'); panel.hidden = true;
++ meBox.append(bar, panel);
++ return meBox;
++ }
++
++ /** Jump back in: the last places, as cards you swipe; a stream that is live now says so. */
++ function backRail() {
++ const items = (o.back || []).filter((x) => x && safe(x.url)).slice(0, 8);
++ if (!items.length) return null;
++ const wrap = h('div');
++ const head = sec('Jump back in');
++ wrap.append(head);
++ const rail = h('div', 'ovh-rail'); rail.setAttribute('role', 'list'); rail.setAttribute('aria-label', 'Jump back in');
++ items.forEach((x, i) => {
++ const live = x.tag === 'Live';
++ const c = h('a', `ovh-card${live ? ' is-live' : ''}`); c.href = safe(x.url); c.setAttribute('role', 'listitem'); c.style.setProperty('--i', String(i));
++ const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(x.site || 'ov'))); ic.append(siteIcon(x.site || 'ov', 44));
++ const meta = h('small');
++ if (live) meta.append(h('span', 'ovh-live', 'LIVE'));
++ const pre = String(x.metaPre || '').replace(/^Live now(?: · )?/, '');
++ const when = h('span', '', [pre, ago(x.at)].filter(Boolean).join(' · ') || x.meta || '');
++ if (x.at) { when.dataset.at = x.at; when.dataset.pre = pre; } // search.js keeps the times current
++ meta.append(when);
++ c.append(ic, h('b', '', x.title), meta);
++ rail.append(c);
++ });
++ const ends = () => rail.classList.toggle('is-end', rail.scrollLeft + rail.clientWidth >= rail.scrollWidth - 4);
++ rail.addEventListener('scroll', ends, { passive: true });
++ requestAnimationFrame(ends);
++ wrap.append(rail);
++ return wrap;
++ }
++
++ /** Most used: the pages you opened, by site, as one bar and its key. */
++ function usage() {
++ const rows = (o.usage || []).map((u) => ({ a: L.byId.get(u.id), n: u.count })).filter((r) => r.a && r.n > 0).sort((x, y) => y.n - x.n).slice(0, 5);
++ if (rows.length < 2) return null;
++ const wrap = h('div');
++ const head = sec('Most used');
++ head.append(h('small', '', 'pages you opened'));
++ wrap.append(head);
++ const box = h('div', 'ovh-use');
++ const bar = h('div', 'ovh-use-bar'); bar.setAttribute('role', 'img');
++ bar.setAttribute('aria-label', rows.map((r) => `${r.a.title} ${r.n}`).join(', '));
++ const keys = h('div', 'ovh-use-keys');
++ const CHART = ['#3b82f6', '#a855f7', '#f59e0b', '#14b8a6', '#ec4899']; // five that tell apart in dark and light
++ rows.forEach((r, i) => {
++ const c = CHART[i % CHART.length];
++ const seg = h('span'); seg.style.setProperty('--n', String(r.n)); seg.style.setProperty('--c', c); seg.style.setProperty('--i', String(i)); bar.append(seg);
++ const k = h('a', 'ovh-key', r.a.title); k.href = safe(r.a.url) || '#'; k.style.setProperty('--c', c);
++ k.append(h('small', '', num(r.n)));
++ keys.append(k);
++ });
++ box.append(bar, keys);
++ wrap.append(box);
++ return wrap;
++ }
function paintHome() {
el.textContent = '';
el.classList.toggle('is-edit', state.edit);
++ if (signedIn(o) && !state.edit) el.append(me());
if (signedIn(o)) { actorBox = actorBox || h('div'); el.append(actorBox); }
-- // Recent: the last places opened, newest first.
-- const recent = (o.recent || []).map((id) => L.byId.get(id)).filter(Boolean).slice(0, 5);
-- if (recent.length && !state.edit) {
-- el.append(sec('Recent'));
-- const row = h('div', 'ovh-grid ovh-recent'); row.setAttribute('aria-label', 'Recent');
-- recent.forEach((a) => row.append(appTile(o, a, { small: true })));
-- el.append(row);
-- }
++ if (!state.edit) { const b = backRail(); if (b) el.append(b); const u = usage(); if (u) el.append(u); }
const head = sec(L.arranged || signedIn(o) ? 'Your home' : 'Popular on OpenVibe');
const edit = h('button', state.edit ? 'is-done' : '', state.edit ? 'Done' : 'Edit');
edit.type = 'button';
@@@ -363,16 +527,19 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
if (m.cancelable) m.preventDefault();
if (scroller) { const r = scroller.getBoundingClientRect(); if (m.clientY < r.top + 56) scroller.scrollTop -= 12; else if (m.clientY > r.bottom - 56) scroller.scrollTop += 12; }
at(m);
-- // Which app's place the finger is over, by layout (offsets ignore the slide in progress), and only well
-- // inside it (the middle 60%), so the grid never flickers between two places.
++ // Which place the finger is over, by the grid's slots (offsetLeft/offsetTop: where each place IS, not where
++ // a sliding app is drawn this frame; the old hit test read the slide in progress, so an app passing under
++ // the finger took the drop and it landed one place off), and only well inside it (the middle 70% by 80%),
++ // so the grid never flickers between two places.
const grid = el.querySelector('.ovh-grid:not(.ovh-recent)');
if (!grid) return;
-- const cell = [...grid.children].find((n) => { const r = n.getBoundingClientRect(); return m.clientX >= r.left && m.clientX < r.right && m.clientY >= r.top && m.clientY < r.bottom; });
-- if (!cell) return;
-- const to = L.home.indexOf(cell.getAttribute('data-id')), from = L.home.indexOf(id);
-- if (to < 0 || to === from) return;
-- const cr = cell.getBoundingClientRect();
-- if (Math.abs(m.clientX - (cr.left + cr.width / 2)) > cr.width * 0.35 || Math.abs(m.clientY - (cr.top + cr.height / 2)) > cr.height * 0.4) return;
++ const gr = grid.getBoundingClientRect();
++ const kids = [...grid.children];
++ const to = kids.findIndex((n) => { const x = gr.left + n.offsetLeft, y = gr.top + n.offsetTop; return m.clientX >= x && m.clientX < x + n.offsetWidth && m.clientY >= y && m.clientY < y + n.offsetHeight; });
++ const from = L.home.indexOf(id);
++ if (to < 0 || to >= L.home.length || to === from) return;
++ const sn = kids[to], cx = gr.left + sn.offsetLeft + sn.offsetWidth / 2, cy = gr.top + sn.offsetTop + sn.offsetHeight / 2;
++ if (Math.abs(m.clientX - cx) > sn.offsetWidth * 0.35 || Math.abs(m.clientY - cy) > sn.offsetHeight * 0.4) return;
const before = new Map([...grid.children].map((n) => [n.getAttribute('data-id'), n.getBoundingClientRect()]));
L.home.splice(from, 1); L.home.splice(to, 0, id);
grid.textContent = ''; L.home.forEach((x, i) => grid.append(homeTile(x, i)));
@@@ -526,10 +693,10 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
card.classList.add('is-invite');
const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 30));
const txt = h('span', 'ovh-actor-txt');
-- txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch: finds work, posts, runs your channel.'));
++ txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch: finds work, posts and runs your channel.'));
const acts = h('span', 'ovh-acts');
const go = h('a', 'ovh-go', 'Set it up'); go.href = `${base}/you`;
-- const no = h('button', 'ovh-x', 'Not now'); no.type = 'button';
++ const no = h('button', 'ovh-x'); no.append(svgIcon('x')); no.type = 'button'; no.setAttribute('aria-label', 'Not now'); no.title = 'Not now';
no.addEventListener('click', () => { try { localStorage.setItem(INVITE_KEY, 'off'); } catch { /* */ } box.textContent = ''; });
acts.append(go, no);
card.append(face, txt, acts);
ddiff --git a/package-lock.json b/package-lock.json
iindex 05bfabe..1fad4d1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.162.0",
++ "version": "4.163.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.162.0",
++ "version": "4.163.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 616cc44..84f853e 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.162.0",
++ "version": "4.163.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/player.css b/player.css
iindex a0bba1a..f3dd1d8 100644
---- a/player.css
++++ b/player.css
@@@ -229,3 +229,10 @@
.ov-player-time { padding: 0 4px; font-size: 12px; }
.ov-player-big { width: 60px; height: 60px; }
}
++
++/* One control bar across OpenVibe (11-09, with Live's: the owner asked for buttons "better sized and centred"): 40 px
++ square buttons (44 on a touch screen) with the icon centred at 22 px; readouts as glass chips. */
++.ov-player-btn { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; display: inline-grid; place-items: center; }
++.ov-player-btn svg { width: 22px; height: 22px; display: block; }
++@media (pointer: coarse) { .ov-player-btn { width: 44px; min-width: 44px; height: 44px; } }
++.ov-player-time { height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: rgba(255, 255, 255, .12); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); font-size: 12.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
ddiff --git a/search.js b/search.js
iindex fb9b9aa..5ab2fab 100644
---- a/search.js
++++ b/search.js
@@@ -171,6 +171,15 @@
} catch { return null; }
}
++ // Times that keep up while the menu is open (once a minute; nothing runs while it is closed).
++ let agoTimer = 0;
++ function tickAgo() {
++ clearInterval(agoTimer);
++ agoTimer = setInterval(() => {
++ if (!el || !el.classList.contains('is-open')) { clearInterval(agoTimer); agoTimer = 0; return; }
++ el.querySelectorAll('[data-at]').forEach((m) => { const t = ago(m.dataset.at); if (t) m.textContent = [m.dataset.pre, t].filter(Boolean).join(' · '); });
++ }, 60000);
++ }
function ago(iso) {
const t = Date.parse(iso || ''); if (!isFinite(t)) return '';
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
@@@ -645,7 +654,7 @@
const main = document.createElement('span'); main.className = 'ovs-main';
const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title; main.appendChild(t);
if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; t.appendChild(tg); }
-- if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
++ if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; if (it.at) { m.dataset.at = it.at; m.dataset.pre = it.metaPre || ''; } main.appendChild(m); }
if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }
if (it.image) {
const im = document.createElement('img');
@@@ -1161,7 +1170,8 @@
const title = String(x.title || '').replace(/\s*[·|–—-]\s*(?:OpenVibe\.\w+|open\.\w+)\s*$/i, '').replace(/^OpenVibe\.\w+:\s*/i, '').trim() || x.title;
const live = pulseData && Array.isArray(pulseData.live) && pulseData.live.some((c) => c && c.url && safeUrl(c.url) && hostOf(c.url) === hostOf(x.url) && String(c.url).replace(/\/$/, '').toLowerCase() === String(x.url).replace(/[?#].*$/, '').replace(/\/$/, '').toLowerCase());
const svc = x.service === 'media' ? 'media-hub' : x.service;
-- return { title: title.slice(0, 90), meta: [live ? 'Live now' : '', x.site, ago(x.at)].filter(Boolean).join(' · '), url: x.url, site: svc && /^[a-z0-9-]{1,30}$/.test(svc) ? svc : null, icon: 'back', kind: 'history', tag: live ? 'Live' : '' };
++ const pre = [live ? 'Live now' : '', x.site].filter(Boolean).join(' · ');
++ return { title: title.slice(0, 90), meta: [pre, ago(x.at)].filter(Boolean).join(' · '), metaPre: pre, at: x.at, url: x.url, site: svc && /^[a-z0-9-]{1,30}$/.test(svc) ? svc : null, icon: 'back', kind: 'history', tag: live ? 'Live' : '' };
}
/** You: their workspace, their places, the pages they were on, tools, their searches, and the display controls. */
@@@ -1170,10 +1180,8 @@
const groups = [];
if (homeLib()) {
groups.push({ as: 'home', items: [], section: 'home' });
-- const seenH = new Set();
-- const backH = mine ? mine.items.filter((x) => { if (seenH.has(x.url)) return false; seenH.add(x.url); return true; }).slice(0, 5).map(backRow) : [];
-- groups.push({ title: 'Jump back in', items: backH, section: 'back' });
-- if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
++ // Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).
++ if (!(ctx.token && ctx.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
return groups;
}
if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
@@@ -1222,8 +1230,12 @@
seen.add(id); recentIds.push(id);
if (recentIds.length >= 5) break;
}
-- 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,
++ // Jump back in (the last places, newest first) and what this person uses most (pages opened per site).
++ const seenB = new Set();
++ const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];
++ const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
++ return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
++ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
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(''); } } };
@@@ -1402,6 +1414,7 @@
drainCards();
syncFeeds();
el.classList.add('is-open');
++ tickAgo();
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
// history entry; Back pops it and only closes the menu.
try { if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; } } catch { pushed = false; }
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex 3f057fd..b7ab75d 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -177,8 +177,13 @@ const server = http.createServer((req, res) => {
await openTab(s, 'you');
assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');
-- assert.ok(await until(s, `document.querySelectorAll('.ovh-recent .ovh-app').length === 2`), 'Recent shows the places opened lately');
-- assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-recent .ovh-app')].map((a) => a.getAttribute('data-id'))`), ['live', 'pics']);
++ // You on top: the name, Display (the Look panel) and Settings beside it; Jump back in as cards; Most used as one bar.
++ assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-me .ovh-who-txt b').textContent`), 'Ada', 'your name on top');
++ assert.ok(await s.evaluate(`!!document.querySelector('.ovh-me [data-ov-look-open]') && !!document.querySelector('.ovh-me a[href="https://openvibe.network/my"]')`), 'Display and Settings beside it');
++ assert.ok(await s.evaluate(`!document.querySelector('.ovs-displayrow')`), 'Display is not at the foot any more');
++ assert.ok(await until(s, `document.querySelectorAll('.ovh-rail .ovh-card').length === 2`), 'Jump back in shows the places opened lately');
++ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-rail .ovh-card')].map((a) => a.getAttribute('href'))`), ['https://openvibe.live/ada', 'https://openvibe.pics/1']);
++ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-use .ovh-key')].map((k) => k.textContent)`), ['Live5', 'Pics2'], 'Most used: pages opened per site');
assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
await s.evaluate(`document.querySelector('.ovh-sec button').click(), true`);