openvibers/openvibe.shared ov-home.js

ov-home.js

main 196.8 KB · 2305 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — the menu's home screen and wall (plan 2026-11-09, ~/openvibe/OpenVibe_Menu_Home_Feed_Plan_2026-11-09.md)
3//
4// search.js loads this the first time the You or Feed tab paints (a sibling, by content hash) and hands it an
5// element and what it knows:
6// OpenVibeHome.home(el, o) You: your Actor, Recent, your Home grid (long-press or Edit, drag to arrange,
7// folders), All apps (the drawer)
8// OpenVibeHome.wallTop(el, o) Feed: what's new with you (the composer, when Community says you can post) and
9// today's numbers
10// OpenVibeHome.timeline(el, o) Feed: the network's wall, newest first, "Show more"
11//
12// The owner, 11-09: "make it more like a phone… hold down on an icon and move it around, just like on Android or
13// iOS… the most popular sites until you set it up… recent apps at the top… tools as a folder of apps… the Actor
14// built in… the feed more like a Facebook wall… who went live recently… how many people today".
15//
16// The arrangement is kept on this device (localStorage ovs-home) and, signed in, on Network
17// (GET/PUT /api/v1/me/home-screen, network.home-screen@1); Network's copy wins when it answers. Everything a person or
18// a site wrote is set as text; links are https or same-site only. No spinners: grey bars hold the place of what loads.
19// ═══════════════════════════════════════════════════════════════
20(function (root) {
21 'use strict';
22 if (typeof document === 'undefined' || root.OpenVibeHome) return;
23
24 const HOME_KEY = 'ovs-home';
25 const INVITE_KEY = 'ovs-actor-invite';
26 const LONG_MS = 450;
27 const ARM_MS = 200; // in Edit, a finger holds an app this long before it moves it (a quicker swipe scrolls)
28 const HOME_MAX = 40;
29 const ACTOR = 'https://openvibe.actor';
30 // The Feed, the wall and the composer go through Network's frame relay (/api/frame/feed, /wall, /status): every
31 // site's connect-src lets the navbar reach openvibe.network and refuses openvibe.community (11-09: the Feed fell back
32 // to its old sections on every site but five). o.frameBase overrides it (tests, staging).
33 const FRAME = 'https://openvibe.network/api/frame';
34
35 // Nothing in, nothing out: an empty value resolved against the page would be the page's own address (a card with no
36 // picture drew a broken image of the page itself, 11-09).
37 const safe = (u) => { if (u == null || String(u).trim() === '') return null; try { const x = new URL(String(u), location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
38 const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
39 const ago = (iso) => {
40 // A time without a zone is UTC (see search.js utcOf).
41 const v = String(iso || ''); const t = Date.parse(/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v); if (!t) return '';
42 const s = Math.max(1, Math.round((Date.now() - t) / 1000));
43 if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;
44 const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;
45 };
46 const num = (n) => { const v = Number(n) || 0; return v >= 10000 ? `${(v / 1000).toFixed(v >= 100000 ? 0 : 1)}k` : v.toLocaleString('en-US'); };
47
48 function styles() {
49 if (document.getElementById('ovh-styles')) return;
50 const st = document.createElement('style');
51 st.id = 'ovh-styles';
52 st.textContent = `
53.ovh { list-style: none; padding: 2px 4px 6px; }
54.ovh-sec { display: flex; align-items: center; gap: 8px; padding: 10px 6px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
55.ovh-sec > span { flex: 1; }
56.ovh-sec button, .ovh-link { min-height: 36px; padding: 0 10px; border: 0; border-radius: 999px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 13px; font-weight: 700; letter-spacing: 0; text-transform: none; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
57.ovh-sec button.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
58.ovh-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px; }
59@media (min-width: 400px) { .ovh-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
60@media (min-width: 861px) { .ovh-grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); } }
61.ovh-grid { position: relative; }
62/* Home fills its width (owner, 11-09: "offset … use more of the space"): the tile grows with its cell up to 72 px,
63 its glyph is about half the tile, folders scale their four with it, and the grid lines up with the cards above. */
64.ovh-grid:not(.ovh-recent) { gap: 4px 2px; padding: 2px 0 0; justify-items: stretch; }
65.ovh-grid:not(.ovh-recent) .ovh-app { min-height: 0; padding: 6px 0 8px; gap: 7px; }
66.ovh-grid:not(.ovh-recent) .ovh-ic, .ovh-own-apps .ovh-ic { width: min(100% - 12px, 72px); height: auto; aspect-ratio: 1; border-radius: 29%; }
67.ovh-ic > .ov-icon { --ovi-size: 74.4%; display: grid; place-items: center; }
68.ovh-ic > .ov-icon svg { width: 100%; height: 100%; }
69.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { grid-template-columns: repeat(2, 36%); grid-auto-rows: 36%; gap: 7%; place-content: center; }
70.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span { width: 100%; height: 100%; border-radius: 30%; }
71.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span .ov-icon { display: grid; place-items: center; width: 100%; height: 100%; }
72.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span .ov-icon svg { width: 150%; height: 150%; }
73.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 12.5px; color: var(--text-primary, #e8ecf3); opacity: .86; }
74.ovh-sec .ovh-edit { display: inline-grid; place-items: center; width: 36px; min-height: 36px; padding: 0; border-radius: 50%;
75 background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-secondary, #a8b3c4); }
76.ovh-sec .ovh-edit svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
77.ovh-sec .ovh-edit:hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); }
78.ovh-sec .ovh-edit.is-done { display: inline-flex; gap: 6px; width: auto; padding: 0 14px 0 11px; border-radius: 999px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
79.ovh-sec .ovh-edit:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
80@media (pointer: coarse) { .ovh-sec .ovh-edit { width: 44px; min-height: 44px; } .ovh-sec .ovh-edit.is-done { width: auto; } }
81.ovh.is-edit .ovh-grid:not(.ovh-recent) { touch-action: pan-y; -webkit-user-select: none; user-select: none; }
82.ovh-recent .ovh-app { min-height: 80px; }
83.ovh-app { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; min-height: 88px; padding: 8px 2px 6px; border: 0; border-radius: 16px;
84 background: transparent; color: inherit; font: inherit; text-decoration: none; cursor: pointer; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }
85.ovh-app:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
86.ovh-ic { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent);
87 box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1); }
88.ovh-recent .ovh-ic { width: 48px; height: 48px; border-radius: 15px; }
89/* App icons: the ring goes, the glyph fills the tile, each app keeps its own hue (11-09). */
90.ovh-grid .ovh-ic, .ovh-own-apps .ovh-ic, .ovh-recent .ovh-ic, .ovh-card .ovh-ic, .ovh-key .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; }
91.ovh-ic .ovi-ring, .ovh-ic .ovi-bg, .ovh-ic .ovi-comet, .ovh-ic .ovi-prog { display: none; }
92.ovh-ic .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 90% 82%); color: hsl(var(--ovh-h, 215) 90% 82%); }
93: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%); }
94.ovh-folder .ovh-ic { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 7%, transparent); grid-template-columns: repeat(2, 22px); grid-auto-rows: 22px; gap: 3px; }
95.ovh-folder .ovh-ic > span { width: 22px; height: 22px; border-radius: 7px; overflow: hidden; background: hsl(var(--ovh-h, 215) 70% 50% / .3); }
96.ovh-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
97.ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, 18px); grid-auto-rows: 18px; gap: 4px; place-content: center; }
98.ovh-folder .ovh-ic > span { display: grid; place-items: center; width: 18px; height: 18px; }
99.ovh-glyph { display: grid; place-items: center; width: 100%; height: 100%; }
100.ovh-glyph svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; color: var(--accent, #3b82f6); }
101.ovh-badge { position: absolute; top: -6px; left: -6px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 0; padding: 0; cursor: pointer;
102 background: var(--bg-tertiary, #2a3140); color: var(--text-primary, #e8ecf3); font: 700 16px/1 system-ui, sans-serif; box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
103.ovh-badge::after { content: ""; position: absolute; inset: -10px; }
104.ovh-badge.is-add { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
105.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app { touch-action: pan-y; }
106.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { animation: ovh-wiggle .32s ease-in-out infinite alternate; }
107.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app:nth-child(2n) .ovh-ic { animation-delay: -.16s; }
108.ovh-app.is-lifted { opacity: .18; transform: scale(.92); }
109.ovh-app.is-armed .ovh-ic { transform: scale(1.1); box-shadow: 0 12px 26px -10px rgba(0, 0, 0, .7); transition: transform .18s cubic-bezier(.34, 1.56, .64, 1); }
110.ovh-ghost .ovh-ic { width: 64px; height: 64px; border-radius: 19px; }
111html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { animation-name: ovh-wiggle-calm !important; }
112@keyframes ovh-wiggle-calm { from { transform: rotate(-.6deg); } to { transform: rotate(.6deg); } }
113.ovh-ghost { position: fixed; z-index: 10040; pointer-events: none; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 19px; display: grid; place-items: center;
114 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); }
115@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }
116.ovh-hint { margin: 10px 6px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
117.ovh-calm { transition: opacity .2s; }
118.ovh.is-edit .ovh-calm { opacity: .38; pointer-events: none; }
119.ovh-headtools { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
120.ovh-sec .ovh-allbtn { all: unset; box-sizing: border-box; text-transform: none; letter-spacing: 0; display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 11px 0 10px; border-radius: 999px; cursor: pointer; font-size: 13px; font-weight: 700;
121 color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 7%, transparent); transition: color .15s, background-color .15s, transform .12s; }
122.ovh-sec .ovh-allbtn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
123.ovh-sec .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }
124.ovh-sec .ovh-allbtn:hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); }
125.ovh-sec .ovh-allbtn:active { transform: scale(.96); }
126.ovh-sec .ovh-allbtn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
127@media (pointer: coarse) { .ovh-sec .ovh-allbtn { min-height: 44px; padding: 0 13px 0 12px; } }
128.ovh-all[hidden], .ovh[hidden] { display: none !important; }
129.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));
130 border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
131.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }
132.ovh-head b { flex: 1; font-size: 16px; }
133/* 4.229.0: a place's head inside You (All apps, a folder): its name, its count and a quiet hint, coming in with the view. */
134.ovh-head-t { flex: 1; min-width: 0; display: grid; gap: 1px; animation: ovh-head-in .28s cubic-bezier(.2, .8, .2, 1) both; }
135.ovh-head-t b { display: inline-flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 800; letter-spacing: -.01em; color: var(--text-primary, #e6edf7); }
136.ovh-head-n { min-width: 24px; padding: 0 8px; box-sizing: border-box; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #93c5fd); font-size: 12.5px; font-weight: 800; line-height: 22px; text-align: center; font-variant-numeric: tabular-nums; }
137.ovh-head-t small { font-size: 12.5px; color: var(--text-muted, #8a96a8); }
138@keyframes ovh-head-in { from { opacity: 0; transform: translateX(-6px); } }
139html[data-ov-motion=off] .ovh-head-t { animation: none; }
140.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
141.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
142 background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; }
143.ovh-actor { box-sizing: border-box; display: flex; align-items: center; gap: 12px; width: calc(100% - 8px); min-height: 64px; margin: 6px 4px 4px; padding: 10px 12px; border-radius: 16px; border: 0; text-align: left; cursor: pointer;
144 background: color-mix(in srgb, var(--accent, #3b82f6) 11%, var(--bg-secondary, #141a24)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); color: inherit; font: inherit; }
145.ovh-face { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; overflow: hidden; background: var(--accent, #3b82f6); color: #fff; font-weight: 800; font-size: 17px; }
146.ovh-face img { width: 100%; height: 100%; object-fit: cover; }
147.ovh-actor-txt { flex: 1; min-width: 0; display: grid; gap: 2px; }
148.ovh-actor-txt b { font-size: 14.5px; }
149.ovh-actor-txt small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150.ovh-chip { display: inline-flex; align-items: center; gap: 5px; margin-left: 6px; font-size: 11.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); }
151.ovh-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted, #8b8b96); }
152.ovh-chip.is-working .ovh-dot { background: var(--success, #22c55e); animation: ovh-pulse 1.8s ease-in-out infinite; }
153.ovh-chip.is-waiting .ovh-dot { background: var(--warning, #f5a524); }
154.ovh-go { flex: none; color: var(--accent, #3b82f6); font-weight: 700; font-size: 13px; }
155.ovh-x { flex: none; min-width: 44px; min-height: 44px; border: 0; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 12.5px; cursor: pointer; }
156@keyframes ovh-pulse { 50% { opacity: .35; } }
157.ovh-actor.is-invite { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 10px 12px; padding: 12px 14px; cursor: default; }
158.ovh-actor.is-invite .ovh-face { width: 48px; height: 48px; border-radius: 15px; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
159.ovh-actor.is-invite .ovh-actor-txt small { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.4; }
160.ovh-actor.is-invite .ovh-acts { grid-column: 2; display: flex; gap: 8px; }
161.ovh-actor.is-invite .ovh-go { display: inline-flex; align-items: center; height: 36px; padding: 0 16px; border-radius: 999px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); text-decoration: none; font-size: 13.5px; }
162.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
163@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
164.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
165.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
166.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }
167.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
168.ovh-stat b { font-size: 18px; font-variant-numeric: tabular-nums; }
169.ovh-stat small { font-size: 11.5px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
170.ovh-stat svg { width: 100%; height: 18px; color: var(--accent, #3b82f6); }
171@media (max-width: 380px) { .ovh-today { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
172.ovh-wall { list-style: none; margin: 0; padding: 0; }
173.ovh-entry { display: flex; gap: 12px; align-items: flex-start; min-height: 56px; padding: 10px 8px; border-radius: 14px; color: inherit; text-decoration: none; }
174.ovh-entry + .ovh-entry { margin-top: 2px; }
175@media (hover: hover) { .ovh-entry:hover, .ovh-app:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } }
176.ovh-entry .ovh-ic { width: 40px; height: 40px; border-radius: 12px; flex: none; }
177.ovh-entry-txt { flex: 1; min-width: 0; display: grid; gap: 3px; }
178.ovh-entry-txt b { font-size: 14px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; }
179.ovh-entry-txt small { font-size: 12px; color: var(--text-muted, #93a0b4); }
180.ovh-lives { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
181.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; }
182.ovh-lives a::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--live, #ef4444); }
183
184/* You (11-09, the owner: "the U tab still sucks"; 11-10: "Your home should be above Jump back in… the thing at the top
185 a little more compact… links to whatever the user uses the most"): you on top in one short bar, with Display and
186 Settings beside your name; then your home; then what you use most, as shortcuts under one bar; then where you were
187 last, as a rail of cards. */
188.ovh-me { display: flex; align-items: center; gap: 6px; margin: 2px 4px 6px; padding: 4px 4px 4px 8px; border-radius: 16px;
189 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);
190 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 18%, var(--border, rgba(255,255,255,.08))); }
191.ovh-who { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 44px; border-radius: 12px; color: inherit; text-decoration: none; }
192.ovh-av { position: relative; flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 50%, #a855f7)); color: #fff; font-size: 15px; font-weight: 800; }
193.ovh-av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
194.ovh-av::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 10px; height: 10px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
195.ovh-who-txt { min-width: 0; display: grid; gap: 0; line-height: 1.25; }
196.ovh-who-txt b { overflow-wrap: anywhere; font-size: 15px; font-weight: 800; letter-spacing: -.01em; }
197.ovh-who-txt small { overflow-wrap: anywhere; font-size: 12px; color: var(--text-secondary, #a8b3c4); }
198.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;
199 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); }
200.ovh-ib svg { display: block; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
201.ovh-ib[aria-expanded="true"] { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
202.ovh-ib:active { transform: scale(.9); }
203.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; }
204@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)); } }
205.ovh-me-look { margin: -2px 4px 8px; }
206.ovh-me .ovh-ib { width: 40px; }
207.ovh-out:hover { color: var(--danger, #f87171); }
208.ovh-own { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 4px 8px; }
209.ovh .ovh-own > .ovh-own-i { font-size: 13px; font-weight: 600; line-height: 1; }
210.ovh-own-i { display: inline-flex; align-items: center; min-height: 36px; padding: 0 13px; border: 0; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); color: var(--text-secondary, #a8b3c4); font: 600 13px/1 inherit; text-decoration: none; cursor: pointer; transition: background-color .15s, color .15s; }
211.ovh-own-i:hover, .ovh-own-i:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--text-primary, #e8ecf3); }
212@media (pointer: coarse) { .ovh-own-i { min-height: 44px; } }
213.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;
214 -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
215.ovh-rail::-webkit-scrollbar { display: none; }
216.ovh-rail.is-end { -webkit-mask-image: none; mask-image: none; }
217.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;
218 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;
219 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));
220 animation: ovh-rise .38s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 40ms); }
221.ovh-card .ovh-ic { grid-row: 1 / 3; width: 40px; height: 40px; border-radius: 12px; }
222.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; }
223.ovh-card small { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 11.5px; color: var(--text-muted, #93a0b4); }
224.ovh-card small > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
225.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); }
226.ovh-card.is-live .ovh-ic { animation: ovh-onair 2.4s ease-in-out infinite; }
227.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; }
228@media (hover: hover) { .ovh-card:hover { background: var(--bg-hover, rgba(255,255,255,.08)); } }
229@media (min-width: 861px) { .ovh-card { width: 232px; } }
230.ovh-use { margin: 0 4px 8px; padding: 12px 12px 4px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
231.ovh-use-bar { display: flex; gap: 3px; height: 12px; border-radius: 999px; overflow: hidden; }
232.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); }
233/* The bar's key is the apps themselves: each one its icon (a way in, not a legend), its name, and how often, with the
234 bar's own colour beside the number. Five across, as the home grid is. */
235.ovh-use-apps { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin: 8px -8px 0; }
236.ovh-key { display: grid; justify-items: center; align-content: start; gap: 3px; min-width: 0; padding: 6px 0; border-radius: 14px; color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; }
237.ovh-key .ovh-ic { width: 44px; height: 44px; border-radius: 14px; transition: transform .16s cubic-bezier(.2,1.4,.4,1); }
238.ovh-key b { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; font-weight: 650; letter-spacing: -.01em; color: var(--text-secondary, #a8b3c4); }
239.ovh-key small { display: inline-flex; align-items: center; gap: 4px; color: var(--text-muted, #93a0b4); font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
240.ovh-key small::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--c, #3b82f6); }
241.ovh-key:active .ovh-ic { transform: scale(calc(1 - .08 * var(--ov-move, 1))); }
242@media (hover: hover) { .ovh-key:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } .ovh-key:hover b { color: var(--text-primary, #e8ecf3); } }
243.ovh-sec small { flex: none; font-size: 11px; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted, #93a0b4); }
244@keyframes ovh-rise { from { opacity: 0; transform: translateY(6px) scale(.98); } }
245@keyframes ovh-grow { from { transform: scaleX(0); } }
246@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); } }
247/* Calm is gentle, not still (the owner, 11-09): shorter travel, slower breath. */
248html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-duration: .5s; }
249@keyframes ovh-fade { from { opacity: 0; } }
250/* The invitation, one compact row (11-09): mark, words, Set it up, and an × to put it away. */
251.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; }
252.ovh-actor.is-invite .ovh-face { grid-row: 1 / 3; align-self: start; width: 44px; height: 44px; border-radius: 14px; }
253.ovh-actor.is-invite .ovh-actor-txt { display: contents; }
254.ovh-actor.is-invite .ovh-actor-txt b { grid-column: 2; grid-row: 1; font-size: 15px; }
255.ovh-actor.is-invite .ovh-actor-txt small { grid-column: 2 / 5; grid-row: 2; -webkit-line-clamp: 3; }
256.ovh-actor.is-invite .ovh-acts { display: contents; }
257.ovh-actor.is-invite .ovh-go { grid-column: 3; grid-row: 1; }
258.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%; }
259.ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
260
261/* 4.223.0: You's three tabs, the one notice above them, and an empty tab said with its mark (openvibe.design rule 141). */
262.ovh-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin: 10px 0 12px; padding: 4px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
263.ovh-tab { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 0 8px; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
264.ovh-tab svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
265.ovh-tab:hover { color: var(--text-primary, #e6edf7); }
266.ovh-tab:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #60a5fa)); outline-offset: -2px; }
267.ovh-tab[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
268.ovh-tab[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }
269.ovh-tab-n { min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box; border-radius: 999px; font: 750 11px/18px Inter, system-ui, sans-serif; letter-spacing: -.01em; text-align: center; font-variant-numeric: tabular-nums; color: #fff; box-shadow: 0 0 0 2px var(--bg-secondary, #141a24), 0 3px 8px -2px rgba(0, 0, 0, .55); animation: ovhBadgeIn .3s cubic-bezier(.34, 1.56, .64, 1); background: var(--danger, #ef4444); }
270@keyframes ovhBadgeIn { from { transform: scale(.5); opacity: 0; } }
271html[data-ov-motion=off] .ovh-tab-n { animation: none; }
272@media (max-width: 380px) { .ovh-tab { gap: 5px; font-size: 13px; } .ovh-tab svg { width: 16px; height: 16px; } }
273.ovh-notes { min-height: 120px; }
274.ovh-empty { display: flex; align-items: center; gap: 14px; margin: 6px 0; padding: 14px 16px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--text-muted, #93a0b4) 45%, transparent); }
275.ovh-empty > svg { width: 22px; height: 22px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
276.ovh-empty > div { display: grid; gap: 2px; } .ovh-empty b { font-size: 14.5px; } .ovh-empty span { font-size: 13px; color: var(--text-muted, #93a0b4); }
277/* Your own AI is one notice under your row: a slim line, not a card (the owner: "make that a notification … up there"). */
278.ovh-notice .ovh-actor.is-invite { margin: 8px 0 0; padding: 8px 8px 8px 10px; gap: 10px; min-height: 0; border-radius: 14px; }
279.ovh-notice .ovh-actor.is-invite .ovh-face { width: 32px; height: 32px; border-radius: 10px; }
280.ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
281.ovh-notice .ovh-actor.is-invite .ovh-acts { flex-direction: row; align-items: center; }
282@media (max-width: 480px) { .ovh-notice .ovh-actor.is-invite { flex-wrap: nowrap; } .ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { -webkit-line-clamp: 2; } }
283
284/* ── 4.223.0: You, redone (the owner, 11-12) ──────────────────────────────────────────────────────────────────────────
285 One top (you, and your AI beside you when there is room), one tab row (Apps · Notifications · Recent, with All and
286 Edit at its end), then the tab. App icons draw the glyph at 58% of the tile, placed absolutely so a hover can never
287 pull it off centre; a folder's four sit in an even 2×2 with their glyphs at two thirds. Names wrap to two balanced
288 lines and never end in "…". */
289.ovh { container: ovh / inline-size; }
290.ovh-top { display: grid; gap: 6px; margin: 2px 4px 0; }
291.ovh-top > * { min-width: 0; }
292.ovh-top .ovh-me, .ovh-top .ovh-notice .ovh-actor { margin: 0; width: 100%; }
293@container ovh (min-width: 600px) {
294 .ovh-top:has(.ovh-notice > *) { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: stretch; }
295 .ovh-top .ovh-notice > .ovh-actor { height: 100%; }
296}
297.ovh-notice:empty { display: none; }
298.ovh-notice .ovh-actor { box-sizing: border-box; min-height: 56px; padding: 8px 12px; border-radius: 16px; }
299.ovh-notice .ovh-actor .ovh-face { width: 36px; height: 36px; font-size: 15px; }
300/* Your row: you, then your round buttons (this site's own first). On a narrow screen the buttons go under your name. */
301.ovh-me { flex-wrap: wrap; row-gap: 0; padding: 6px 6px 6px 8px; min-height: 56px; box-sizing: border-box; }
302.ovh-acts { display: flex; align-items: center; gap: 4px; flex: none; }
303/* 4.237.0, you: one slim row (your picture with its online dot, your name, Display, your account's menu); the menu sits
304 in the top layer; what the site gives you are apps at the start of the grid. */
305.ovh-mebox { margin: 2px 4px 8px; }
306.ovh-mebox > .ovh-me { display: flex; align-items: center; gap: 6px; margin: 0; padding: 6px 6px 6px 8px; min-height: 0; border-radius: 18px; flex-wrap: nowrap;
307 background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
308.ovh-mebox .ovh-who { flex: 1 1 auto; min-width: 0; min-height: 44px; gap: 11px; padding: 2px 6px 2px 2px; border-radius: 14px; transition: background-color .15s; }
309@media (hover: hover) and (pointer: fine) { .ovh-mebox .ovh-who:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); } }
310.ovh-mebox .ovh-av { width: 40px; height: 40px; }
311.ovh-mebox .ovh-who-txt b { font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
312.ovh-mebox .ovh-who-txt small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
313.ovh-mebox .ovh-me .ovh-ib { flex: none; width: 42px; height: 42px; border-radius: 13px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
314.ovh-mebox .ovh-acct svg { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
315.ovh-mebox .ovh-acct[aria-expanded="true"] { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
316.ovh-mebox .ovh-acct[aria-expanded="true"] svg { transform: rotate(180deg); }
317.ovh-acct-menu { min-width: 230px; padding: 6px; border-radius: 16px; background: var(--bg-secondary, #141a24); color: var(--text-primary, #e6edf7);
318 box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent), 0 18px 40px -12px rgba(0, 0, 0, .7); }
319.ovh-acct-i { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 12px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 11px; cursor: pointer; font-size: 14.5px; font-weight: 650; }
320.ovh-acct-i svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; color: var(--text-secondary, #a8b3c4); }
321.ovh-acct-i:hover, .ovh-acct-i:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
322.ovh-acct-i.is-danger, .ovh-acct-i.is-danger svg { color: var(--danger, #f87171); }
323.ovh-own-apps { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px; margin-bottom: 4px; padding-bottom: 10px; border-bottom: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
324.ovh-own-ic { color: hsl(var(--ovh-h, 220) 80% 72%); }
325.ovh-own-ic svg { width: 46%; height: 46%; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
326html[data-ov-motion=off] .ovh-mebox .ovh-acct svg { transition: none; }
327.ovh-ib.is-own { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
328.ovh-ib.is-text { width: auto; padding: 0 12px; border-radius: 999px; font-size: 12.5px; font-weight: 750; white-space: nowrap; }
329@container ovh (max-width: 520px) {
330 .ovh-me { padding: 8px 8px 6px 10px; }
331 .ovh-acts { flex: 1 1 100%; flex-wrap: wrap; gap: 6px; padding: 6px 0 0 52px; }
332 .ovh-acts .ovh-out { margin-left: auto; }
333}
334/* The tab row: on a phone only the chosen tab says its name (the others are their glyph and count), so the row also
335 holds All and Edit; from 520 px every tab says its name. It stays at the top while the apps scroll. */
336.ovh-tabrow { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; gap: 8px; margin: 8px 0 6px; padding: 4px;
337 background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); border-radius: 16px; }
338/* 4.236.0 (the owner, 11-13: "more apparent that these are tabs … apps, then notifications, then history … the edit
339 button twice … use the icon only"): the three tabs are one segmented track, each with its glyph and name, the chosen
340 one lit; All and Edit are two round glyphs at the end (Edit turns into the check while you arrange). */
341.ovh-tabrow .ovh-tabs { flex: 1 1 auto; min-width: 0; display: flex; gap: 2px; margin: 0; padding: 3px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
342.ovh-tabrow .ovh-tab { flex: 1 1 0; min-width: 0; min-height: 40px; padding: 0 6px; gap: 6px; border-radius: 11px; justify-content: center; transition: background-color .18s, color .18s; }
343.ovh-tabrow .ovh-tab .ovh-tab-t { font-size: 13.5px; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
344.ovh-tabrow .ovh-tab svg { width: 19px; height: 19px; }
345.ovh-tabrow .ovh-headtools { gap: 8px; }
346.ovh-headtools .ovh-edit.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); animation: ovhDoneIn .3s cubic-bezier(.34, 1.56, .64, 1); }
347@keyframes ovhDoneIn { from { transform: scale(.8) rotate(-20deg); } }
348html[data-ov-motion=off] .ovh-headtools .ovh-edit.is-done { animation: none; }
349.ovh-tabrow .ovh-headtools { flex: none; display: flex; align-items: center; gap: 4px; }
350@container ovh (max-width: 519px) {
351 .ovh-tabrow .ovh-tab { padding: 0 4px; gap: 5px; }
352}
353@container ovh (max-width: 340px) { .ovh-tabrow .ovh-tab:not([aria-selected="true"]) .ovh-tab-t { display: none; } }
354.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { min-height: 42px; }
355.ovh-tabrow .ovh-edit { width: 42px; }
356.ovh-headtools .ovh-edit.is-done { width: auto; }
357/* Section heads: a marked glyph, the name, its count; buttons at the end. */
358.ovh-sec { gap: 8px; padding: 12px 6px 6px; }
359.ovh-sec > span { flex: 0 1 auto; min-width: 0; }
360.ovh-sec-sp { flex: 1; }
361.ovh-sec-ic { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
362.ovh-sec-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
363.ovh-sec-n { flex: none; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 9%, transparent); color: var(--text-secondary, #a8b3c4); font-size: 11px; font-weight: 750; line-height: 20px; letter-spacing: 0; text-align: center; }
364/* Back is a round arrow; in All apps the search sits beside it. */
365.ovh-head { gap: 8px; padding: 6px 4px; }
366.ovh-back { flex: none; display: inline-grid; place-items: center; width: 44px; height: 44px; min-height: 44px; padding: 0; border-radius: 50%; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); transition: background-color .15s, transform .12s; }
367.ovh-back svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
368.ovh-back:active { transform: scale(.92); }
369.ovh-back:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
370@media (hover: hover) { .ovh-back:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 15%, transparent); } }
371.ovh-head .ovh-filter { flex: 1; min-width: 0; width: auto; margin: 0; }
372/* The grid: four across on a phone, as many as fit from 560 px. */
373.ovh-grid, .ovh-own-apps { grid-template-columns: repeat(4, minmax(0, 1fr)); }
374@media (min-width: 560px) { .ovh-grid, .ovh-own-apps { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); } }
375/* App icons: the glyph at 58% of the tile. */
376.ovh-ic > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
377.ovh-ic > .ov-icon svg { position: absolute; left: -13%; top: -13%; width: 126%; height: 126%; margin: 0; transform: none !important; }
378.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: auto; gap: 9%; padding: 14%; box-sizing: border-box; place-content: center stretch; align-content: center; }
379.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span { position: relative; display: block; width: 100%; height: auto; aspect-ratio: 1; align-self: center; border-radius: 30%; overflow: hidden;
380 background: linear-gradient(150deg, hsl(var(--ovh-h, 215) 72% 58% / .55), hsl(var(--ovh-h, 215) 70% 38% / .35)); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 70% 66% / .3); }
381.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --ovi-glyph: hsl(var(--ovh-h, 215) 92% 86%); color: hsl(var(--ovh-h, 215) 92% 86%); }
382.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic > span > .ov-icon svg { position: absolute; left: -23%; top: -23%; width: 146%; height: 146%; margin: 0; transform: none !important; }
383:root[data-theme="light"] .ovh-grid .ovh-folder .ovh-ic > span > .ov-icon { --ovi-glyph: hsl(var(--ovh-h, 215) 70% 28%); color: hsl(var(--ovh-h, 215) 70% 28%); }
384/* Names: two balanced lines, centred, never "…". */
385.ovh-name { white-space: normal; text-overflow: clip; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: center; line-height: 1.2; text-wrap: balance; overflow-wrap: normal; word-break: normal; }
386.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 12px; letter-spacing: -.005em; }
387/* A + to put an app on Home sits on its icon's corner, not in the cell's. */
388.ovh-grid .ovh-app > .ovh-badge { top: 0; left: calc(50% - min(50% - 6px, 36px) - 8px); width: 24px; height: 24px; font-size: 15px; }
389/* Touch, pen and mouse: a press sinks the icon at once; a mouse lifts it and lights it in its own hue. */
390.ovh-ic { transition: transform .22s cubic-bezier(.2, 1.4, .4, 1), box-shadow .22s; }
391.ovh-app:active .ovh-ic { transform: scale(.93); transition-duration: .08s; }
392@media (hover: hover) and (pointer: fine) {
393 .ovh-app:hover .ovh-ic { transform: translateY(-3px) scale(1.04); box-shadow: inset 0 0 0 1px hsl(var(--ovh-h, 215) 75% 70% / .5), 0 12px 26px -8px hsl(var(--ovh-h, 215) 75% 45% / .6); }
394 .ovh-app:hover .ovh-name { opacity: 1; }
395}
396html[data-ov-motion=off] .ovh-ic { transition: none; }
397/* Recent: where you were, in days, as cards with the whole title; two columns when there is room. */
398.ovh-hist-list { display: grid; gap: 8px; padding: 0 4px; }
399@container ovh (min-width: 600px) { .ovh-hist-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
400.ovh-hist-day { grid-column: 1 / -1; margin: 8px 4px 0; font-size: 12px; font-weight: 800; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }
401.ovh-hist-day:first-child { margin-top: 0; }
402.ovh-hrow { box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-width: 0; min-height: 70px; padding: 10px 12px; border-radius: 16px; color: inherit; text-decoration: none;
403 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));
404 transition: background-color .15s, transform .15s; animation: ovh-rise .34s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 22ms); }
405.ovh-hrow .ovh-ic { flex: none; width: 48px; height: 48px; border-radius: 15px; }
406.ovh-htxt { min-width: 0; display: grid; gap: 3px; }
407.ovh-htxt b { font-size: 14.5px; font-weight: 700; line-height: 1.28; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
408.ovh-htxt small { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
409.ovh-hrow.is-live { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--live-red, #ef4444) 45%, transparent); }
410.ovh-hrow:active { transform: scale(.985); }
411.ovh-hrow:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
412@media (hover: hover) and (pointer: fine) { .ovh-hrow:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); transform: translateY(-1px); } }
413.ovh-hist-more[hidden] { display: none; }
414@keyframes ovh-rise { from { opacity: 0; transform: translateY(8px); } }
415@keyframes ovh-rise-calm { from { opacity: 0; } }
416html[data-ov-motion=reduced] .ovh-hrow { animation-name: ovh-rise-calm; animation-delay: 0s; }
417html[data-ov-motion=off] .ovh-hrow { animation: none; }
418
419/* 4.223.0, round two: All and Edit in the tab row; the tabs as one segmented control whose ink slides to the chosen tab,
420 and the tab's content slides in from its side (the owner, 11-12: "animations, effects to the tabs … aligned more
421 perfectly … bigger font size and better icon size"). */
422.ovh-tabrow .ovh-tabs { position: relative; isolation: isolate; }
423.ovh-tabrow .ovh-tab[aria-selected="true"] { background: transparent; box-shadow: none; }
424.ovh-tabrow .ovh-tab { font-size: 14.5px; gap: 8px; }
425.ovh-tabrow .ovh-tab svg { width: 19px; height: 19px; transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
426.ovh-tabrow .ovh-tab[aria-selected="true"] svg { transform: scale(1.08); }
427@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-tab:not([aria-selected="true"]):hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }
428.ovh-tabrow .ovh-tab:active svg { transform: scale(.9); }
429html[data-ov-motion=off] .ovh-tabrow .ovh-tab svg { transition: none; }
430.ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: ovh-pane-r .3s cubic-bezier(.2, .8, .2, 1) both; }
431/* 4.238.0, labels and glyphs (the owner, 11-13: "the icons are so small, make them use more of the space, less padding …
432 the text apps … a little bigger … better consistency"): every app's name (yours too) one size and weight; the glyphs
433 fill more of their tiles and of the round buttons. */
434.ovh-grid:not(.ovh-recent) .ovh-name, .ovh-own-apps .ovh-name { font-size: 13.5px; font-weight: 800; letter-spacing: .005em; color: color-mix(in srgb, var(--text-primary, #e8ecf3) 94%, transparent); opacity: 1; }
435.ovh-own-apps .ovh-name { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; }
436.ovh-own-ic svg { width: 56%; height: 56%; stroke-width: 2; }
437.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { width: 48px; min-width: 48px; height: 48px; }
438.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 26px; height: 26px; }
439.ovh-tabrow .ovh-tab .ovh-tab-t { text-overflow: clip; }
440.ovh-mebox .ovh-me .ovh-ib svg { width: 26px; height: 26px; }
441/* 4.238.0, round two (the owner's phone, 11-13: "the tabs are still off center … the icons aren't big enough, not even
442 nearly … and neither are the button icons"): the ink sits on its tab by the track's geometry; glyphs are 26 px on the
443 tabs and 22-24 px on every round button. */
444.ovh-tabrow .ovh-tab { min-height: 48px; gap: 8px; }
445.ovh-tabrow .ovh-tab svg { width: 26px; height: 26px; }
446/* Round three (the owner, 11-13: "hide the label of the inactive tabs … add the badges back"): the chosen tab is lit and
447 widens to show its name; the others are their glyphs; the count sits on the bell's corner. */
448.ovh-tabrow .ovh-tabs { gap: 4px; }
449.ovh-tabrow .ovh-tab { flex: 1 1 0; min-width: 52px; min-height: 48px; padding: 0 10px; gap: 0; transition: flex-grow .36s cubic-bezier(.3, 1.2, .4, 1), background-color .25s, box-shadow .25s, color .2s; }
450.ovh-tabrow .ovh-tab[aria-selected="true"]:not(.is-new), .ovh-tabrow .ovh-tab.is-was { flex-grow: 2.4; color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826);
451 box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent); }
452:root[data-theme="light"] .ovh-tabrow .ovh-tab[aria-selected="true"]:not(.is-new), :root[data-theme="light"] .ovh-tabrow .ovh-tab.is-was { background: #fff; }
453.ovh-tabrow .ovh-tab .ovh-tab-t { display: block; max-width: 0; margin-left: 0; opacity: 0; font-size: 14.5px; font-weight: 750; transition: max-width .36s cubic-bezier(.3, 1.1, .4, 1), margin-left .36s, opacity .2s; }
454.ovh-tabrow .ovh-tab[aria-selected="true"]:not(.is-new) .ovh-tab-t, .ovh-tabrow .ovh-tab.is-was .ovh-tab-t { max-width: 8em; margin-left: 8px; opacity: 1; }
455.ovh-tab-g { position: relative; flex: none; display: grid; place-items: center; }
456.ovh-tab-g .ovh-tab-n { position: absolute; top: -8px; left: calc(100% - 10px); margin: 0; }
457html[data-ov-motion=reduced] .ovh-tabrow .ovh-tab, html[data-ov-motion=reduced] .ovh-tabrow .ovh-tab .ovh-tab-t { transition-duration: .15s; }
458html[data-ov-motion=off] .ovh-tabrow .ovh-tab, html[data-ov-motion=off] .ovh-tabrow .ovh-tab .ovh-tab-t { transition: none; }
459.ovh-mebox .ovh-me .ovh-ib { width: 44px; height: 44px; }
460.ovh-mebox .ovh-me .ovh-ib svg { width: 24px; height: 24px; }
461.ovh-actor .ovh-go.is-ic svg, .ovh-actor .ovh-go.is-answer svg { width: 24px; height: 24px; }
462.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 22px; height: 22px; }
463.ovh-sec .ovh-edit svg { width: 22px; height: 22px; }
464.ovh-sec .ovh-allbtn svg { width: 20px; height: 20px; }
465.ovh-back svg { width: 24px; height: 24px; }
466.ovh-actor.is-invite .ovh-x svg, .ovh-notice .ovh-actor.is-invite .ovh-x svg { width: 20px; height: 20px; }
467.ovh[data-enter="left"] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-pane-l; }
468@keyframes ovh-pane-r { from { opacity: 0; transform: translateX(18px); } }
469@keyframes ovh-pane-l { from { opacity: 0; transform: translateX(-18px); } }
470html[data-ov-motion=reduced] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-rise-calm; }
471html[data-ov-motion=off] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation: none; }
472.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 42px; border-radius: 12px; cursor: pointer;
473 font-size: 13.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); transition: background-color .15s, color .15s, transform .12s; -webkit-tap-highlight-color: transparent; }
474.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { position: relative; width: 44px; min-width: 44px; height: 44px; padding: 0; }
475.ovh-headtools .ovh-edit.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
476.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
477.ovh-headtools .ovh-allbtn b { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3); }
478.ovh-tabrow .ovh-allbtn b { position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; display: grid; place-items: center; font-size: 10.5px; line-height: 1;
479 background: var(--bg-tertiary, #1f2937); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
480.ovh-tabrow .ovh-allbtn:focus-visible, .ovh-tabrow .ovh-edit:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
481.ovh-tabrow .ovh-allbtn:active, .ovh-tabrow .ovh-edit:active { transform: scale(.94); }
482@media (hover: hover) and (pointer: fine) { .ovh-tabrow .ovh-allbtn:hover, .ovh-tabrow .ovh-edit:not(.is-done):hover { color: var(--text-primary, #e8ecf3); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 13%, transparent); } }
483@container ovh (max-width: 380px) { .ovh-tabrow .ovh-allbtn span { display: none; } }
484
485/* Your own AI, not yet set up: one slim row under you (its mark, two short lines, Set it up, ×). */
486.ovh-notice .ovh-actor.is-invite { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 8px 8px 10px; }
487.ovh-notice .ovh-actor.is-invite .ovh-face { flex: none; grid-row: auto; align-self: center; width: 40px; height: 40px; border-radius: 12px; }
488.ovh-notice .ovh-actor.is-invite .ovh-actor-txt { display: grid; flex: 1; min-width: 0; gap: 1px; }
489.ovh-notice .ovh-actor.is-invite .ovh-actor-txt small { -webkit-line-clamp: 2; font-size: 12.5px; line-height: 1.3; }
490.ovh-notice .ovh-actor.is-invite .ovh-acts { flex: none; display: flex; flex-direction: row; align-items: center; gap: 4px; grid-column: auto; padding: 0; }
491.ovh-notice .ovh-actor.is-invite .ovh-go { height: 38px; padding: 0 14px; }
492.ovh-notice .ovh-actor.is-invite .ovh-x { display: grid; place-items: center; width: 38px; min-height: 38px; height: 38px; padding: 0; }
493.ovh-notice .ovh-actor.is-invite .ovh-x svg { width: 16px; height: 16px; }
494
495/* Names in the brand face (OV Brand, Nunito 700–900, loaded by the navbar on every site): app names, the tabs, section
496 heads; the words you read (times, notes) stay in the UI face. Section heads are names now, not small capitals. */
497.ovh-name, .ovh-tab, .ovh-sec > span, .ovh-allbtn, .ovh-hist-day, .ovh-htxt b, .ovh-who-txt b, .ovh-actor-txt b { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; }
498.ovh-grid:not(.ovh-recent) .ovh-name { font-size: 13px; font-weight: 800; letter-spacing: .005em; color: color-mix(in srgb, var(--text-primary, #e8ecf3) 92%, transparent); opacity: 1; }
499.ovh-tabrow .ovh-tab { font-weight: 800; letter-spacing: .005em; }
500.ovh-sec { font-size: 15px; font-weight: 800; letter-spacing: .005em; text-transform: none; color: var(--text-primary, #e8ecf3); }
501.ovh-sec small { font-family: inherit; }
502.ovh-hist-day { font-size: 13px; font-weight: 800; letter-spacing: .01em; color: var(--text-secondary, #a8b3c4); }
503.ovh-who-txt b { font-size: 16px; font-weight: 800; letter-spacing: .005em; }
504.ovh-actor-txt b { font-weight: 800; }
505.ovh-grid:not(.ovh-recent) .ovh-folder .ovh-ic { gap: 7%; padding: 11%; }
506
507/* The AI card: its face says what it is doing (a glyph on its picture's corner, or the whole face), working ones breathe
508 (Calm: slower; Off: still); Open is a round arrow, Answer a pill with its glyph. */
509.ovh-actor .ovh-face { position: relative; overflow: visible; }
510.ovh-actor .ovh-face img { border-radius: 50%; }
511.ovh-doing { position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: #fff;
512 background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
513.ovh-doing svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.3; stroke-linecap: round; stroke-linejoin: round; }
514.ovh-face.is-glyph { border-radius: 13px; background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); }
515.ovh-face.is-glyph .ovh-doing { position: static; width: 100%; height: 100%; border-radius: inherit; background: none; box-shadow: none; }
516.ovh-face.is-glyph .ovh-doing svg { width: 22px; height: 22px; }
517.ovh-doing.is-waiting { background: linear-gradient(140deg, #f59e0b, #ef4444); }
518.ovh-face.is-glyph:has(.is-waiting) { background: linear-gradient(140deg, #f59e0b, #ef4444); }
519.ovh-face.is-glyph:has(.is-working) { animation: ovh-breathe 2.4s ease-in-out infinite; }
520@keyframes ovh-breathe { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); } }
521html[data-ov-motion=reduced] .ovh-face.is-glyph:has(.is-working) { animation-duration: 5s; }
522html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none; }
523.ovh-actor .ovh-go.is-ic { flex: none; display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border-radius: 50%; color: var(--accent-light, var(--accent, #60a5fa));
524 background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); transition: transform .2s cubic-bezier(.3, 1.4, .4, 1), background-color .15s; }
525.ovh-actor .ovh-go.is-ic svg, .ovh-actor .ovh-go.is-answer svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
526.ovh-actor .ovh-go.is-answer { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px 0 11px; border-radius: 999px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font-weight: 750; font-size: 13.5px; }
527@media (hover: hover) and (pointer: fine) { .ovh-actor:hover .ovh-go.is-ic { transform: translate(2px, -2px); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); } }
528
529/* 4.223.1: the notice's name stays on one line ("Your own AI", not "Your own / AI"); its sentence takes the wrap. */
530.ovh-notice .ovh-actor.is-invite .ovh-actor-txt b { white-space: nowrap; overflow: hidden; text-overflow: clip; }
531@container ovh (max-width: 400px) { .ovh-notice .ovh-actor.is-invite .ovh-go { padding: 0 12px; font-size: 13px; } }
532
533/* 4.238.0, the last word (later than every rule above): the round buttons' glyphs fill more of them (the owner, 11-13:
534 "the icons are so small, make them use more of the space, less padding"); the bell rings once when something is new;
535 the notifications tab takes the menu's whole height, its foot at the bottom ("take up the full height properly"). */
536.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { width: 48px; min-width: 48px; height: 48px; }
537.ovh-tabrow .ovh-allbtn svg, .ovh-tabrow .ovh-edit svg { width: 26px; height: 26px; }
538.ovh-actor .ovh-go.is-ic { width: 44px; height: 44px; }
539.ovh-actor .ovh-go.is-ic svg, .ovh-actor .ovh-go.is-answer svg { width: 24px; height: 24px; }
540.ovh-mebox .ovh-me .ovh-ib { width: 46px; height: 46px; }
541.ovh-mebox .ovh-me .ovh-ib svg { width: 26px; height: 26px; }
542.ovh-tab.has-new .ovh-tab-g > svg { transform-origin: 50% 14%; animation: ovh-ring 1.6s cubic-bezier(.36, .07, .19, .97) .3s 1 both; }
543@keyframes ovh-ring { 10% { transform: rotate(16deg); } 22% { transform: rotate(-13deg); } 34% { transform: rotate(9deg); } 46% { transform: rotate(-6deg); } 58% { transform: rotate(3deg); } 70%, 100% { transform: rotate(0); } }
544html[data-ov-motion=reduced] .ovh-tab.has-new .ovh-tab-g > svg { animation-duration: .9s; }
545html[data-ov-motion=off] .ovh-tab.has-new .ovh-tab-g > svg { animation: none; }
546.ovs-list > .ovh:has(> .ovh-notes) { min-height: 100%; box-sizing: border-box; display: flex; flex-direction: column; }
547.ovh > .ovh-notes { flex: 1 1 auto; display: flex; flex-direction: column; }
548.ovh > .ovh-notes > .openvibe-notif-dock { flex: 1 1 auto; }
549.ovh > .ovh-notes .openvibe-nv-foot { margin-top: auto; }
550.ovh > .ovh-notes .openvibe-nv-list:has(> .openvibe-nv-empty) { flex: 1 1 auto; display: grid; place-items: center; }
551.ovh-tabrow { margin: 0 0 4px; padding: 3px; gap: 6px; }
552.ovh-tabrow .ovh-tabs { padding: 2px; }
553.ovh-tabrow .ovh-tab { min-height: 42px; }
554.ovh-tabrow .ovh-tab svg { width: 27px; height: 27px; }
555.ovh-tabrow .ovh-allbtn, .ovh-tabrow .ovh-edit { width: 44px; min-width: 44px; height: 44px; }
556`;
557 document.head.appendChild(st);
558 }
559
560 // ── The arrangement ────────────────────────────────────────────────────────────────────────────────────────
561 function readLocal() { try { const v = JSON.parse(localStorage.getItem(HOME_KEY) || 'null'); return v && Array.isArray(v.home) ? v : null; } catch { return null; } }
562 function writeLocal(l) { try { localStorage.setItem(HOME_KEY, JSON.stringify(l)); } catch { /* a private window keeps it for this opening */ } }
563 const homeUrl = (o) => `${o.apiBase || 'https://openvibe.network'}/api/v1/me/home-screen`;
564 const signedIn = (o) => !!(o.token && o.user && !o.user.is_anon);
565 // A person the bar knows, with or without a token for the network's reads (a site with its own session, Run's):
566 // You's tabs are theirs either way (4.236.0; the owner, 11-13: "what happened to all the tabs … bring all that back").
567 const known = (o) => !!(o.user && !o.user.is_anon);
568 let putTimer = 0;
569 function pushRemote(o, l) {
570 if (!signedIn(o) || typeof fetch !== 'function') return;
571 clearTimeout(putTimer);
572 putTimer = setTimeout(() => {
573 fetch(homeUrl(o), { method: 'PUT', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' },
574 body: JSON.stringify({ home: l.home.slice(0, HOME_MAX), folders: l.folders }) }).catch(() => {});
575 }, 600);
576 }
577 let remoteAsked = false;
578 async function pullRemote(o) {
579 if (remoteAsked || !signedIn(o) || typeof fetch !== 'function') return null;
580 remoteAsked = true;
581 try {
582 const r = await fetch(homeUrl(o), { credentials: 'omit', headers: { Authorization: `Bearer ${o.token}` } });
583 if (!r.ok) return null;
584 const j = await r.json();
585 return j && Array.isArray(j.home) ? { home: j.home.filter((x) => typeof x === 'string').slice(0, HOME_MAX), folders: Array.isArray(j.folders) ? j.folders : [] } : null;
586 } catch { return null; }
587 }
588
589 /** Every app the menu knows, by id: sites, the person's own places (Files, Wallet…), the folders. */
590 function universe(o) {
591 const byId = new Map();
592 (o.apps || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
593 (o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
594 return byId;
595 }
596 // The network's eight groups, in the menu's own order (OpenVibe.Network frame/sites.js GROUPS, the owner's
597 // regrouping of 2026-11-10), as folders for the sites past the first page (until the person arranges their home).
598 const GROUP_FOLDERS = [['watch', 'Watch & stream'], ['talk', 'Talk & belong'], ['play', 'Play'], ['read', 'Read & ask'], ['shop', 'Shop & money'], ['tools', 'Free tools'], ['make', 'Make with AI'], ['build', 'For developers']];
599 // A group folder's id: `g-watch` …; Build keeps `build`, the id saved home screens already use.
600 const folderId = (g) => (g === 'build' ? 'build' : `g-${g}`);
601 // Saved before the regrouping: the old group ids map onto the new folders.
602 const FOLDER_ALIAS = { 'g-streams': 'g-watch', 'g-people': 'g-talk', 'g-games': 'g-play', 'g-answers': 'g-read' };
603 const normalizeFolder = (id) => (id.startsWith('folder:') ? `folder:${FOLDER_ALIAS[id.slice(7)] || id.slice(7)}` : id);
604 function topSites(o) {
605 const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id));
606 const limit = Math.max(6, 12 - 2 - mineFirst.length);
607 // Leave at least one app in every group for its folder: a group that is all singles has no folder to open.
608 const left = new Map();
609 for (const a of o.apps || []) left.set(a.group, (left.get(a.group) || 0) + 1);
610 const sites = [];
611 for (const a of o.apps || []) {
612 if (a.group === 'build') continue;
613 if ((left.get(a.group) || 0) <= 1) continue;
614 left.set(a.group, left.get(a.group) - 1);
615 sites.push(a.id);
616 if (sites.length >= limit) break;
617 }
618 return sites;
619 }
620 function folderDefs(o) {
621 const apps = o.apps || [];
622 const top = new Set(topSites(o));
623 const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: folderId(g), name, group: g, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))
624 .filter((f) => f.apps.length);
625 return groups.concat([
626 { 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/' } },
627 ]).filter((f) => f.apps.length);
628 }
629 /** Until the person arranges it: their own places first (signed in), then the most used and popular sites, then
630 * one folder per group (the menu's eight, in its order) and the quick tools. */
631 function defaultHome(o) {
632 const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);
633 const groupFolders = folderDefs(o).filter((f) => f.group).map((f) => `folder:${f.id}`);
634 return mineFirst.concat(topSites(o), groupFolders, ['folder:tools']);
635 }
636 function layoutOf(o) {
637 const byId = universe(o);
638 const folders = folderDefs(o);
639 const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));
640 const l = readLocal();
641 const home = (l ? l.home : defaultHome(o)).map(normalizeFolder).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);
642 return { home, folders, arranged: !!l, byId };
643 }
644
645 // Line icons for the buttons beside your name (24-unit boxes, optically centred), built as nodes.
646 const ICON = {
647 pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
648 check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
649 chev: [['path', { d: 'M6.5 9.5l5.5 5.5 5.5-5.5' }]],
650 briefcase: [['rect', { x: 3.5, y: 7.2, width: 17, height: 12.3, rx: 2.4 }], ['path', { d: 'M8.8 7.2V5.6a1.8 1.8 0 0 1 1.8-1.8h2.8a1.8 1.8 0 0 1 1.8 1.8v1.6M3.5 12.6h17' }]],
651 bell: [['path', { d: 'M12 3.5a1.22 1.22 0 0 1 1.22 1.22v0.47a5.44 5.44 0 0 1 4.22 5.25v3.38c0 0.84 0.28 1.59 0.84 2.25l0.75 0.84a0.75 0.75 0 0 1 -0.56 1.31H5.53a0.75 0.75 0 0 1 -0.56 -1.31l0.75 -0.84c0.56 -0.66 0.84 -1.41 0.84 -2.25v-3.38a5.44 5.44 0 0 1 4.22 -5.25v-0.47A1.22 1.22 0 0 1 12 3.5Z' }], ['path', { d: 'M9.94 18.97a2.16 2.16 0 0 0 4.13 0' }]],
652 history: [['path', { d: 'M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2' }], ['path', { d: 'M12 7.9v4.3l3.1 1.9' }]],
653 attach: [['path', { d: 'M12 5v14M5 12h14' }]],
654 plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],
655 out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],
656 grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],
657 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' }],
658 ['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' }]],
659 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' }]],
660 x: [['path', { d: 'M6 6l12 12M18 6 6 18' }]],
661 image: [['rect', { x: 3.5, y: 4.5, width: 17, height: 15, rx: 3 }], ['circle', { cx: 9, cy: 10, r: 1.8 }], ['path', { d: 'M4 17.5l4.6-4.4a1.5 1.5 0 0 1 2 0l2.4 2.3 2.2-2a1.5 1.5 0 0 1 2 0l3.3 3' }]],
662 eye: [['path', { d: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z' }], ['circle', { cx: 12, cy: 12, r: 2.8 }]],
663 heart: [['path', { d: 'M12 20s-7.2-4.4-8.9-8.8C1.9 7.9 4 4.8 7.2 4.8c1.9 0 3.4 1 4.8 2.9 1.4-1.9 2.9-2.9 4.8-2.9 3.2 0 5.3 3.1 4.1 6.4C19.2 15.6 12 20 12 20Z' }]],
664 reply: [['path', { d: 'M20.5 11.6a8 8 0 0 1-11.7 7.1L4 20l1.3-4.4a8 8 0 1 1 15.2-4Z' }]],
665 people: [['circle', { cx: 9, cy: 8.5, r: 3.2 }], ['path', { d: 'M3 19.5c.8-3.2 3.1-5 6-5s5.2 1.8 6 5' }], ['circle', { cx: 16.8, cy: 9.2, r: 2.4 }], ['path', { d: 'M16.6 14.6c2.2.2 3.8 1.7 4.4 4.4' }]],
666 trophy: [['path', { d: 'M8 4h8v5.2a4 4 0 0 1-8 0V4Z' }], ['path', { d: 'M8 6H5.2c0 2.3 1.2 3.8 3.1 4.1M16 6h2.8c0 2.3-1.2 3.8-3.1 4.1M12 13.2V17M8.8 20h6.4M10 17h4' }]],
667 calendar: [['rect', { x: 3.5, y: 5, width: 17, height: 15.5, rx: 3 }], ['path', { d: 'M3.5 10h17M8 3v4M16 3v4' }]],
668 chat: [['path', { d: 'M5 5h11a2.5 2.5 0 0 1 2.5 2.5v6A2.5 2.5 0 0 1 16 16H10l-4.5 3.5V16H5a2.5 2.5 0 0 1-2.5-2.5v-6A2.5 2.5 0 0 1 5 5Z' }], ['path', { d: 'M7 9.5h7M7 12.5h4' }]],
669 page: [['path', { d: 'M6.5 3h7.5l4 4v14h-11.5Z' }], ['path', { d: 'M14 3v4h4M9.5 12h5M9.5 15.5h5' }]],
670 play: [['path', { d: 'M8 5.5v13l10.5-6.5Z' }]],
671 // 4.223.0: drawings from the glyph registry (openvibe-shared/glyphs.js).
672 arrow: [['path', { d: 'M7 17L17 7M9 7h8v8' }]],
673 go: [['path', { d: 'M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3' }]],
674 clock: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M12 7.5V12l3 2' }]],
675 back: [['path', { d: 'M20.8 12H4M10.3 5.7l-6.3 6.3l6.3 6.3' }]],
676 game: [['rect', { x: 3.7, y: 7.2, width: 16.7, height: 9.7, rx: 4.4 }], ['path', { d: 'M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0' }]],
677 video: [['rect', { x: 3.4, y: 6.4, width: 12.3, height: 11.3, rx: 1.9 }], ['path', { d: 'M15.6 10.2l4.7-2.8v9.4l-4.7-2.8' }]],
678 book: [['path', { d: 'M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z' }], ['path', { d: 'M12 6.3v13.2' }]],
679 store: [['path', { d: 'M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1' }]],
680 wrench: [['path', { d: 'M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z' }]],
681 code: [['path', { d: 'M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8' }]],
682 spark: [['path', { d: 'M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z' }]],
683 user: [['circle', { cx: 12, cy: 7.4, r: 3.8 }], ['path', { d: 'M4.3 20.5c0-3.9 3.4-6.6 7.7-6.6s7.7 2.6 7.7 6.6' }]],
684 star: [['path', { d: 'M12 4l2.6 5.3l5.7 0.6l-4.3 3.9l1.2 5.7L12 16.8l-5.2 2.9l1.2-5.7l-4.3-3.9l5.7-0.6Z' }]],
685 toolbox: [['rect', { x: 3.5, y: 8.1, width: 17, height: 11.3, rx: 1.9 }], ['path', { d: 'M9.2 8.1V5.8A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.8V8.1M3.5 12.8h17M8.2 11.4v2.8M15.8 11.4v2.8' }]],
686 compass: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'm15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z' }]],
687 home: [['path', { d: 'M3.6 10.6L12 3.3l8.4 7.3v8.4a1 1 0 0 1-1 1h-4.2v-6.3h-6.3v6.3H4.7a1 1 0 0 1-1-1Z' }]],
688 live: [['circle', { cx: 12, cy: 11.8, r: 2 }], ['path', { d: 'M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9' }]],
689 shield: [['path', { d: 'M12 3.7L5.5 6.6v5.6c0 4.1 2.8 7.1 6.5 8.4c3.7-1.3 6.5-4.2 6.5-8.4V6.6Z' }]],
690 // 4.241.0: the feed's chips and actions.
691 tag: [['path', { d: 'M3.8 11.2V3.8h7.4l9.3 9.3l-7.4 7.4Z' }], ['circle', { cx: 8, cy: 8, r: 1.2 }]],
692 chart: [['path', { d: 'M3.9 3.4v16.7h16.7' }], ['path', { d: 'M7.7 14.5l3.6-4.1l3.2 2.7l5.2-6.3' }]],
693 scissors: [['circle', { cx: 6, cy: 7, r: 2.6 }], ['circle', { cx: 6, cy: 17, r: 2.6 }], ['path', { d: 'M8.2 8.6 20 18M8.2 15.4 20 6' }]],
694 smile: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01' }]],
695 share: [['path', { d: 'M12 15.2V3.3M7.4 8.1L12 3.3L16.6 8.1M4.9 12.9v6.1a1 1 0 0 0 1 1h12.2a1 1 0 0 0 1-1v-6.1' }]],
696 };
697 function svgIcon(name) {
698 const NS = 'http://www.w3.org/2000/svg';
699 const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
700 const def = ICON[name] || ICON.grid;
701 def.forEach(([tag, at]) => { const n = document.createElementNS(NS, tag); Object.keys(at).forEach((k) => n.setAttribute(k, String(at[k]))); s.append(n); });
702 return s;
703 }
704
705 // ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────
706 /** 4.229.0: a card with no person behind it (Deals, Community, Report) wears its site's app icon (design rule 142),
707 * 40 px like a person's face beside it, the glyph filling it (the owner, 11-12: "icons to increase size"). */
708 function appFace(site) {
709 const s = h('span', 'ov-appicon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', '40'); s.setAttribute('aria-hidden', 'true');
710 return s;
711 }
712 function siteIcon(site, size) {
713 const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');
714 return s;
715 }
716 /** A section's glyph from its name, so every group of apps is marked (the owner, 11-12: "each section should have an icon"). */
717 function groupGlyph(name) {
718 const n = String(name || '').toLowerCase();
719 return /your|yours/.test(n) ? 'user' : /watch|stream|video|live/.test(n) ? 'video' : /talk|belong|chat|social|community/.test(n) ? 'chat'
720 : /play|game|fun/.test(n) ? 'game' : /read|ask|news|learn/.test(n) ? 'book' : /shop|money|buy|market/.test(n) ? 'store'
721 : /quick/.test(n) ? 'toolbox' : /tool/.test(n) ? 'wrench' : /\bai\b|make/.test(n) ? 'spark' : /develop|build|code/.test(n) ? 'code'
722 : /home|popular/.test(n) ? 'home' : /used|most/.test(n) ? 'star' : /recent|where|history/.test(n) ? 'history' : 'compass';
723 }
724 /** A steady hue per site (the same on every page and device), so each app has its own colour. */
725 function hueOf(id) { let x = 7; const t = String(id || 'ov'); let i = 0; while (i < t.length) { x = (x * 31 + t.charCodeAt(i)) % 360; i++; } return x; }
726 function appTile(o, a, { tag = 'a', size = 40, small = false } = {}) {
727 const el = h(tag, 'ovh-app');
728 if (tag === 'a') el.href = safe(a.url) || '#'; else el.type = 'button';
729 el.setAttribute('data-id', a.id);
730 // An app icon, not a ring (11-09, the owner: "icons need major improvement, make use of the full space… more
731 // consistent"): the site's glyph large on a squircle tinted with its own steady hue.
732 const ic = h('span', 'ovh-ic');
733 ic.style.setProperty('--ovh-h', String(hueOf(a.site || a.id)));
734 ic.append(siteIcon(a.site, small ? 44 : 60));
735 el.append(ic, h('span', 'ovh-name', a.title));
736 if (tag === 'a') el.addEventListener('click', () => { if (o.onOpen) o.onOpen(a); });
737 return el;
738 }
739 function folderTile(o, f, byId, tag) {
740 const el = h(tag, 'ovh-app ovh-folder');
741 if (tag === 'button') el.type = 'button'; else { el.href = '#'; el.setAttribute('role', 'button'); }
742 el.setAttribute('data-id', `folder:${f.id}`);
743 el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);
744 const ic = h('span', 'ovh-ic');
745 // Each app's own icon (a tool's own glyph, not the folder's: four identical </> was the owner's complaint, 11-09).
746 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; };
747 const seenG = new Set(), cells = [];
748 f.apps.forEach((id) => { const g = glyphOf(id); if (cells.length < 4 && !seenG.has(g)) { seenG.add(g); cells.push([id, g]); } });
749 f.apps.forEach((id) => { if (cells.length < 4 && !cells.some((c) => c[0] === id)) cells.push([id, glyphOf(id)]); });
750 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); });
751 el.append(ic, h('span', 'ovh-name', f.name));
752 return el;
753 }
754
755 // ── You: the home screen ───────────────────────────────────────────────────────────────────────────────────
756 /** Your row: you (to your profile), Display, and your account's menu (profile, settings, switch, Sign out). Drawn by
757 * the menu's top sheet (youCard) since 4.238.0, and by You where the top does not show you. */
758 function meRow(o) {
759 const u = o.user || {};
760 const m = o.me || {};
761 const meBox = h('div', 'ovh-mebox');
762 const bar = h('div', 'ovh-me');
763 const profile = u.username ? `https://openvibe.network/@${encodeURIComponent(u.username)}` : 'https://openvibe.network/my';
764 const who = h('a', 'ovh-who'); who.href = profile; who.setAttribute('data-ov-tip', 'Your profile');
765 const av = h('span', 'ovh-av');
766 const pic = /^https:\/\//.test(String(u.avatar_url || '')) ? u.avatar_url : '';
767 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();
768 const txt = h('span', 'ovh-who-txt');
769 txt.append(h('b', '', u.display_name || u.username || 'You'), h('small', '', [u.username ? `@${u.username}` : '', 'Online'].filter(Boolean).join(' · ')));
770 who.append(av, txt);
771 const look = h('a', 'ovh-ib ovh-me-look-btn'); look.append(svgIcon('look')); look.href = 'https://openvibe.design/look'; look.setAttribute('data-ov-look-open', '');
772 look.setAttribute('aria-expanded', 'false'); look.setAttribute('aria-label', 'Display: theme, text size, motion'); look.setAttribute('data-ov-tip', 'Display');
773 const acct = h('button', 'ovh-ib ovh-acct'); acct.type = 'button'; acct.append(svgIcon('chev'));
774 acct.setAttribute('aria-haspopup', 'menu'); acct.setAttribute('aria-expanded', 'false');
775 acct.setAttribute('aria-label', 'Your account'); acct.setAttribute('data-ov-tip', 'Your account');
776 bar.append(who, look, acct);
777
778 const menu = h('div', 'ovh-acct-menu'); menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', 'Your account'); menu.hidden = true;
779 const item = (glyph, label, act, danger) => {
780 const it = typeof act === 'string' ? h('a', 'ovh-acct-i') : h('button', 'ovh-acct-i');
781 if (typeof act === 'string') it.href = act; else { it.type = 'button'; it.addEventListener('click', () => { close(); try { act(); } catch { /* the site's */ } }); }
782 if (danger) it.classList.add('is-danger');
783 it.setAttribute('role', 'menuitem');
784 it.append(svgIcon(glyph), h('span', '', label));
785 menu.append(it);
786 };
787 item('user', 'Your profile', profile);
788 item('gear', 'Account and settings', 'https://openvibe.network/my');
789 if (m.add && safe(m.add)) item('plus', 'Add or switch account', m.add);
790 if (typeof m.out === 'function') item('out', 'Sign out', () => m.out(), true);
791 let anchored = null;
792 const onDown = (e) => { if (!menu.contains(e.target) && !acct.contains(e.target)) close(); };
793 const onKey = (e) => { if (e.key === 'Escape') { close(); acct.focus(); } };
794 function close() {
795 if (menu.hidden) return;
796 menu.hidden = true; acct.setAttribute('aria-expanded', 'false');
797 if (anchored) { anchored.release(); anchored = null; }
798 document.removeEventListener('pointerdown', onDown, true); document.removeEventListener('keydown', onKey, true);
799 }
800 acct.addEventListener('click', () => {
801 if (!menu.hidden) { close(); return; }
802 menu.hidden = false; acct.setAttribute('aria-expanded', 'true');
803 const S = typeof window !== 'undefined' ? window.OVSurfaces : null;
804 if (S && typeof S.anchor === 'function') anchored = S.anchor(menu, acct, { place: 'bottom-end', sheet: false });
805 document.addEventListener('pointerdown', onDown, true); document.addEventListener('keydown', onKey, true);
806 const first = menu.querySelector('.ovh-acct-i'); if (first) first.focus({ preventScroll: true });
807 });
808 const panel = h('div', 'ud-look ovh-me-look'); panel.hidden = true;
809 meBox.append(bar, menu, panel);
810 return meBox;
811 }
812
813 function home(el, o) { // o is replaced by update() when the menu learns more (history, the catalogue)
814 styles();
815 el.classList.add('ovh');
816 el.setAttribute('data-sec', 'home');
817 // 4.223.0: You is three tabs (Apps first); the one a person chose stays for this tab of the browser.
818 let keptTab = null; try { keptTab = sessionStorage.getItem('ovh-you-tab'); } catch { /* */ }
819 const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0, tab: o.startTab || (['apps', 'notifications', 'recent'].includes(keptTab) ? keptTab : 'apps') };
820 let L = layoutOf(o);
821 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; };
822 const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };
823 let meBox = null, notesBox = null;
824 // The Look panel (theme, text size, motion, icons) opens under your name, drawn by the navbar (the account menu's).
825 if (typeof o.look === 'function') { try { o.look(el); } catch { /* the button stays a link to openvibe.design/look */ } }
826
827 /** You: your picture (online), your name, and Display and Settings beside it. Made once per opening, so an open
828 * Look panel survives the repaints that come as history and the catalogue arrive. */
829 // Your row (4.195.0): the old account block is not set here any more (the owner: "get rid of this thing … we
830 // didn't really need [it] here, other than like adding your account"). The row has the five things a person needs of
831 // an account; under it, only what has no other home in the menu.
832 /**
833 * You (4.237.0; the owner, 11-13: "completely redesign this card … I hate that thing … the buttons are below"):
834 * one slim row, your picture, your name and that you are online, Display, and your account's menu. The menu
835 * (anchored in the top layer, OVSurfaces) holds your profile, settings, switching account and signing out. What
836 * this site gives you as staff or as a creator (your channel, Admin, moderation) are apps at the start of the
837 * grid, not buttons here (ownApps below).
838 */
839 function me() { if (!meBox) meBox = meRow(o); return meBox; }
840
841 /** What this site gives you (your channel, Admin, moderation): apps at the start of the grid, by your rank. */
842 function ownApps() {
843 const m = o.me || {};
844 const OWN = { 'ov-channel': ['live', 'Your channel'], 'ov-admin': ['shield', 'Admin'], 'ov-mod': ['shield', 'Moderation'] };
845 const own = (Array.isArray(m.own) ? m.own : []).filter((it) => it && it.label && (typeof it.onClick === 'function' || (it.href && safe(it.href)))).slice(0, 8);
846 if (!own.length) return null;
847 const row = h('div', 'ovh-own-apps'); row.setAttribute('aria-label', 'Yours on this site');
848 own.forEach((it) => {
849 const [g, name] = OWN[it.id] || [ICON[it.glyph] ? it.glyph : 'star', it.short || it.label];
850 const el = it.href ? h('a', 'ovh-app ovh-own-app') : h('button', 'ovh-app ovh-own-app');
851 if (it.href) el.href = it.href; else { el.type = 'button'; el.addEventListener('click', () => { try { it.onClick(); } catch { /* the site's */ } }); }
852 el.setAttribute('data-ov-tip', it.label);
853 const ic = h('span', 'ovh-ic ovh-own-ic'); ic.style.setProperty('--ovh-h', String(hueOf(it.id))); ic.append(svgIcon(g));
854 el.append(ic, h('span', 'ovh-name', name));
855 row.append(el);
856 });
857 return row;
858 }
859
860 /** The three tabs under your row; Notifications carries how many wait. */
861 let countBound = false, tabWas = null;
862 function youTabs() {
863 const bar = h('div', 'ovh-tabs'); bar.setAttribute('role', 'tablist'); bar.setAttribute('aria-label', 'You');
864 if (!countBound && typeof document !== 'undefined') {
865 countBound = true;
866 const onCount = () => {
867 if (!el.isConnected) { document.removeEventListener('openvibe-notifications-count', onCount); countBound = false; return; }
868 const t = el.querySelector('.ovh-tab[data-tab="notifications"]'); if (!t) return;
869 let n = 0; try { n = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { n = 0; }
870 let b = t.querySelector('.ovh-tab-n');
871 if (n > 0) { if (!b) { b = h('b', 'ovh-tab-n'); (t.querySelector('.ovh-tab-g') || t).append(b); } b.textContent = n > 99 ? '99+' : String(n); t.setAttribute('aria-label', `Notifications, ${n} new`); }
872 else if (b) { b.remove(); t.setAttribute('aria-label', 'Notifications'); }
873 };
874 document.addEventListener('openvibe-notifications-count', onCount);
875 }
876 let unread = 0; try { unread = typeof o.unread === 'function' ? Number(o.unread()) || 0 : 0; } catch { unread = 0; }
877 [['apps', 'Apps', 'grid'], ['notifications', 'Notifications', 'bell', 'Alerts'], ['recent', 'History', 'history']].forEach(([id, label, ic, short]) => {
878 const b = h('button', 'ovh-tab'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.tab = id;
879 b.setAttribute('aria-selected', String(state.tab === id));
880 const g = h('span', 'ovh-tab-g'); g.append(svgIcon(ic));
881 b.append(g, h('span', 'ovh-tab-t', short || label)); b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label);
882 if (id === 'notifications' && unread > 0) { g.append(h('b', 'ovh-tab-n', unread > 99 ? '99+' : String(unread))); b.setAttribute('aria-label', `Notifications, ${unread} new`); b.classList.add('has-new'); }
883 b.addEventListener('click', () => {
884 if (state.tab === id) return;
885 tabWas = state.tab;
886 state.tab = id; try { sessionStorage.setItem('ovh-you-tab', id); } catch { /* */ } paint();
887 });
888 // Arrow keys move along the tabs (a tablist's own keys).
889 b.addEventListener('keydown', (e) => {
890 if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
891 const all = [...bar.querySelectorAll('.ovh-tab')]; const k = all.indexOf(b);
892 const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];
893 e.preventDefault(); next.click(); requestAnimationFrame(() => { const t = el.querySelector(`.ovh-tab[data-tab="${next.dataset.tab}"]`); if (t) t.focus(); });
894 });
895 b.tabIndex = state.tab === id ? 0 : -1;
896 bar.append(b);
897 });
898 // The chosen tab is lit and widens to show its name; the others are their glyphs (the owner, 11-13: "hide the
899 // label of the inactive tabs"). Switching, the last one folds as the new one opens (Calm: shorter; Off: at once).
900 const order = ['apps', 'notifications', 'recent'];
901 const dir = tabWas ? Math.sign(order.indexOf(state.tab) - order.indexOf(tabWas)) : 0;
902 const was = tabWas && tabWas !== state.tab ? bar.querySelector(`.ovh-tab[data-tab="${tabWas}"]`) : null;
903 const now = bar.querySelector(`.ovh-tab[data-tab="${state.tab}"]`);
904 tabWas = null;
905 if (was && now) {
906 was.classList.add('is-was'); now.classList.add('is-new');
907 requestAnimationFrame(() => requestAnimationFrame(() => { was.classList.remove('is-was'); now.classList.remove('is-new'); }));
908 }
909 if (dir) el.dataset.enter = dir > 0 ? 'right' : 'left'; else delete el.dataset.enter;
910 return bar;
911 }
912 /** The whole notifications list, made once per opening (the menu gives the mount): a repaint keeps its place. */
913 function notes() {
914 if (notesBox) return notesBox;
915 notesBox = h('div', 'ovh-notes');
916 // Without a token here (a site with its own session) the list is on the person's account page, one tap away.
917 if (!o.token) {
918 notesBox.append(h('p', 'ovh-hint', 'Your notifications are on your account.'));
919 const go = h('a', 'ovh-ib is-text', 'Open notifications'); go.href = 'https://openvibe.network/my#notifications';
920 notesBox.append(go);
921 return notesBox;
922 }
923 if (typeof o.notifications === 'function') { try { o.notifications(notesBox); } catch { notesBox.append(h('p', 'ovh-hint', 'Notifications did not load. Close the menu and open it again.')); } }
924 else notesBox.append(h('p', 'ovh-hint', 'Notifications are on openvibe.network/my.'));
925 return notesBox;
926 }
927
928 /** Recent (4.223.0; the owner, 11-12: "jump back in … shows a little more … like in the feed, longer"; "view full
929 * history doesn't even work"): every place you were, newest first, in days, each a card with its whole title. */
930 function history() {
931 const items = (o.back || []).filter((x) => x && safe(x.url));
932 if (!items.length) return null;
933 const wrap = h('div', 'ovh-hist');
934 const dayOf = (at) => {
935 const t = Date.parse(at || ''); if (!t) return 'Earlier';
936 const d0 = new Date(); d0.setHours(0, 0, 0, 0);
937 const d = new Date(t); d.setHours(0, 0, 0, 0);
938 const diff = Math.round((d0 - d) / 864e5);
939 return diff <= 0 ? 'Today' : diff === 1 ? 'Yesterday' : diff < 7 ? 'This week' : 'Earlier';
940 };
941 const list = h('div', 'ovh-hist-list'); list.setAttribute('role', 'list'); list.setAttribute('aria-label', 'Where you were');
942 const more = h('button', 'ovh-all ovh-hist-more', 'Show more'); more.type = 'button';
943 let shown = 12;
944 const draw = () => {
945 list.textContent = '';
946 let last = '';
947 items.slice(0, shown).forEach((x, i) => {
948 const d = dayOf(x.at);
949 if (d !== last) { last = d; list.append(h('div', 'ovh-hist-day', d)); }
950 const live = x.tag === 'Live';
951 const c = h('a', `ovh-hrow${live ? ' is-live' : ''}`); c.href = safe(x.url); c.setAttribute('role', 'listitem'); c.style.setProperty('--i', String(i % 12));
952 const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(x.site || 'ov'))); ic.append(siteIcon(x.site || 'ov', 44));
953 const t = h('span', 'ovh-htxt');
954 t.append(h('b', '', x.title));
955 const meta = h('small');
956 if (live) meta.append(h('span', 'ovh-live', 'LIVE'));
957 const pre = String(x.metaPre || '').replace(/^Live now(?: · )?/, '');
958 const when = h('span', '', [pre, ago(x.at)].filter(Boolean).join(' · ') || x.meta || '');
959 if (x.at) { when.dataset.at = x.at; when.dataset.pre = pre; } // search.js keeps the times current
960 meta.append(when); t.append(meta);
961 c.append(ic, t);
962 list.append(c);
963 });
964 more.hidden = shown >= items.length;
965 };
966 more.addEventListener('click', () => { shown += 12; draw(); icons(); });
967 draw();
968 const head = sec('Where you were', 'history');
969 const full = h('a', 'ovh-link', 'Full history'); full.href = 'https://openvibe.my/history'; head.append(full);
970 wrap.append(head, list, more);
971 return wrap;
972 }
973
974 /** Jump back in: the last places, as cards you swipe; a stream that is live now says so. */
975 function backRail() {
976 const items = (o.back || []).filter((x) => x && safe(x.url)).slice(0, 8);
977 if (!items.length) return null;
978 const wrap = h('div');
979 const head = sec('Jump back in');
980 head.append(h('small', '', 'where you were last'));
981 wrap.append(head);
982 const rail = h('div', 'ovh-rail'); rail.setAttribute('role', 'list'); rail.setAttribute('aria-label', 'Jump back in');
983 items.forEach((x, i) => {
984 const live = x.tag === 'Live';
985 const c = h('a', `ovh-card${live ? ' is-live' : ''}`); c.href = safe(x.url); c.setAttribute('role', 'listitem'); c.style.setProperty('--i', String(i));
986 const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(x.site || 'ov'))); ic.append(siteIcon(x.site || 'ov', 44));
987 const meta = h('small');
988 if (live) meta.append(h('span', 'ovh-live', 'LIVE'));
989 const pre = String(x.metaPre || '').replace(/^Live now(?: · )?/, '');
990 const when = h('span', '', [pre, ago(x.at)].filter(Boolean).join(' · ') || x.meta || '');
991 if (x.at) { when.dataset.at = x.at; when.dataset.pre = pre; } // search.js keeps the times current
992 meta.append(when);
993 c.append(ic, h('b', '', x.title), meta);
994 rail.append(c);
995 });
996 const ends = () => rail.classList.toggle('is-end', rail.scrollLeft + rail.clientWidth >= rail.scrollWidth - 4);
997 rail.addEventListener('scroll', ends, { passive: true });
998 requestAnimationFrame(ends);
999 wrap.append(rail);
1000 const all = h('a', 'ovh-link', 'View full history'); all.href = 'https://openvibe.my/my#history';
1001 wrap.append(all);
1002 return wrap;
1003 }
1004
1005 /** Most used: the pages you opened, by site, as one bar; its key is the apps themselves, each a way in. */
1006 function usage() {
1007 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);
1008 if (rows.length < 2) return null;
1009 const wrap = h('div');
1010 const head = sec('Most used', 'star');
1011 // Your stats on Report (private by default; you choose what is public), as the section's link.
1012 const more = h('a', 'ovh-link', 'Your stats'); more.href = 'https://openvibe.report/me';
1013 head.append(more);
1014 wrap.append(head);
1015 const box = h('div', 'ovh-use');
1016 const bar = h('div', 'ovh-use-bar'); bar.setAttribute('role', 'img');
1017 bar.setAttribute('aria-label', rows.map((r) => `${r.a.title} ${r.n}`).join(', '));
1018 const keys = h('div', 'ovh-use-apps'); keys.setAttribute('role', 'list');
1019 const CHART = ['#3b82f6', '#a855f7', '#f59e0b', '#14b8a6', '#ec4899']; // five that tell apart in dark and light
1020 rows.forEach((r, i) => {
1021 const c = CHART[i % CHART.length];
1022 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);
1023 const k = h('a', 'ovh-key'); k.href = safe(r.a.url) || '#'; k.style.setProperty('--c', c); k.setAttribute('role', 'listitem');
1024 k.setAttribute('aria-label', `${r.a.title}, opened ${num(r.n)} ${r.n === 1 ? 'time' : 'times'}`);
1025 const ic = h('span', 'ovh-ic'); ic.style.setProperty('--ovh-h', String(hueOf(r.a.site || r.a.id))); ic.append(siteIcon(r.a.site, 44));
1026 k.append(ic, h('b', '', r.a.title), h('small', '', num(r.n)));
1027 k.addEventListener('click', () => { if (o.onOpen) o.onOpen(r.a); });
1028 keys.append(k);
1029 });
1030 box.append(bar, keys);
1031 wrap.append(box);
1032 return wrap;
1033 }
1034
1035 function paintHome() {
1036 el.textContent = '';
1037 el.classList.toggle('is-edit', state.edit);
1038 // Edit keeps everything where it was: a long-press goes straight on to drag the app under the finger, so
1039 // nothing above the grid may come or go (the macOS run of #394 dropped onto the wrong place when the hint
1040 // pushed the grid down mid-drag). The cards above stay, quiet and inert; the hint goes under the grid.
1041 const calm = (x) => { x.classList.add('ovh-calm'); x.inert = state.edit; return x; };
1042 // The account's block wherever the bar has a person (a site with its own session may have no token for
1043 // the network's reads yet: sign out and the rest must still be there); else the short bar.
1044 const top = h('div', 'ovh-top');
1045 if (((o.user && !o.user.is_anon) || signedIn(o)) && !o.meInTop) top.append(calm(me()));
1046 if (top.childNodes.length) el.append(top);
1047 // 4.223.0 (the owner, 11-12: "at the top … the goosely Online bar. Under that is tabs … put the apps more front
1048 // facing … the first tab"; then "compact … waste of space"): one row of tabs, and on Apps the All and Edit
1049 // buttons at its end, so no head of its own. Edit stays pressable while the rest is quiet.
1050 const tab = known(o) ? state.tab : 'apps';
1051 const tools = tab === 'apps' ? homeTools() : null;
1052 if (known(o)) {
1053 const row = h('div', 'ovh-tabrow');
1054 row.append(calm(youTabs()));
1055 if (tools) row.append(tools);
1056 el.append(row);
1057 }
1058 const later = () => { icons(); };
1059 if (tab === 'notifications') { el.append(notes()); return later(); }
1060 if (tab === 'recent') {
1061 const u = usage(), b = history();
1062 if (u) el.append(u);
1063 if (b) el.append(b);
1064 if (!u && !b) { const e = h('div', 'ovh-empty'); e.append(svgIcon('history')); const w = h('div'); w.append(h('b', '', 'Nothing here yet'), h('span', '', 'The places you open and the apps you use most show up here.')); e.append(w); el.append(e); }
1065 return later();
1066 }
1067 if (!signedIn(o)) {
1068 const head = sec(L.arranged ? 'Your home' : 'Popular on OpenVibe', 'home');
1069 head.append(tools);
1070 el.append(head);
1071 }
1072 if (known(o)) { const own = ownApps(); if (own) el.append(calm(own)); }
1073 const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
1074 L.home.forEach((id, i) => grid.append(homeTile(id, i)));
1075 el.append(grid);
1076 if (state.edit) {
1077 el.append(h('p', 'ovh-hint', 'Hold an app, then drag it (a quick swipe scrolls), or use the arrow keys. − takes it off Home; All puts it back.'));
1078 }
1079 // Signed out there are no tabs: what you use most and where you were stay under the apps.
1080 if (!signedIn(o)) { const u = usage(); if (u) el.append(calm(u)); const b = backRail(); if (b) el.append(calm(b)); }
1081 icons();
1082 }
1083
1084 function homeTile(id, i) {
1085 let t;
1086 if (id.startsWith('folder:')) {
1087 const f = L.folders.find((x) => `folder:${x.id}` === id);
1088 t = folderTile(o, f, L.byId, state.edit ? 'button' : 'a');
1089 if (!state.edit) t.addEventListener('click', (e) => { e.preventDefault(); if (Date.now() < state.suppress) return; state.view = 'folder'; state.folder = f.id; paint(); });
1090 } else {
1091 const a = L.byId.get(id);
1092 t = appTile(o, a, { tag: state.edit ? 'button' : 'a' });
1093 }
1094 const name = t.querySelector('.ovh-name').textContent;
1095 if (state.edit) {
1096 t.setAttribute('aria-label', `${name}, ${i + 1} of ${L.home.length}. Arrow keys move it; Delete takes it off Home.`);
1097 const rm = h('button', 'ovh-badge', '−'); rm.type = 'button'; rm.setAttribute('aria-label', `Take ${name} off Home`);
1098 rm.addEventListener('click', (e) => { e.stopPropagation(); L.home.splice(L.home.indexOf(id), 1); save(); paint(); });
1099 rm.addEventListener('pointerdown', (e) => e.stopPropagation());
1100 t.append(rm);
1101 t.addEventListener('keydown', (e) => keyMove(e, id));
1102 }
1103 press(t, id);
1104 return t;
1105 }
1106
1107 function keyMove(e, id) {
1108 const i = L.home.indexOf(id); let j = i;
1109 const cols = Math.max(1, getComputedStyle(el.querySelector('.ovh-grid:not(.ovh-recent)')).gridTemplateColumns.split(' ').length);
1110 if (e.key === 'ArrowLeft') j = i - 1; else if (e.key === 'ArrowRight') j = i + 1;
1111 else if (e.key === 'ArrowUp') j = i - cols; else if (e.key === 'ArrowDown') j = i + cols;
1112 else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); L.home.splice(i, 1); save(); paint(); const n = el.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); if (n) n.focus(); return; }
1113 else return;
1114 e.preventDefault(); e.stopPropagation();
1115 j = Math.max(0, Math.min(L.home.length - 1, j));
1116 if (j === i) return;
1117 L.home.splice(i, 1); L.home.splice(j, 0, id); save(); paint();
1118 const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.focus();
1119 }
1120
1121 // Long-press starts Edit (and keeps hold of the app, so a finger can drag at once); in Edit a press drags.
1122 function press(t, id) {
1123 t.addEventListener('contextmenu', (e) => { if (state.edit || state.drag) e.preventDefault(); });
1124 t.addEventListener('pointerdown', (e) => {
1125 if (e.button !== 0) return;
1126 const start = { x: e.clientX, y: e.clientY, id: e.pointerId };
1127 // 4.225.0, a phone (the owner: a long press then a move lost the icon): entering Edit repaints the grid, so
1128 // the tile under the finger leaves the page and the grid's own touchmove guard no longer hears this touch;
1129 // the browser scrolled the menu and cancelled the press. The guard rides on the pressed tile itself, which
1130 // keeps getting the touch's events after it is replaced, until the finger lifts.
1131 // 4.236.0 (the owner, 11-13: "in edit mode … trying to scroll up on mobile … you can't"): in Edit a touch
1132 // only holds the page once it has taken an app (a hold of ARM_MS, or a sideways move); a quick swipe up or
1133 // down scrolls the menu as anywhere else.
1134 let armed = false, armTimer = 0;
1135 if (e.pointerType === 'touch') {
1136 const hold = (ev) => { if ((armed || state.drag) && ev.cancelable) ev.preventDefault(); };
1137 const done = () => { t.removeEventListener('touchmove', hold); t.removeEventListener('touchend', done); t.removeEventListener('touchcancel', done); };
1138 t.addEventListener('touchmove', hold, { passive: false });
1139 t.addEventListener('touchend', done);
1140 t.addEventListener('touchcancel', done);
1141 }
1142 let timer = 0;
1143 const cancel = () => { clearTimeout(timer); clearTimeout(armTimer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };
1144 const early = (m) => {
1145 if (m.pointerId !== start.id) return;
1146 const dx = m.clientX - start.x, dy = m.clientY - start.y;
1147 if (Math.hypot(dx, dy) <= 8) return;
1148 // A touch not yet holding an app that moves mostly up or down is a scroll: let it go.
1149 if (e.pointerType === 'touch' && state.edit && !armed && Math.abs(dy) > Math.abs(dx)) { cancel(); return; }
1150 cancel(); if (state.edit) beginDrag(id, m);
1151 };
1152 if (e.pointerType === 'touch' && state.edit) {
1153 armTimer = setTimeout(() => {
1154 armed = true;
1155 try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* */ }
1156 const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-armed');
1157 window.addEventListener('pointerup', () => { const k = el.querySelector('.is-armed'); if (k) k.classList.remove('is-armed'); }, { once: true });
1158 }, ARM_MS);
1159 }
1160 window.addEventListener('pointermove', early);
1161 window.addEventListener('pointerup', cancel);
1162 window.addEventListener('pointercancel', cancel);
1163 if (!state.edit) {
1164 timer = setTimeout(() => {
1165 cancel();
1166 state.suppress = Date.now() + 700;
1167 try { if (navigator.vibrate) navigator.vibrate(12); } catch { /* */ }
1168 setEdit(true);
1169 // The finger is still down: the next move drags this app.
1170 const follow = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 6) { window.removeEventListener('pointermove', follow); beginDrag(id, m); } };
1171 window.addEventListener('pointermove', follow);
1172 window.addEventListener('pointerup', () => window.removeEventListener('pointermove', follow), { once: true });
1173 }, LONG_MS);
1174 }
1175 });
1176 // A long-press must not also open the app.
1177 t.addEventListener('click', (e) => { if (Date.now() < state.suppress && !(e.target.closest && e.target.closest('.ovh-badge'))) { e.preventDefault(); e.stopImmediatePropagation(); } }, true);
1178 }
1179
1180 // While editing, a finger on the grid never scrolls the page (11-09, the owner: holding an icon "scrolled up and
1181 // down… even though I'm in edit mode").
1182 if (!el.__ovhTouch) { el.__ovhTouch = true; el.addEventListener('touchmove', (e) => { if (state.drag && e.target.closest && e.target.closest('.ovh-grid:not(.ovh-recent)') && e.cancelable) e.preventDefault(); }, { passive: false }); }
1183 function beginDrag(id, e) {
1184 if (state.drag) return;
1185 const ghost = h('div', 'ovh-ghost');
1186 const src = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"] .ovh-ic`);
1187 if (src) { const c = src.cloneNode(true); c.style.animation = 'none'; ghost.append(c); }
1188 document.body.append(ghost);
1189 state.drag = { id, ghost };
1190 const at = (m) => { ghost.style.left = `${m.clientX}px`; ghost.style.top = `${m.clientY}px`; };
1191 at(e);
1192 const lift = () => { const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-lifted'); };
1193 lift();
1194 // Near the top or bottom of the menu's list, the list scrolls under the finger (one page of apps can be longer
1195 // than the screen); nothing else scrolls while an app is held.
1196 const scroller = (() => { let n = el.parentElement; while (n && n !== document.body) { const st = getComputedStyle(n); if (/(auto|scroll)/.test(st.overflowY) && n.scrollHeight > n.clientHeight) return n; n = n.parentElement; } return null; })();
1197 const move = (m) => {
1198 if (m.cancelable) m.preventDefault();
1199 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; }
1200 at(m);
1201 // Which place the finger is over, by the grid's slots (offsetLeft/offsetTop: where each place IS, not where
1202 // a sliding app is drawn this frame; the old hit test read the slide in progress, so an app passing under
1203 // the finger took the drop and it landed one place off), and only well inside it (the middle 70% by 80%),
1204 // so the grid never flickers between two places.
1205 const grid = el.querySelector('.ovh-grid:not(.ovh-recent)');
1206 if (!grid) return;
1207 const gr = grid.getBoundingClientRect();
1208 const kids = [...grid.children];
1209 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; });
1210 const from = L.home.indexOf(id);
1211 if (to < 0 || to >= L.home.length || to === from) return;
1212 const sn = kids[to], cx = gr.left + sn.offsetLeft + sn.offsetWidth / 2, cy = gr.top + sn.offsetTop + sn.offsetHeight / 2;
1213 if (Math.abs(m.clientX - cx) > sn.offsetWidth * 0.35 || Math.abs(m.clientY - cy) > sn.offsetHeight * 0.4) return;
1214 const before = new Map([...grid.children].map((n) => [n.getAttribute('data-id'), n.getBoundingClientRect()]));
1215 L.home.splice(from, 1); L.home.splice(to, 0, id);
1216 grid.textContent = ''; L.home.forEach((x, i) => grid.append(homeTile(x, i)));
1217 icons(); lift();
1218 // Each app slides from where it was to where it now is (FLIP), so the grid moves like a phone's.
1219 [...grid.children].forEach((n) => {
1220 const b = before.get(n.getAttribute('data-id')); if (!b) return;
1221 const a = n.getBoundingClientRect(); const dx = b.left - a.left, dy = b.top - a.top;
1222 if (!dx && !dy) return;
1223 n.style.transition = 'none'; n.style.transform = `translate(${dx}px, ${dy}px)`;
1224 requestAnimationFrame(() => { n.style.transition = 'transform .26s cubic-bezier(.2, .9, .3, 1)'; n.style.transform = ''; });
1225 });
1226 };
1227 const end = () => {
1228 window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);
1229 ghost.remove(); state.drag = null; state.suppress = Date.now() + 300; save(); paint();
1230 };
1231 window.addEventListener('pointermove', move);
1232 window.addEventListener('pointerup', end);
1233 window.addEventListener('pointercancel', end);
1234 }
1235
1236 function homeTools() {
1237 const edit = h('button', state.edit ? 'ovh-edit is-done' : 'ovh-edit');
1238 edit.type = 'button';
1239 edit.append(svgIcon(state.edit ? 'check' : 'pencil'));
1240 edit.setAttribute('aria-label', state.edit ? 'Done arranging Home' : 'Edit Home');
1241 edit.setAttribute('data-ov-tip', state.edit ? 'Done' : 'Edit Home: move apps, remove them, add folders');
1242 edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
1243 edit.addEventListener('click', () => setEdit(!state.edit));
1244 // All apps sits beside Edit, small, with its count (the owner, 2026-11-10: "all apps should just be in a
1245 // button in the corner so it's more compact").
1246 const total = L.byId.size;
1247 const all = h('button', 'ovh-allbtn'); all.type = 'button';
1248 all.append(svgIcon('grid'), h('b', '', String(total)));
1249 all.setAttribute('aria-label', `All apps (${total})`); all.setAttribute('data-ov-tip', `All apps (${total})`);
1250 all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
1251 const tools = h('div', 'ovh-headtools'); tools.append(all, edit);
1252 return tools;
1253 }
1254 function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-edit'); if (b && !on) b.focus(); }
1255
1256 function paintFolder() {
1257 const f = L.folders.find((x) => x.id === state.folder);
1258 if (!f) { state.view = 'home'; return paintHome(); }
1259 el.textContent = '';
1260 el.append(backHead(f.name));
1261 const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', f.name);
1262 const ranked = f.apps.map((id) => L.byId.get(id) || (o.tools || []).find((t) => t.id === id)).filter(Boolean);
1263 const shown = f.id === 'tools' ? ranked.slice(0, 24) : ranked;
1264 shown.forEach((a) => grid.append(appTile(o, a)));
1265 el.append(grid);
1266 if (f.more) { const m = h('a', 'ovh-all', f.more.label); m.append(svgIcon('go')); m.href = f.more.href; el.append(m); }
1267 icons();
1268 }
1269
1270 function paintDrawer() {
1271 el.textContent = '';
1272 // 4.229.0 (the owner, 11-12: "confusing"): one search. The menu's own pill reads "Find among 64 apps" and
1273 // filters this list as you type (OpenVibeHome's filter()); the head is the place's name and its count.
1274 const head = backHead('All apps', { count: L.byId.size, hint: typeof o.onView === 'function' ? 'Type above to find one' : '' });
1275 el.append(head);
1276 let field = null;
1277 if (typeof o.onView !== 'function') { // a host without the menu's pill keeps the box of its own
1278 field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;
1279 head.append(field);
1280 }
1281 const body = h('div');
1282 el.append(body);
1283 const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();
1284 const draw = () => {
1285 body.textContent = '';
1286 const q = state.filter.trim().toLowerCase();
1287 const all = [...L.byId.values()].filter((a) => !q || a.title.toLowerCase().includes(q)).sort((a, b) => a.title.localeCompare(b.title));
1288 const yours = all.filter((a) => a.id.startsWith('you:'));
1289 const sets = [];
1290 if (yours.length) sets.push(['Yours', yours]);
1291 groups.forEach((g, gi) => {
1292 const list = all.filter((a) => !a.id.startsWith('you:') && (g.id ? a.group === g.id : true) && (g.id || gi === groups.length - 1 || true));
1293 if (list.length) sets.push([g.name, list]);
1294 });
1295 const placed = new Set(sets.flatMap(([, list]) => list.map((a) => a.id)));
1296 const rest = all.filter((a) => !placed.has(a.id));
1297 if (rest.length) sets.push(['More', rest]);
1298 L.folders.forEach((f) => { if (!q || f.name.toLowerCase().includes(q)) sets.push([`${f.name} folder`, [{ folder: f }]]); });
1299 if (!sets.length) { body.append(h('p', 'ovh-note', 'No app has that name.')); return; }
1300 sets.forEach(([name, list]) => {
1301 body.append(sec(name, groupGlyph(name), list.length));
1302 const grid = h('div', 'ovh-grid');
1303 list.forEach((a) => {
1304 const id = a.folder ? `folder:${a.folder.id}` : a.id;
1305 const t = a.folder ? folderTile(o, a.folder, L.byId, 'a') : appTile(o, a);
1306 if (a.folder) t.addEventListener('click', (e) => { e.preventDefault(); state.view = 'folder'; state.folder = a.folder.id; paint(); });
1307 if (!L.home.includes(id) && L.home.length < HOME_MAX) {
1308 const add = h('button', 'ovh-badge is-add', '+'); add.type = 'button';
1309 add.setAttribute('aria-label', `Put ${a.folder ? a.folder.name : a.title} on Home`);
1310 add.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); L.home.push(id); save(); draw(); icons(); });
1311 t.append(add);
1312 }
1313 grid.append(t);
1314 });
1315 body.append(grid);
1316 });
1317 icons();
1318 };
1319 if (field) field.addEventListener('input', () => { state.filter = field.value; draw(); });
1320 drawerDraw = draw;
1321 draw();
1322 }
1323 let drawerDraw = null;
1324
1325 function backHead(title, { count = null, hint = '' } = {}) {
1326 const head = h('div', 'ovh-head');
1327 const back = h('button', 'ovh-back'); back.type = 'button'; back.append(svgIcon('back'));
1328 back.setAttribute('aria-label', 'Back to your home'); back.setAttribute('data-ov-tip', 'Back to your home');
1329 back.addEventListener('click', () => { state.view = 'home'; state.folder = null; state.filter = ''; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });
1330 const name = h('div', 'ovh-head-t');
1331 const b = h('b', '', title);
1332 if (count != null) b.append(h('span', 'ovh-head-n', String(count)));
1333 name.append(b);
1334 if (hint) name.append(h('small', '', hint));
1335 head.append(back, name);
1336 return head;
1337 }
1338 function sec(text, glyph, count) {
1339 const s = h('div', 'ovh-sec');
1340 if (glyph) { const i = h('i', 'ovh-sec-ic'); i.append(svgIcon(glyph)); s.append(i); }
1341 s.append(h('span', '', text));
1342 if (count != null) s.append(h('b', 'ovh-sec-n', String(count)));
1343 s.append(h('i', 'ovh-sec-sp'));
1344 return s;
1345 }
1346
1347 function paint() {
1348 // The menu hears which view shows, so its pill can search inside it (All apps) or go back to searching OpenVibe.
1349 if (typeof o.onView === 'function') { try { o.onView(state.view, { apps: L.byId.size }); } catch { /* the menu's own */ } }
1350 if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome();
1351 }
1352 paint();
1353 // Network's copy wins when it answers and differs.
1354 pullRemote(o).then((r) => {
1355 if (!r) return;
1356 const now = JSON.stringify(L.home);
1357 const known = r.home.filter((id) => L.byId.has(id) || (id.startsWith('folder:') && L.folders.some((f) => `folder:${f.id}` === id)));
1358 if (!known.length || JSON.stringify(known) === now) return;
1359 writeLocal({ home: known, folders: r.folders });
1360 L = layoutOf(o);
1361 if (!state.drag && el.isConnected) paint();
1362 });
1363 return {
1364 repaint: paint,
1365 /** 4.229.0: which view shows ('home', 'drawer' for All apps, 'folder'), and the pill's words for All apps. */
1366 get view() { return state.view; },
1367 apps: () => L.byId.size,
1368 filter(q) { state.filter = String(q || ''); if (state.view === 'drawer' && drawerDraw) drawerDraw(); },
1369 /** Show 'drawer' (All apps) or 'home' from outside: a menu address (you/apps) opens All apps. */
1370 show(view) { if (view !== 'drawer' && view !== 'home') return; state.view = view; state.folder = null; state.filter = ''; paint(); },
1371 /** The menu learned more (history, the catalogue): redraw with it, unless the person is arranging. */
1372 update(next) { o = next; L = layoutOf(o); if (!state.drag && !state.edit && state.view === 'home') paint(); },
1373 };
1374 }
1375
1376 // ── Your Actor, on top of You ──────────────────────────────────────────────────────────────────────────────
1377 /** The glyph for what an agent is doing now (the owner, 11-12: "put an icon depending on the agent status … custom for
1378 * the agent"): its step's words first, then its state. A site or Actor may name one (`a.now.glyph`) from the registry. */
1379 function doingGlyph(a, st) {
1380 const named = a && a.now && a.now.glyph; if (named && ICON[named]) return named;
1381 const t = String((a && a.now && a.now.current_step) || '').toLowerCase();
1382 if (st === 'waiting') return 'chat';
1383 if (st === 'paused') return 'clock';
1384 if (/read|review|look|watch/.test(t)) return 'book';
1385 if (/search|find|hunt|scan|brows/.test(t)) return 'compass';
1386 if (/writ|draft|post|reply|edit/.test(t)) return 'pencil';
1387 if (/code|build|fix|deploy|test/.test(t)) return 'code';
1388 if (/stream|live|clip|video/.test(t)) return 'live';
1389 if (/buy|deal|shop|price|sell/.test(t)) return 'store';
1390 if (/game|play/.test(t)) return 'game';
1391 if (/think|plan|idea/.test(t)) return 'spark';
1392 return st === 'working' ? 'spark' : 'clock';
1393 }
1394 async function actor(box, o) {
1395 // 4.242.0: through the Frame unless the page names Actor itself (every site's connect-src allows openvibe.network
1396 // and most refuse openvibe.actor, so a direct read failed there and the card said "Set it up" to people who had one).
1397 const url = o.actorBase ? `${String(o.actorBase).replace(/\/+$/, '')}/api/v1/me/actor` : o.frameBase ? `${String(o.frameBase).replace(/\/+$/, '')}/actor` : `${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/frame/actor`;
1398 let a = null;
1399 try {
1400 const r = await fetch(url, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${o.token}` } });
1401 if (r.ok) a = await r.json();
1402 } catch { a = null; }
1403 if (!box.isConnected) return;
1404 box.textContent = '';
1405 if (a && a.id && a.enabled !== false) {
1406 const asks = Array.isArray(a.asks) ? a.asks : [];
1407 const st = asks.length ? 'waiting' : ['working', 'resting', 'paused'].includes(a.status) ? a.status : 'resting';
1408 const label = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' }[st];
1409 const b = h('button', 'ovh-actor'); b.type = 'button';
1410 const face = h('span', 'ovh-face');
1411 const pic = a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '';
1412 if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';
1413 // What it is doing, as a glyph on its face (its whole face when it has no picture).
1414 const doing = h('span', `ovh-doing is-${st}`); doing.append(svgIcon(doingGlyph(a, st))); doing.setAttribute('aria-hidden', 'true');
1415 if (!pic) { face.textContent = ''; face.classList.add('is-glyph'); }
1416 face.append(doing);
1417 const txt = h('span', 'ovh-actor-txt');
1418 const name = h('b', '', 'Actor');
1419 const chip = h('span', `ovh-chip is-${st}`); chip.append(h('span', 'ovh-dot'), document.createTextNode(label));
1420 name.append(chip);
1421 const now = a.now || {};
1422 txt.append(name, h('small', '', (a.name && !asks.length ? `${a.name} · ` : '') + (asks.length ? `It asks you: ${asks[0].text || ''}` : now.current_step ? String(now.current_step) : st === 'paused' ? 'Paused until you resume it' : 'Resting until its next goal is due')));
1423 const go = h('span', asks.length ? 'ovh-go is-answer' : 'ovh-go is-ic'); go.append(svgIcon(asks.length ? 'chat' : 'arrow'));
1424 if (asks.length) go.append(h('span', '', 'Answer'));
1425 b.setAttribute('aria-label', `${a.name || 'Your Actor'}, ${label}: ${asks.length ? 'answer it' : 'open it'}`);
1426 b.append(face, txt, go);
1427 b.addEventListener('click', () => {
1428 if (root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function') { if (o.close) o.close(); try { root.OpenVibeNavbar.openDock('agent'); return; } catch { /* */ } }
1429 location.href = `${base}/you`;
1430 });
1431 box.append(b);
1432 return;
1433 }
1434 // No Actor yet: one invitation, which the person can put away.
1435 try { if (localStorage.getItem(INVITE_KEY) === 'off') return; } catch { /* */ }
1436 const card = h('div', 'ovh-actor'); card.setAttribute('role', 'group'); card.setAttribute('aria-label', 'Your own AI');
1437 // 11-09, the owner: the line was cut off, the icon small, the buttons plain words: the whole sentence on two lines,
1438 // a bigger mark, and two real buttons (beside the words on a wide menu, under them on a phone).
1439 card.classList.add('is-invite');
1440 const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 30));
1441 const txt = h('span', 'ovh-actor-txt');
1442 txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch.'));
1443 const acts = h('span', 'ovh-acts');
1444 const go = h('a', 'ovh-go', 'Set it up'); go.href = `${base}/you`;
1445 const no = h('button', 'ovh-x'); no.append(svgIcon('x')); no.type = 'button'; no.setAttribute('aria-label', 'Not now'); no.title = 'Not now';
1446 no.addEventListener('click', () => { try { localStorage.setItem(INVITE_KEY, 'off'); } catch { /* */ } box.textContent = ''; });
1447 acts.append(go, no);
1448 card.append(face, txt, acts);
1449 box.append(card);
1450 if (typeof o.mountIcons === 'function') { try { o.mountIcons(box); } catch { /* */ } }
1451 }
1452
1453 // ── Feed: what's new with you, and today ───────────────────────────────────────────────────────────────────
1454 function wallTop(el, o) {
1455 styles();
1456 el.classList.add('ovh');
1457 el.setAttribute('data-sec', 'wall-top');
1458 el.textContent = '';
1459 const compose = h('div'); const today = h('div');
1460 el.append(compose, today);
1461 // The composer shows when Community says this person can post (pulse viewer.can_post), so it never offers a
1462 // post that would be refused.
1463 o.pulseReady.then((p) => { if (p && p.viewer && p.viewer.can_post && signedIn(o) && compose.isConnected) composer(compose, o); });
1464 fetch(`${o.apiBase || 'https://openvibe.network'}/api/v1/frame/today`, { credentials: 'omit', headers: { Accept: 'application/json' } })
1465 .then((r) => (r.ok ? r.json() : null)).then((t) => { if (t && today.isConnected) paintToday(today, t); }).catch(() => {});
1466 }
1467 // A picture for a post: scaled to at most 2560 px on its long side (phone photos are often 12 MB; Community takes 8 MB),
1468 // and a small preview drawn to a data: address (every site's img-src allows data:, not all allow blob:).
1469 async function preparePicture(file) {
1470 const out = { file, thumb: '' };
1471 try {
1472 const bmp = await createImageBitmap(file);
1473 const draw = (w, hgt) => { const c = document.createElement('canvas'); c.width = w; c.height = hgt; c.getContext('2d').drawImage(bmp, 0, 0, w, hgt); return c; };
1474 const t = 112 / Math.max(bmp.width, bmp.height);
1475 out.thumb = draw(Math.max(1, Math.round(bmp.width * t)), Math.max(1, Math.round(bmp.height * t))).toDataURL('image/jpeg', 0.8);
1476 const k = Math.min(1, 2560 / Math.max(bmp.width, bmp.height));
1477 if ((k < 1 || file.size > 7.5e6) && !/gif/i.test(file.type)) {
1478 const blob = await new Promise((ok) => draw(Math.round(bmp.width * k), Math.round(bmp.height * k)).toBlob(ok, 'image/jpeg', 0.86));
1479 if (blob) out.file = new File([blob], `${String(file.name || 'picture').replace(/\.[a-z0-9]+$/i, '')}.jpg`, { type: 'image/jpeg' });
1480 }
1481 } catch { /* the original goes as it is */ }
1482 return out;
1483 }
1484 // The Feed's composer is the shared one (ov-composer.js 4.215.0, composer v2, preset post): the person's picture, a
1485 // box that grows, photos as tiles each with its description, files, emoji, voice, the ring and Post. The 11-12
1486 // owner: "this composer is completely ugly and broken … doesn't have the file attachment" (its own + drew an icon
1487 // with no stroke, so it could not be seen). What it posts is still the Feed's: pictures through the frame with their
1488 // descriptions, other files to Media, then the post or the status.
1489 function composer(box, o) {
1490 const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
1491 const auth = { Authorization: `Bearer ${o.token}` };
1492 const upload = async (url, f) => {
1493 const fd = new FormData(); fd.append('file', f, f.name || 'file');
1494 const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: auth, body: fd });
1495 const j = await r.json().catch(() => ({}));
1496 const id = (j.attachment && (j.attachment.media_id || j.attachment.id)) || j.id;
1497 if (!r.ok || !id) throw new Error(j.detail || `${f.name || 'File'} did not upload.`);
1498 return id;
1499 };
1500 const start = () => {
1501 if (!root.OpenVibeComposer || !root.OpenVibeComposer.mount || !box.isConnected || box.querySelector('#ovh-feed-composer')) return;
1502 const pics = new Map();
1503 const c = root.OpenVibeComposer.mount(box, {
1504 preset: 'post', id: 'ovh-feed-composer', user: o.user, maxLength: 2000, maxFiles: 10, maxMb: 300,
1505 placeholder: first ? `What's new, ${first}?` : "What's new?", label: "What's new",
1506 async onSubmit(v, api) {
1507 const images = v.files.filter((f) => /^image\//.test(f.type));
1508 if (images.length > 4) { api.ctx.note('Choose up to four pictures.'); return false; }
1509 // 4.228.0: words and descriptions are optional (the owner, 11-12: a picture alone, as on any social
1510 // app). A picture nobody described is described by AI as it posts (Network /api/frame/caption, the
1511 // person's own allowance); if that cannot be done it posts without, never stops the post.
1512 for (const f of images) {
1513 const field = pics.get(f);
1514 if (!field || field.value.trim()) continue;
1515 c.send.textContent = 'Describing your picture';
1516 try { await api.ctx.caption(f); } catch { /* posts without */ }
1517 if (!field.value.trim() && api.ctx.alt(f)) field.value = api.ctx.alt(f);
1518 }
1519 const pictures = [], attachments = [];
1520 for (const f of v.files) {
1521 c.send.textContent = `Uploading ${f.name || 'file'}`;
1522 if (/^image\//.test(f.type)) {
1523 const prepared = await preparePicture(f);
1524 const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
1525 pictures.push({ media_id, alt: ((pics.get(f) && pics.get(f).value) || '').trim().slice(0, 300) });
1526 } else attachments.push(await upload('https://openvibe.media/api/v1/attachments', f));
1527 }
1528 c.send.textContent = 'Posting';
1529 const url = v.files.length ? `${o.frameBase || FRAME}/posts` : `${o.frameBase || FRAME}/status`;
1530 const body = v.files.length ? { text: v.text, pictures, attachments } : { text: v.text };
1531 try {
1532 const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify(body) });
1533 const j = await r.json().catch(() => ({}));
1534 if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
1535 pics.clear();
1536 api.ctx.note('Posted. It is on your profile and in the Feed.');
1537 if (o.onPosted) o.onPosted(j);
1538 } finally { c.send.textContent = 'Post'; }
1539 return true;
1540 },
1541 });
1542 // Each picture asks what it shows (the Feed shows pictures with their descriptions).
1543 c.form.addEventListener('ovc:file-added', (e) => {
1544 const { file: f, chip } = e.detail;
1545 if (!/^image\//.test(f.type)) return;
1546 chip.classList.add('ovc-image');
1547 const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What it shows (or ✨ AI)';
1548 alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows (optional: AI writes it if you leave it)`);
1549 chip.append(alt); pics.set(f, alt);
1550 });
1551 // ✨ on a picture wrote its description: it goes in the picture's field unless the person wrote one.
1552 c.form.addEventListener('ovc:caption', (e) => {
1553 const field = pics.get(e.detail.file);
1554 if (field && !field.value.trim() && e.detail.alt) field.value = e.detail.alt;
1555 });
1556 c.form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); });
1557 };
1558 if (root.OpenVibeComposer && root.OpenVibeComposer.mount) start();
1559 else if (typeof o.sibling === 'function') {
1560 let script = document.getElementById('ov-composer-script');
1561 if (!script) { script = document.createElement('script'); script.id = 'ov-composer-script'; script.src = o.sibling('ov-composer.js'); script.onload = start; document.head.append(script); }
1562 else script.addEventListener('load', start, { once: true });
1563 }
1564 }
1565 function spark(series) {
1566 const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);
1567 if (v.length < 2) return null;
1568 const max = Math.max(...v, 1), w = 100, hgt = 18;
1569 const pts = v.map((x, i) => `${(i / (v.length - 1)) * w},${hgt - (x / max) * (hgt - 2) - 1}`).join(' ');
1570 const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1571 svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true');
1572 const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
1573 pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'currentColor'); pl.setAttribute('stroke-width', '1.6'); pl.setAttribute('vector-effect', 'non-scaling-stroke');
1574 svg.append(pl);
1575 return svg;
1576 }
1577 function paintToday(box, t) {
1578 const rows = [['people', 'People'], ['streams', 'Streams'], ['posts', 'Posts'], ['videos', 'New videos']]
1579 .map(([k, label]) => [label, t[k] && typeof t[k] === 'object' ? t[k] : { today: t[k] }]).filter(([, v]) => v && Number.isFinite(Number(v.today)));
1580 if (!rows.length) return;
1581 const s = h('div', 'ovh-sec'); s.append(h('span', '', 'Today on OpenVibe')); box.append(s);
1582 const grid = h('div', 'ovh-today');
1583 rows.forEach(([label, v]) => {
1584 const c = h('div', 'ovh-stat');
1585 c.append(h('b', '', num(v.today)), h('small', '', label));
1586 const sp = spark(v.series); if (sp) c.append(sp);
1587 grid.append(c);
1588 });
1589 box.append(grid);
1590 }
1591
1592 // ── Feed: the wall ─────────────────────────────────────────────────────────────────────────────────────────
1593 const LIVE_RE = /^(.{1,60}?) went live(?::\s*(.*))?$/;
1594 function pulseUrl(o, cursor) {
1595 const p = new URLSearchParams({ limit: '20' });
1596 if (signedIn(o)) p.set('for', 'me');
1597 if (cursor) p.set('after', cursor);
1598 return `${o.frameBase || FRAME}/wall?${p}`;
1599 }
1600 function loadPulse(o, cursor) {
1601 return fetch(pulseUrl(o, cursor), { credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {}) })
1602 .then((r) => (r.ok ? r.json() : null)).catch(() => null);
1603 }
1604 function timeline(el, o) {
1605 styles();
1606 el.classList.add('ovh');
1607 el.setAttribute('data-sec', 'wall');
1608 el.textContent = '';
1609 const s = h('div', 'ovh-sec'); s.append(h('span', '', 'On OpenVibe'));
1610 const list = h('div', 'ovh-wall');
1611 for (let k = 0; k < 3; k++) list.append(h('span', 'ovh-skel'));
1612 el.append(s, list);
1613 let next = null;
1614 const more = h('button', 'ovh-all', 'Show more'); more.type = 'button'; more.hidden = true;
1615 el.append(more);
1616 const draw = (j, append) => {
1617 if (!append) list.textContent = '';
1618 const items = (j && Array.isArray(j.items) ? j.items : []).filter((x) => x && typeof x.title === 'string' && safe(x.url));
1619 // Nothing to show: the section goes, not a line saying so (11-09, the owner: "why does it say show more if there's
1620 // nothing new").
1621 if (!items.length && !append) { el.hidden = true; more.hidden = true; return; }
1622 el.hidden = false;
1623 // Went live, back to back: one row of names.
1624 let lives = null;
1625 items.forEach((x) => {
1626 const m = LIVE_RE.exec(x.title.trim());
1627 if (m) {
1628 if (!lives) {
1629 const row = h('div', 'ovh-entry');
1630 const ic = h('span', 'ovh-ic'); ic.append(siteIcon('live', 26));
1631 const txt = h('span', 'ovh-entry-txt'); txt.append(h('b', '', 'Went live recently'));
1632 lives = h('span', 'ovh-lives'); txt.append(lives, h('small', '', ago(x.occurred_at || x.at)));
1633 row.append(ic, txt); list.append(row);
1634 }
1635 if (lives.childNodes.length < 8) { const a = h('a', '', m[1]); a.href = safe(x.url); a.title = m[2] || ''; lives.append(a); }
1636 return;
1637 }
1638 lives = null;
1639 const a = h('a', 'ovh-entry'); a.href = safe(x.url);
1640 const svc = String((x.ref && x.ref.service) || x.service || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 30);
1641 const ic = h('span', 'ovh-ic'); ic.append(siteIcon(svc || 'ov', 26));
1642 const txt = h('span', 'ovh-entry-txt');
1643 txt.append(h('b', '', x.title.slice(0, 220)));
1644 const meta = [ago(x.occurred_at || x.at), x.via ? `via ${String(x.via).slice(0, 40)}` : '', x.origin === 'ai' ? 'made by AI' : ''].filter(Boolean).join(' · ');
1645 if (meta) txt.append(h('small', '', meta));
1646 a.append(ic, txt); list.append(a);
1647 });
1648 next = j && j.next_cursor ? j.next_cursor : null;
1649 more.hidden = !next;
1650 if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* */ } }
1651 };
1652 o.pulseReady.then((j) => { if (el.isConnected) draw(j, false); });
1653 more.addEventListener('click', async () => { more.disabled = true; const j = await loadPulse(o, next); more.disabled = false; if (j) draw(j, true); });
1654 }
1655
1656
1657 // ── Feed v2: a real feed (owner, 11-09: "a real feed… you scroll through easy content… sections or a filter system…
1658 // clean, intuitive"). Community's GET /api/v1/feed (plan "Feed v2"): one stream of cards by kind, chips on top, more as
1659 // you near the end. When the API is not there yet, the pulse wall above stands (timeline()).
1660 // Following needs a signed-in person (the feed reads their Network follows); the rest are the feed's kinds (Community
1661 // #310: wiki pages, quest badges, busy chat rooms, calendar events and the people who joined, beside the media kinds).
1662 const KINDS = [['', 'For you'], ['following', 'Following'], ['live', 'Live'], ['video', 'Videos'], ['clip', 'Clips'], ['pic', 'Pics'], ['post', 'Posts'], ['rundown', 'Rundown'], ['meme', 'Memes'], ['game', 'Games'], ['chat', 'Chat'], ['event', 'Events'], ['work', 'Jobs'], ['deal', 'Deals'], ['report', 'Stats'], ['wiki', 'Wiki'], ['quest', 'Quests'], ['update', 'Updates']];
1663 // 4.241.0: each kind's glyph on its chip.
1664 const KIND_GL = { '': 'spark', following: 'people', live: 'live', video: 'video', clip: 'scissors', pic: 'image', post: 'pencil', rundown: 'page', meme: 'smile', game: 'game', chat: 'chat', event: 'calendar', work: 'briefcase', deal: 'tag', report: 'chart', wiki: 'book', quest: 'trophy', update: 'bell' };
1665 function feedUrl(o, kind, cursor) {
1666 const p = new URLSearchParams({ limit: '12' });
1667 if (kind === 'following') p.set('following', '1'); else if (kind) p.set('kind', kind);
1668 if (cursor) p.set('cursor', cursor);
1669 return `${o.frameBase || FRAME}/feed?${p}`;
1670 }
1671 function loadFeed(o, kind, cursor) {
1672 return fetch(feedUrl(o, kind, cursor), { credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {}) })
1673 .then((r) => (r.ok ? r.json() : null)).then((j) => (j && Array.isArray(j.items) ? j : null)).catch(() => null);
1674 }
1675 // A preview is a video that starts by itself: Calm and Off both leave it to a tap (as does a data saver).
1676 const still = () => { try { const m = document.documentElement.getAttribute('data-ov-motion'); return m === 'off' || m === 'reduced' || (!m && matchMedia('(prefers-reduced-motion: reduce)').matches) || !!(navigator.connection && navigator.connection.saveData); } catch { return false; } };
1677 const count = (n) => { const v = Number(n) || 0; return v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v); };
1678 let player = null; // one IntersectionObserver plays the preview in view and pauses the rest
1679 function watchPreview(box, c) {
1680 const media = c.media || {};
1681 const preview = safe(media.preview);
1682 if (!preview || still() || typeof IntersectionObserver !== 'function') return;
1683 player = player || new IntersectionObserver((es) => es.forEach((x) => {
1684 const v = x.target.querySelector('video');
1685 if (x.isIntersecting && x.intersectionRatio >= 0.6) {
1686 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 = 'ovfd-video'; x.target.append(nv); nv.play().catch(() => {}); }
1687 else v.play().catch(() => {});
1688 } else if (v) v.pause();
1689 }), { threshold: [0, 0.6] });
1690 box.setAttribute('data-preview', preview);
1691 player.observe(box);
1692 }
1693 const glyph = (name) => { const g = svgIcon(name); g.setAttribute('class', 'ovfd-gl'); return g; };
1694 const when = (iso) => {
1695 const t = Date.parse(iso || ''); if (!t) return '';
1696 const d = new Date(t);
1697 return d.toDateString() === new Date().toDateString() ? ago(iso) : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
1698 };
1699 /** "Starts 2026-11-12 18:00 UTC" (Today's line when an event has no description) in the reader's own time. */
1700 const startsLocal = (text) => {
1701 const m = /^Starts (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}) UTC$/.exec(String(text || ''));
1702 if (!m) return text;
1703 const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]));
1704 return `Starts ${d.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' })}, ${d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`;
1705 };
1706 const tidy = (v) => String(v || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
1707 const cap = (v) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v);
1708 /** A face: the person's picture, else their initial; the site's icon on its hue when nobody is behind the card. */
1709 function faceOf(c, cls) {
1710 const face = h('span', cls || 'ovfd-face');
1711 const av = c.actor && safe(c.actor.avatar);
1712 if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; img.onerror = () => { img.remove(); face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase(); }; face.append(img); }
1713 else if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
1714 else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
1715 return face;
1716 }
1717 /**
1718 * Something that happened, as one row (11-09, the owner: "all kinds of events and even global chat… everything date
1719 * by date"): who or what on the left, what happened in words, then the kind, the site and when. A thing to look at
1720 * (a video, a picture, a post) stays a card; a busy room, people joining, a badge, a calendar entry, a wiki edit
1721 * and a stream that is over read better as a line.
1722 */
1723 function actRow(c, { tile, title, what, icon, text, site = true }) {
1724 const a = h('a', `ovfd-act ovfd-k-${String(c.kind || '').replace(/[^a-z]/g, '')}`); a.href = safe(c.url);
1725 const body = h('span', 'ovfd-act-txt');
1726 body.append(h('b', '', String(title || c.title || '').slice(0, 200)));
1727 const meta = h('small', '');
1728 if (icon) meta.append(glyph(icon));
1729 meta.append(document.createTextNode(` ${[what, site ? (c.site_name || c.site) : '', when(c.at)].filter(Boolean).join(' · ')}`));
1730 body.append(meta);
1731 if (text) body.append(h('span', 'ovfd-act-more', String(text).slice(0, 200)));
1732 a.append(tile, body);
1733 return a;
1734 }
1735 function activity(c) {
1736 const kind = String(c.kind || '');
1737 const title = String(c.title || '');
1738 const who = String((c.actor && c.actor.name) || '').slice(0, 60);
1739 if (kind === 'member') {
1740 const n = (/^(\d[\d,]*)\s/.exec(title) || [])[1];
1741 const tile = h('span', 'ovfd-tile ovfd-tile-num'); tile.append(h('b', '', n ? `+${n}` : '+'), glyph('people'));
1742 return actRow(c, { tile, what: 'New on OpenVibe', icon: 'people', text: c.text, site: false });
1743 }
1744 if (kind === 'chat') {
1745 const m = /^#(\S{1,64}):\s*(\d+) messages this hour$/.exec(title);
1746 return actRow(c, { tile: faceOf({ site: c.site || 'chat' }), title: m ? `#${m[1]} is busy` : title, what: m ? `${m[2]} messages this hour` : 'Chat', icon: 'chat', text: c.text });
1747 }
1748 if (kind === 'event') {
1749 const m = /^(Live|Event):\s*(.+)$/.exec(title);
1750 const st = /^Starts (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}) UTC$/.exec(String(c.text || ''));
1751 let tile;
1752 if (st) {
1753 const d = new Date(Date.UTC(+st[1], +st[2] - 1, +st[3], +st[4], +st[5]));
1754 tile = h('span', 'ovfd-tile ovfd-tile-date');
1755 tile.append(h('small', '', d.toLocaleDateString('en-US', { month: 'short' })), h('b', '', String(d.getDate())));
1756 } else tile = faceOf(c.actor ? c : { site: c.site || 'today' });
1757 return actRow(c, { tile, title: m ? m[2] : title, what: m && m[1] === 'Live' ? 'Stream on the calendar' : 'Event', icon: 'calendar', text: startsLocal(c.text), site: false });
1758 }
1759 if (kind === 'quest') {
1760 const m = /^Finished a quest:\s*(.+)$/.exec(title);
1761 const tile = h('span', 'ovfd-tile-wrap'); tile.append(faceOf(c), h('span', 'ovfd-badge')); tile.lastChild.append(glyph('trophy'));
1762 return actRow(c, { tile, title: m ? m[1] : title, what: who ? `${who} finished a quest` : 'Quest finished', icon: 'trophy' });
1763 }
1764 if (kind === 'wiki') {
1765 const m = /^Wiki:\s*([^/]{1,40}?)\s*\/\s*(.+)$/.exec(title);
1766 return actRow(c, { tile: faceOf(c), title: m ? cap(tidy(m[2])) : title, what: m ? `${who ? `${who} · ` : ''}Wiki page in ${tidy(m[1])}` : `${who ? `${who} · ` : ''}Wiki page`, icon: 'page', text: c.text, site: false });
1767 }
1768 if (kind === 'update') return actRow(c, { tile: faceOf({ site: c.site || 'ov' }), what: 'Update', text: c.text });
1769 if (kind === 'stream') {
1770 const t = title.replace(/^.{1,60}? went live:?\s*/i, '');
1771 return actRow(c, { tile: faceOf(c), title: t || `${who} streamed`, what: who ? `${who} streamed` : 'Stream', icon: 'play' });
1772 }
1773 return null;
1774 }
1775 const ACTS = new Set(['member', 'chat', 'event', 'quest', 'wiki', 'stream', 'update']);
1776 function countsRow(n) {
1777 const parts = [['views', 'eye', 'views'], ['watchers', 'eye', 'watched'], ['likes', 'heart', 'likes'], ['comments', 'reply', 'replies']]
1778 .filter(([k]) => Number(n && n[k]) > 0);
1779 if (!parts.length) return null;
1780 const row = h('span', 'ovfd-counts');
1781 parts.forEach(([k, g, word]) => { const s = h('span', ''); s.setAttribute('aria-label', `${num(n[k])} ${word}`); s.title = `${num(n[k])} ${word}`; s.append(glyph(g), document.createTextNode(count(n[k]))); row.append(s); });
1782 return row;
1783 }
1784 /** A length as people read it: 5203 → 1:26:43, 95 → 1:35; a length already written ("12:04") stays. */
1785 function lenOf(v) {
1786 const t = String(v == null ? '' : v).trim();
1787 if (!/^\d+(\.\d+)?$/.test(t)) return t.slice(0, 10);
1788 const n = Math.round(Number(t)); const hh = Math.floor(n / 3600), mm = Math.floor((n % 3600) / 60), ss = n % 60;
1789 return hh ? `${hh}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}` : `${mm}:${String(ss).padStart(2, '0')}`;
1790 }
1791 /** The Rundown's episodes (Live writes them from what streamers said): Network's kind `rundown`, or the older items
1792 * that came as "System" with a "Rundown:" title. */
1793 function isRundown(c) { return c.kind === 'rundown' || (/^Rundown:\s*/i.test(String(c.title || '')) && (!c.actor || /^system$/i.test(String(c.actor.name || '')))); }
1794 /** The person behind a card (4.241.0): their name wears its effect (items.css; Inventory by subject where the page
1795 * has items.js), their level and @handle beside it. "System" is nobody: the site speaks for itself. */
1796 const personOf = (c) => (c.actor && c.actor.name && !/^system$/i.test(String(c.actor.name)) ? c.actor : null);
1797 const FX = /^name-fx-[a-z]{2,24}$/;
1798 let itemsCss = false;
1799 function nameOf(c, actor, o) {
1800 const name = h('b', 'ovfd-name', String(actor.name || '').slice(0, 60));
1801 if (c.subject && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(c.subject))) name.setAttribute('data-ov-subject', String(c.subject));
1802 if (actor.fx && FX.test(String(actor.fx))) {
1803 name.classList.add('ov-fx', String(actor.fx));
1804 if (!itemsCss && typeof document !== 'undefined' && o && typeof o.sibling === 'function' && !document.querySelector('link[data-ov-items-css]')) {
1805 itemsCss = true;
1806 const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = o.sibling('items.css'); l.setAttribute('data-ov-items-css', ''); document.head.append(l);
1807 }
1808 }
1809 const line = h('span', 'ovfd-nameline'); line.append(name);
1810 const lv = Number(actor.level);
1811 if (Number.isInteger(lv) && lv > 0) { const b = h('span', 'ovfd-lvl', `Lv ${lv}`); b.setAttribute('aria-label', `level ${lv}`); line.append(b); }
1812 return line;
1813 }
1814 const KIND_WORD = { post: 'Post', pic: 'A picture', clip: 'Clip', video: 'Video', live: 'Live', meme: 'Meme', deal: 'Deals', work: 'Jobs', report: 'Report', game: 'Game', wiki: 'Wiki', news: 'News', poll: 'Poll' };
1815 /** Under a post, a picture, a clip or a video: reply (with the count), likes, and share. Never inside the card's link. */
1816 function actsOf(c, url) {
1817 const n = c.counts || {};
1818 const row = h('div', 'ovfd-acts');
1819 const reply = h('a', 'ovfd-act-b'); reply.href = `${url}#comments`; reply.append(glyph('reply'), h('span', '', Number(n.comments) > 0 ? count(n.comments) : 'Comment'));
1820 reply.setAttribute('aria-label', Number(n.comments) > 0 ? `${num(n.comments)} comments` : 'Comment');
1821 row.append(reply);
1822 if (Number(n.likes) > 0) { const lk = h('span', 'ovfd-act-b is-static'); lk.append(glyph('heart'), h('span', '', count(n.likes))); lk.setAttribute('aria-label', `${num(n.likes)} likes`); row.append(lk); }
1823 const sh = h('button', 'ovfd-act-b'); sh.type = 'button'; sh.append(glyph('share'), h('span', '', 'Share'));
1824 sh.setAttribute('aria-label', 'Share');
1825 sh.addEventListener('click', async () => {
1826 const data = { title: String(c.title || '').slice(0, 120), url };
1827 try { if (navigator.share) { await navigator.share(data); return; } } catch { return; }
1828 try { await navigator.clipboard.writeText(url); sh.lastChild.textContent = 'Link copied'; setTimeout(() => { sh.lastChild.textContent = 'Share'; }, 1800); } catch { /* */ }
1829 });
1830 row.append(sh);
1831 return row;
1832 }
1833 const SOCIAL = new Set(['post', 'pic', 'clip', 'video', 'meme', 'rundown']);
1834 function feedCard(c, o) {
1835 const kind = isRundown(c) ? 'rundown' : String(c.kind || 'post');
1836 const url = safe(c.url);
1837 if (!url) return null;
1838 if (ACTS.has(kind) && !(c.media && safe(c.media.thumb))) return activity(c);
1839 if (kind === 'update') {
1840 const a = h('a', 'ovfd-update'); a.href = url;
1841 const ic = h('span', 'ovh-ic'); ic.append(siteIcon(c.site || 'ov', 20));
1842 const t = h('span', 'ovfd-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(' · ')));
1843 a.append(ic, t); return a;
1844 }
1845 const a = h('a', `ovfd-card ovfd-k-${kind.replace(/[^a-z]/g, '')}`); a.href = url;
1846 if (kind === 'rundown') a.setAttribute('aria-label', `The Rundown: ${String(c.title || '').replace(/^Rundown:\s*/i, '').slice(0, 160)}`);
1847 const head = h('span', 'ovfd-head');
1848 const face = h('span', 'ovfd-face');
1849 const av = personOf(c) && safe(c.actor.avatar);
1850 // No person behind it (the network's trending pictures, a report): the site's icon, not a "?".
1851 if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; img.onerror = () => { img.remove(); face.textContent = String((c.actor && c.actor.name) || '?').charAt(0).toUpperCase(); }; face.append(img); }
1852 else if (!personOf(c)) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
1853 else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
1854 const who = h('span', 'ovfd-who');
1855 const meta = h('small', '');
1856 if (kind === 'rundown') {
1857 // 4.223.0 (the owner, 11-12: "the rundown should look visually distinct versus the live feeds"): its own face and
1858 // name, whom it is about, and its picture marked as the Rundown's.
1859 face.textContent = ''; face.className = 'ovfd-face is-rundown'; face.append(glyph('page'));
1860 // feed.item@1 (Contracts 0.415.0): `subject` is a person's id, so whom it is about is its actor's name.
1861 const about = (c.subject && typeof c.subject === 'object' && c.subject.name ? String(c.subject.name) : '') || (c.actor && c.actor.name && !/^system$/i.test(String(c.actor.name)) ? String(c.actor.name) : '')
1862 || (/^Rundown:\s*([^:]{1,40}?)\s*(?:prep|:)/i.exec(String(c.title || '')) || [])[1] || '';
1863 who.append(h('b', '', 'The Rundown'));
1864 meta.append(siteIcon('live', 18), document.createTextNode(` ${[about ? `about ${about}` : 'from today’s streams', when(c.at)].filter(Boolean).join(' · ')}`));
1865 a.classList.add('ovfd-k-rundown');
1866 } else if (personOf(c)) {
1867 const p = personOf(c);
1868 who.append(nameOf(c, p, o));
1869 meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[p.handle ? `@${String(p.handle).slice(0, 40)}` : '', c.site_name || '', when(c.at)].filter(Boolean).join(' · ')}`));
1870 } else {
1871 // Nobody behind it (a site's own card, or a picture whose author the feed does not know yet): the site's name,
1872 // then what it is and when, never "Community · Community".
1873 who.append(h('b', '', String(c.label && !/^system$/i.test(String(c.label)) ? c.label : c.site_name || '').slice(0, 60)));
1874 // A report says its sentence beside its number (statOf); a member card says it here.
1875 const what = kind === 'member' ? String(c.text || '').slice(0, 60) : kind === 'report' ? '' : KIND_WORD[kind] || '';
1876 meta.append(siteIcon(c.site || 'ov', 18), document.createTextNode(` ${[what, when(c.at)].filter(Boolean).join(' · ')}`));
1877 }
1878 who.append(meta);
1879 head.append(face, who);
1880 if (kind === 'live' && c.live_now) { a.classList.add('is-on'); head.append(h('span', 'ovfd-livechip', 'LIVE')); }
1881 a.append(head);
1882 const media = c.media || {};
1883 const thumb = safe(media.thumb);
1884 if (thumb && /^https:/.test(thumb)) {
1885 const box = h('span', 'ovfd-media');
1886 const r = Number(media.ratio) > 0 ? Math.max(0.8, Math.min(1.91, Number(media.ratio))) : (kind === 'pic' || kind === 'meme' ? 1 : 16 / 9);
1887 box.style.aspectRatio = String(r);
1888 const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = thumb; box.append(img);
1889 const len = media.length || c.duration_s;
1890 if (len) box.append(h('span', 'ovfd-len', lenOf(len)));
1891 if (kind === 'rundown') { const rib = h('span', 'ovfd-ribbon'); rib.append(glyph('page'), document.createTextNode('Rundown')); box.append(rib); }
1892 if (kind === 'live' && c.counts && c.counts.viewers) box.append(h('span', 'ovfd-len ovfd-viewers', `${count(c.counts.viewers)} watching`));
1893 a.append(box);
1894 if (kind === 'clip' || kind === 'video' || kind === 'live') watchPreview(box, c);
1895 }
1896 let title = String(c.title || '');
1897 if (kind === 'post' && /^Status:\s*/.test(title) && c.text && String(c.text).startsWith(title.replace(/^Status:\s*/, '').replace(/…$/, '').slice(0, 40))) title = '';
1898 if (kind === 'live') title = title.replace(/^.{1,60}? went live:?\s*/i, '') || title;
1899 if (kind === 'rundown') title = title.replace(/^Rundown:\s*/i, '') || title;
1900 if (title) a.append(h('b', 'ovfd-title', title.slice(0, 200)));
1901 if (kind === 'report') { const st = statOf(c); if (st) a.append(st); }
1902 // A card without a person says its text in the line under its name already (not twice).
1903 const textInMeta = !personOf(c) && kind === 'member' && !!c.text;
1904 if (c.text && (kind === 'post' || !thumb) && kind !== 'report' && !textInMeta) a.append(h('p', 'ovfd-text', String(c.text).slice(0, 500)));
1905 const many = groupList(c);
1906 if (many) a.append(many);
1907 // A run of deals says its count in its own link ("See all 37 deals"), not again under it.
1908 const people = many && kind === 'deal' ? null : peopleOf(kind, c);
1909 const counts = countsRow(c.counts);
1910 if (SOCIAL.has(kind)) {
1911 // 4.241.0: a post reads like a post: the card, then reply, likes and share under it (outside its link).
1912 if (people) { const row = h('span', 'ovfd-counts'); row.append(people); a.append(row); }
1913 else if (counts && Number((c.counts || {}).views) > 0) { const row = h('span', 'ovfd-counts'); const v = counts.querySelector('[aria-label$="views"], [aria-label$="watched"]'); if (v) { row.append(v); a.append(row); } }
1914 const post = h('article', `ovfd-post ovfd-k-${kind.replace(/[^a-z]/g, '')}`);
1915 post.append(a, actsOf(c, url));
1916 return post;
1917 }
1918 if (people || counts) { const row = counts || h('span', 'ovfd-counts'); if (people) row.prepend(people); a.append(row); }
1919 return a;
1920 }
1921 /** 4.226.0, the one feed's new kinds (feed.item@1). A report is its number, large, with which way it moved. */
1922 function statOf(c) {
1923 const n = c.counts && Number(c.counts.people);
1924 const t = c.trend && typeof c.trend === 'object' ? c.trend : null;
1925 if (!Number.isFinite(n) && !t) return null;
1926 const box = h('span', 'ovfd-stat');
1927 if (Number.isFinite(n)) box.append(h('b', 'ovfd-stat-n', num(n)));
1928 if (c.text) box.append(h('small', 'ovfd-stat-l', String(c.text).slice(0, 120)));
1929 if (t && ['up', 'down', 'flat'].includes(t.direction)) {
1930 const pct = Number.isFinite(Number(t.pct)) ? `${Math.round(Number(t.pct))}%` : '';
1931 const was = Number.isFinite(Number(t.previous)) ? `was ${num(Number(t.previous))}` : '';
1932 const pill = h('span', `ovfd-trend is-${t.direction}`, [t.direction === 'up' ? '▲' : t.direction === 'down' ? '▼' : '=', pct].filter(Boolean).join(' '));
1933 pill.setAttribute('aria-label', `${t.direction === 'flat' ? 'about the same' : `${t.direction} ${pct}`}${was ? `, ${was}` : ''}`);
1934 box.append(pill);
1935 if (was) box.append(h('small', 'ovfd-stat-was', was));
1936 }
1937 return box;
1938 }
1939 /** A folded run of the same kind (new jobs, new deals): its first three, each to its own page. */
1940 function groupList(c) {
1941 const g = c.group && Array.isArray(c.group.items) ? c.group.items : [];
1942 if (g.length < 2) return null;
1943 const list = h('span', 'ovfd-group');
1944 const pic = (x) => { const th = x && x.media && safe(x.media.thumb); return th && /^https:/.test(th) ? th : ''; };
1945 const anyPic = g.slice(0, 3).some((x) => !!pic(x));
1946 const money = (p) => { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: p.currency }).format(Number(p.amount)); } catch { return `${Number(p.amount).toFixed(2)} ${p.currency}`; } };
1947 const isDeal = c.kind === 'deal';
1948 if (isDeal) list.classList.add('is-deals');
1949 g.slice(0, isDeal ? 4 : 3).forEach((x) => {
1950 const row = h('span', 'ovfd-group-i');
1951 const th = pic(x);
1952 // Rows line up: where one has a picture, the others hold its place (a deal's: its store's first letter).
1953 if (th) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = th; row.append(img); }
1954 else if (isDeal) { const ph = h('i', 'ovfd-group-ph is-deal'); ph.append(glyph('tag')); row.append(ph); }
1955 else if (anyPic) row.append(h('i', 'ovfd-group-ph'));
1956 const t = h('span', 'ovfd-group-t'); t.append(h('span', 'ovfd-group-n', String(x.title || '').replace(/\s+[—-]\s+[^—-]{2,40}$/, '').slice(0, 120)));
1957 if (isDeal) {
1958 const line = h('span', 'ovfd-price');
1959 const p = x.price && Number.isFinite(Number(x.price.amount)) && /^[A-Z]{3}$/.test(String(x.price.currency || '')) ? x.price : null;
1960 if (p) line.append(h('b', '', Number(p.amount) === 0 ? 'Free' : money(p)));
1961 if (p && Number(x.was) > Number(p.amount)) { line.append(h('s', '', money({ amount: x.was, currency: p.currency }))); line.append(h('em', '', `-${Math.round((1 - Number(p.amount) / Number(x.was)) * 100)}%`)); }
1962 const store = x.store || (/\s[—-]\s([^—-]{2,40})$/.exec(String(x.title || '')) || [])[1];
1963 if (store) line.append(h('small', '', String(store).slice(0, 40)));
1964 if (line.childNodes.length) t.append(line);
1965 }
1966 row.append(t);
1967 list.append(row);
1968 });
1969 const more = Number(c.group.count) - Math.min(c.kind === 'deal' ? 4 : 3, g.length);
1970 if (more > 0) list.append(h('small', 'ovfd-group-more', `and ${num(more)} more`));
1971 if (more > 0 && isDeal) list.lastChild.textContent = `See all ${num(Number(c.group.count))} deals`;
1972 return list;
1973 }
1974 /** What `counts.people` means for a kind: jobs, voters, people online; nothing where it means nothing. */
1975 function peopleOf(kind, c) {
1976 const n = c.counts && Number(c.counts.people);
1977 if (!Number.isFinite(n) || n <= 0 || kind === 'report') return null;
1978 const word = kind === 'work' ? (n === 1 ? 'job' : 'jobs') : kind === 'poll' ? 'voted' : kind === 'deal' ? (n === 1 ? 'deal' : 'deals') : kind === 'member' ? 'joined' : 'people';
1979 const sp = h('span', ''); sp.setAttribute('aria-label', `${num(n)} ${word}`); sp.title = `${num(n)} ${word}`;
1980 sp.append(glyph(kind === 'work' ? 'briefcase' : kind === 'poll' ? 'check' : 'user'), document.createTextNode(`${count(n)} ${word}`));
1981 return sp;
1982 }
1983 /** Several streamers who went live (and are not live now), as one card: their faces, then each name to their channel. */
1984 function livesCard(items) {
1985 const box = h('div', 'ovfd-card ovfd-lives');
1986 const head = h('div', 'ovfd-head');
1987 const faces = h('span', 'ovfd-faces');
1988 items.slice(0, 4).forEach((x) => {
1989 const f = h('span', 'ovfd-face');
1990 const av = x.actor && safe(x.actor.avatar);
1991 if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; f.append(img); } else f.textContent = String((x.actor && x.actor.name) || '?').charAt(0).toUpperCase();
1992 faces.append(f);
1993 });
1994 const names = items.map((x) => String((x.actor && x.actor.name) || '').slice(0, 40)).filter(Boolean);
1995 const who = h('span', 'ovfd-who');
1996 who.append(h('b', '', names.length > 2 ? `${names.slice(0, 2).join(', ')} and ${names.length - 2} more went live` : `${names.join(' and ')} went live`));
1997 const meta = h('small'); meta.append(siteIcon('live', 14), document.createTextNode(` Live · ${[when(items[items.length - 1].at), items.length > 1 ? when(items[0].at) : ''].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' – ')}`));
1998 who.append(meta);
1999 head.append(faces, who);
2000 const row = h('div', 'ovfd-lives-row');
2001 items.forEach((x) => {
2002 const l = h('a', 'ovfd-pill'); l.href = safe(x.url);
2003 const t = String(x.title || '').replace(/^.*?went live:\s*/i, '');
2004 l.append(h('b', '', String((x.actor && x.actor.name) || '').slice(0, 40)));
2005 if (t) l.append(h('span', '', t.slice(0, 60)));
2006 row.append(l);
2007 });
2008 box.append(head, row);
2009 return box;
2010 }
2011 function dayLine(t) {
2012 const d = new Date(t); const now = new Date();
2013 const start = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
2014 const diff = Math.round((start(now) - start(d)) / 86400000);
2015 const label = diff <= 0 ? 'Today' : diff === 1 ? 'Yesterday' : diff < 7 ? d.toLocaleDateString('en-US', { weekday: 'long' })
2016 : d.toLocaleDateString('en-US', d.getFullYear() === now.getFullYear() ? { day: 'numeric', month: 'long' } : { day: 'numeric', month: 'long', year: 'numeric' });
2017 const line = h('div', 'ovfd-day'); line.setAttribute('role', 'separator'); line.append(h('span', '', label));
2018 return line;
2019 }
2020 function feed(el, o) {
2021 styles(); feedStyles();
2022 el.classList.add('ovh', 'ovfd');
2023 el.setAttribute('data-sec', 'wall');
2024 el.textContent = '';
2025 const examples = h('div', 'ovfd-examples'); examples.setAttribute('aria-label', 'Example feed searches');
2026 const resultHead = h('div', 'ovfd-results'); resultHead.hidden = true;
2027 const chips = h('div', 'ovfd-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');
2028 const list = h('div', 'ovfd-list');
2029 const sentinel = h('div', 'ovfd-more');
2030 el.append(examples, resultHead, chips, list, sentinel);
2031 let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';
2032 // 4.229.0: a card already on the list is never drawn again (the owner, 11-12: "112 new deals" three times, one
2033 // per page of a Deals poll; Network now names a burst once, and this keeps any repeat off the screen).
2034 const shownIds = new Set();
2035 // Names wear what their people wear (OpenVibe.Inventory through items.js, where the page has it).
2036 const decorate = () => { try { if (root.OpenVibeItems && typeof root.OpenVibeItems.decorate === 'function') root.OpenVibeItems.decorate(list); } catch { /* names stay plain */ } };
2037 let query = '', searchSeq = 0;
2038 const loaded = [];
2039 const feedBase = `${o.frameBase || FRAME}/feed`;
2040 const headers = () => Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {});
2041 const fallbackExamples = ["Who's live", 'New photos', 'Clips from today', 'Videos this week'];
2042 const showExamples = (values) => {
2043 examples.textContent = '';
2044 [...new Set([...values, ...fallbackExamples])].slice(0, 6).forEach((value) => {
2045 const b = h('button', 'ovfd-ex', value); b.type = 'button';
2046 b.addEventListener('click', () => { if (o.onSearch) o.onSearch(value); });
2047 examples.append(b);
2048 });
2049 };
2050 showExamples(fallbackExamples);
2051 fetch(`${feedBase}/examples`, { credentials: 'omit', headers: headers() }).then((r) => r.ok ? r.json() : null).then((j) => {
2052 const values = j && Array.isArray(j.examples) ? j.examples.map((x) => x && x.text).filter((x) => typeof x === 'string' && x.trim()) : [];
2053 if (values.length) showExamples(values);
2054 }).catch(() => {});
2055 const matches = (x, q) => {
2056 const words = q.toLowerCase().replace(/^(new |who's |from |this )/g, '').split(/\s+/).filter((w) => !['the', 'today', 'week'].includes(w));
2057 const hay = [x.title, x.text, x.kind, x.site, x.site_name, x.actor && x.actor.name].join(' ').toLowerCase();
2058 if (q.toLowerCase().includes('today') && String(x.at || '').slice(0, 10) !== new Date().toISOString().slice(0, 10)) return false;
2059 if (q.toLowerCase().includes('this week') && Date.parse(x.at || '') < Date.now() - 7 * 864e5) return false;
2060 return words.filter((w) => !['from', 'this', 'week'].includes(w)).every((w) => {
2061 const word = w.replace(/s$/, '');
2062 return hay.includes(word) || word === 'photo' && x.kind === 'pic';
2063 });
2064 };
2065 async function search(q) {
2066 query = String(q || '').trim(); const current = ++searchSeq;
2067 resultHead.textContent = '';
2068 resultHead.hidden = !query; chips.hidden = !!query; sentinel.hidden = !!query;
2069 examples.hidden = !!query;
2070 if (!query) { cursor = null; done = false; busy = false; more(true); return; }
2071 list.textContent = '';
2072 resultHead.append(h('span', '', 'Searching…'));
2073 let found = null;
2074 try {
2075 const r = await fetch(`${feedBase}/search?q=${encodeURIComponent(query)}`, { credentials: 'omit', headers: headers() });
2076 if (r.ok) { const j = await r.json(); if (Array.isArray(j.items)) found = j.items; }
2077 } catch { /* the loaded cards remain searchable */ }
2078 if (current !== searchSeq) return;
2079 if (!found) found = loaded.filter((x) => matches(x, query));
2080 resultHead.textContent = '';
2081 resultHead.append(h('span', '', `${found.length} result${found.length === 1 ? '' : 's'}`));
2082 const clear = h('button', 'ovk-btn ovk-btn--quiet', '×'); clear.type = 'button'; clear.setAttribute('aria-label', 'Clear feed search');
2083 clear.addEventListener('click', () => { if (o.onSearch) o.onSearch(''); }); resultHead.append(clear);
2084 list.textContent = '';
2085 found.forEach((x) => { const card = feedCard(x, o); if (card) list.append(card); }); decorate();
2086 if (typeof o.mountIcons === 'function') o.mountIcons(list);
2087 }
2088 const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };
2089 const clearSkels = () => list.querySelectorAll('.ovfd-skel').forEach((x) => x.remove());
2090 async function more(first) {
2091 if (busy || done) return;
2092 busy = true; const my = ++seq;
2093 if (first) { list.textContent = ''; shownIds.clear(); skeletons(); } else skeletons();
2094 const j = await loadFeed(o, kind, cursor);
2095 if (my !== seq) return;
2096 clearSkels();
2097 if (!j) {
2098 busy = false;
2099 if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall stands
2100 done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
2101 }
2102 if (first && o.feedLive) o.feedLive();
2103 if (!kind) { if (first) loaded.length = 0; loaded.push(...j.items); }
2104 if (query) { busy = false; search(query); return; }
2105 // Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).
2106 const past = (x) => x && (x.kind === 'stream' || (x.kind === 'live' && !x.live_now)) && safe(x.url);
2107 let run = [];
2108 const flush = () => { if (run.length >= 2) list.append(livesCard(run)); else run.forEach((x) => { const c = feedCard(x, o); if (c) list.append(c); }); run = []; decorate(); };
2109 // Date by date (the owner, 11-09): a quiet day line where the day changes. The network's trending cards
2110 // (no time of their own) sit between, under the day they were woven into.
2111 const day = (x) => { const t = Date.parse(String((x && x.at) || '')); return Number.isFinite(t) ? new Date(t).toDateString() : ''; };
2112 const mark = (x) => { const d = day(x); if (!d || d === lastDay) return; flush(); lastDay = d; list.append(dayLine(Date.parse(String(x.at)))); };
2113 j.items.filter((x) => x && x.id && !shownIds.has(x.id) && shownIds.add(x.id)).forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x, o); if (c) list.append(c); });
2114 flush();
2115 if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
2116 cursor = j.next_cursor || null;
2117 done = !cursor;
2118 busy = false;
2119 sentinel.textContent = '';
2120 if (done) sentinel.append(h('p', 'ovh-note ovfd-caught', list.querySelector('.ovfd-card,.ovfd-act,.ovfd-update') ? "You're all caught up."
2121 : (kind === 'following' ? 'Nothing yet from the people you follow. Follow someone on their profile and what they share shows here.' : 'Nothing here yet.')));
2122 }
2123 KINDS.forEach(([k, label]) => {
2124 if (k === 'following' && !signedIn(o)) return;
2125 const b = h('button', 'ovfd-chip'); b.type = 'button'; b.setAttribute('role', 'tab'); b.setAttribute('aria-selected', k === kind ? 'true' : 'false');
2126 { const g = svgIcon(KIND_GL[k] || 'spark'); g.setAttribute('class', 'ovfd-chip-g'); b.append(g, h('span', '', label)); }
2127 b.addEventListener('click', () => {
2128 if (k === kind) return;
2129 kind = k; cursor = null; done = false; busy = false; lastDay = '';
2130 chips.querySelectorAll('.ovfd-chip').forEach((x) => x.setAttribute('aria-selected', x === b ? 'true' : 'false'));
2131 more(true);
2132 });
2133 chips.append(b);
2134 });
2135 if (typeof IntersectionObserver === 'function') {
2136 const io = new IntersectionObserver((es) => { if (es.some((x) => x.isIntersecting)) more(false); }, { root: el.closest('.ovs-list') || null, rootMargin: '600px 0px' });
2137 io.observe(sentinel);
2138 }
2139 more(true);
2140 return { update() {}, search, get query() { return query; } };
2141 }
2142 function feedStyles() {
2143 if (document.getElementById('ovfd-styles')) return;
2144 const st = document.createElement('style'); st.id = 'ovfd-styles';
2145 st.textContent = `
2146.ovfd-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)}
2147.ovfd-chips::-webkit-scrollbar{display:none}
2148.ovfd-examples{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:2px 4px 6px;margin:0 -4px}.ovfd-examples::-webkit-scrollbar{display:none}.ovfd-examples[hidden]{display:none}
2149.ovfd-ex{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;border-radius:999px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:13px;font-weight:650;cursor:pointer;white-space:nowrap;transition:background .15s}
2150.ovfd-ex::before{content:"";width:12px;height:12px;flex:none;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;opacity:.8}
2151.ovfd-ex:hover,.ovfd-ex:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent)}
2152.ovfd-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}
2153.ovfd-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}
2154.ovfd-list{display:grid;gap:10px;padding:4px 0}
2155@media (min-width:861px){.ovfd{max-width:720px;margin-inline:auto}}
2156.ovfd-stat{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:10px;row-gap:2px;padding:4px 2px}
2157.ovfd-stat-n{font-family:'OV Brand',Nunito,Inter,sans-serif;font-size:34px;font-weight:800;line-height:1.05;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
2158.ovfd-stat-l{font-size:13px;color:var(--text-secondary,#a8b3c4);align-self:center}
2159.ovfd-trend{grid-column:1;justify-self:start;font-size:12.5px;font-weight:750;padding:1px 8px;border-radius:999px;font-variant-numeric:tabular-nums}
2160.ovfd-trend.is-up{color:#34d399;background:color-mix(in srgb,#10b981 16%,transparent)}.ovfd-trend.is-down{color:#f87171;background:color-mix(in srgb,#ef4444 14%,transparent)}.ovfd-trend.is-flat{color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent)}
2161.ovfd-stat-was{font-size:12px;color:var(--text-muted,#93a0b4);align-self:center}
2162.ovfd-group{display:grid;gap:4px;padding:2px 0}
2163.ovfd-group-i{display:flex;align-items:center;gap:8px;min-width:0;font-size:13.5px;color:var(--text-secondary,#a8b3c4)}
2164.ovfd-group-i img,.ovfd-group-ph{flex:none;width:36px;height:36px;border-radius:8px;object-fit:cover}
2165.ovfd-group-ph{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}
2166.ovfd-group:not(:has(img)) .ovfd-group-i::before{content:'';flex:none;width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.55}
2167.ovfd-group-i span{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
2168.ovfd-group-more{font-size:12px;color:var(--text-muted,#93a0b4)}
2169.ovfd-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}
2170@media (hover:hover){.ovfd-card:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 7%,transparent)}}
2171.ovfd-head{display:flex;align-items:center;gap:8px;min-width:0}
2172.ovfd-face{flex:none;display:grid;place-items:center;width:40px;height:40px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:15px}
2173.ovfd-face img{width:100%;height:100%;object-fit:cover}
2174.ovfd-face.is-site{border-radius:30%;background:none;overflow:visible}.ovfd-face.is-site>.ov-appicon{--ova-size:40px}
2175.ovfd-who{min-width:0;display:grid}
2176.ovfd-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2177.ovfd-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}
2178.ovfd-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;animation:ovfd-onair 2.4s ease-in-out infinite}
2179.ovfd-card.is-on{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--live,#ef4444) 45%,transparent);background:color-mix(in srgb,var(--live,#ef4444) 7%,transparent)}
2180@keyframes ovfd-onair{50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--live,#ef4444) 25%,transparent)}}
2181.ovfd-faces{display:flex;flex:none}.ovfd-faces .ovfd-face{width:30px;height:30px;box-shadow:0 0 0 2px var(--bg-secondary,#141a24)}.ovfd-faces .ovfd-face+.ovfd-face{margin-left:-9px}
2182.ovfd-lives-row{display:flex;flex-wrap:wrap;gap:6px}
2183.ovfd-lives .ovfd-who b{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.3}
2184.ovfd-pill{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-height:36px;padding:6px 12px;border-radius:999px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent);color:inherit;text-decoration:none;font-size:13px;line-height:1.2}
2185.ovfd-pill b{flex:none;font-weight:700}.ovfd-pill span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#93a0b4)}
2186@media (hover:hover){.ovfd-pill:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 11%,transparent)}}
2187.ovfd-media{position:relative;display:block;border-radius:12px;overflow:hidden;background:var(--bg-tertiary,#1f2633)}
2188.ovfd-media img,.ovfd-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
2189.ovfd-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}
2190.ovfd-viewers{left:8px;right:auto}
2191.ovfd-title{font-size:14.5px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
2192.ovfd-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}
2193.ovfd-counts{display:flex;flex-wrap:wrap;gap:14px;font-size:12.5px;font-weight:650;color:var(--text-muted,#93a0b4);font-variant-numeric:tabular-nums}
2194.ovfd-counts span{display:inline-flex;align-items:center;gap:5px}
2195.ovfd svg.ovfd-gl{flex:none;width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
2196.ovfd-act{display:flex;gap:12px;align-items:flex-start;padding:10px;border-radius:16px;color:inherit;text-decoration:none;min-width:0}
2197@media (hover:hover){.ovfd-act:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 5%,transparent)}}
2198.ovfd-act .ovfd-face,.ovfd-tile,.ovfd-tile-wrap{flex:none;width:40px;height:40px}
2199.ovfd-act .ovfd-face.is-site{border-radius:12px}
2200.ovfd-act-txt{min-width:0;display:grid;gap:3px;padding-top:1px}
2201.ovfd-act-txt b{font-size:14px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
2202.ovfd-act-txt small{display:flex;align-items:center;gap:5px;min-width:0;font-size:12px;color:var(--text-muted,#93a0b4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2203.ovfd-act-more{font-size:13px;line-height:1.45;color:var(--text-secondary,#a8b3c4);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
2204.ovfd-tile{box-sizing:border-box;display:grid;place-items:center;border-radius:12px;font-variant-numeric:tabular-nums}
2205.ovfd-tile-num{position:relative;background:linear-gradient(150deg,hsl(152 62% 48% / .32),hsl(152 60% 32% / .16));color:hsl(152 70% 78%)}
2206.ovfd-tile-num b{font-size:15px;font-weight:800;letter-spacing:-.02em}
2207.ovfd-tile-num svg.ovfd-gl{position:absolute;right:-3px;bottom:-3px;width:16px;height:16px;padding:2px;border-radius:50%;background:var(--bg-secondary,#141a24);color:hsl(152 70% 70%)}
2208.ovfd-tile-date{grid-template-rows:auto 1fr;place-items:stretch;overflow:hidden;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 8%,transparent);text-align:center}
2209.ovfd-tile-date small{display:block;padding:1px 0;background:var(--live,#ef4444);color:#fff;font-size:9.5px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
2210.ovfd-tile-date b{display:grid;place-items:center;font-size:16px;font-weight:800;line-height:1}
2211.ovfd-tile-wrap{position:relative}
2212.ovfd-tile-wrap .ovfd-face{width:40px;height:40px}
2213.ovfd-badge{position:absolute;right:-4px;bottom:-4px;display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:hsl(43 92% 56%);color:#3a2600;box-shadow:0 0 0 2px var(--bg-secondary,#141a24)}
2214.ovfd-badge svg.ovfd-gl{width:12px;height:12px;stroke-width:2.4}
2215.ovfd-day{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin:8px 2px 0;font-size:12px;font-weight:750;letter-spacing:.04em;color:var(--text-muted,#93a0b4)}
2216.ovfd-day::after{content:"";flex:1;height:1px;background:var(--border,rgba(255,255,255,.1))}
2217.ovfd-list>.ovfd-day:first-child{margin-top:0}
2218.ovfd-update{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:12px;color:inherit;text-decoration:none}
2219.ovfd-update .ovh-ic{width:34px;height:34px;border-radius:10px;flex:none}
2220.ovfd-update-txt{min-width:0;display:grid;gap:2px}.ovfd-update-txt b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovfd-update-txt small{font-size:12px;color:var(--text-muted,#93a0b4)}
2221.ovfd-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:ovfd-shimmer 1.4s ease-in-out infinite}
2222.ovfd-more{min-height:1px}.ovfd-caught{text-align:center;padding:10px 0}
2223@keyframes ovfd-shimmer{to{background-position:-200% 0}}
2224
2225/* 4.223.0 (the owner, 11-12: "these icons are tiny … fix this icon shit"): the kind glyph beside the time at 18 px and a
2226 site's face drawn as an app icon; lengths read as times. The Rundown has its own card: its mark, whom it is about, a
2227 ribbon on its picture, and the picture graded so it never reads as the stream's own card above it. */
2228.ovfd-who small{gap:6px;font-size:12.5px}
2229.ovfd-who small .ov-icon{position:relative;flex:none;width:18px;height:18px;overflow:hidden;border-radius:5px}
2230.ovfd-who small .ov-icon .ovi-ring,.ovfd-who small .ov-icon .ovi-bg,.ovfd-who small .ov-icon .ovi-comet,.ovfd-who small .ov-icon .ovi-prog{display:none}
2231.ovfd-who small .ov-icon svg{position:absolute;left:-26%;top:-26%;width:152%;height:152%;transform:none!important}
2232.ovfd-face{width:36px;height:36px}
2233.ovfd-face.is-site{position:relative}
2234.ovfd-face.is-site .ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ovh-h,215) 90% 82%)}
2235.ovfd-face.is-site .ov-icon .ovi-ring,.ovfd-face.is-site .ov-icon .ovi-bg,.ovfd-face.is-site .ov-icon .ovi-comet,.ovfd-face.is-site .ov-icon .ovi-prog{display:none}
2236.ovfd-face.is-site .ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;transform:none!important}
2237.ovfd-act .ovfd-face,.ovfd-tile,.ovfd-tile-wrap{width:44px;height:44px}
2238.ovfd-face.is-rundown{border-radius:12px;background:linear-gradient(140deg,#f59e0b,#ef4444 55%,#a855f7);color:#fff;box-shadow:0 4px 14px -4px rgba(239,68,68,.55)}
2239.ovfd-face.is-rundown svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2.1;stroke-linecap:round;stroke-linejoin:round}
2240.ovfd-k-rundown{box-shadow:inset 0 0 0 1px color-mix(in srgb,#f59e0b 38%,transparent);background:linear-gradient(180deg,color-mix(in srgb,#f59e0b 7%,transparent),transparent 60%)}
2241.ovfd-k-rundown .ovfd-media img{filter:saturate(1.35) contrast(1.06) sepia(.12)}
2242.ovfd-k-rundown .ovfd-media::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,transparent 55%,rgba(15,8,30,.55)),linear-gradient(120deg,rgba(245,158,11,.18),transparent 40%,rgba(168,85,247,.18))}
2243.ovfd-ribbon{position:absolute;left:10px;top:10px;z-index:1;display:inline-flex;align-items:center;gap:6px;padding:5px 10px 5px 8px;border-radius:999px;font-size:12px;font-weight:800;letter-spacing:.02em;color:#fff;background:linear-gradient(140deg,#f59e0b,#ef4444 60%,#a855f7);box-shadow:0 4px 12px -2px rgba(0,0,0,.45)}
2244.ovfd-ribbon svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
2245.ovfd-media{position:relative}
2246.ovfd-len{font-variant-numeric:tabular-nums;z-index:1}
2247
2248/* 4.241.0: the feed as social posts. Chips with glyphs; a person's line (face, name with its effect, level, @handle);
2249 a post is its card and its reply, likes and share; a run of deals is a listing (picture, price, old price, store). */
2250.ovfd-chip{display:inline-flex;align-items:center;gap:7px;min-height:38px;padding:0 15px 0 11px;transition:background-color .2s,color .2s,border-color .2s,transform .18s cubic-bezier(.3,1.4,.4,1)}
2251.ovfd-chip-g{flex:none;width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;opacity:.85}
2252.ovfd-chip[aria-selected=true]{background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent);color:var(--accent-light,#93c5fd);border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}
2253.ovfd-chip[aria-selected=true] .ovfd-chip-g{opacity:1}
2254.ovfd-chip:active{transform:scale(.94)}
2255@media (hover:hover){.ovfd-chip:not([aria-selected=true]):hover{color:var(--text-primary,#e8ecf3);border-color:color-mix(in srgb,var(--text-primary,#e8ecf3) 28%,transparent)}}
2256.ovfd-nameline{display:flex;align-items:center;gap:6px;min-width:0}
2257.ovfd-nameline .ovfd-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14.5px;font-weight:800}
2258.ovfd-lvl{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 6px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.02em;color:#fde68a;background:linear-gradient(135deg,color-mix(in srgb,#f59e0b 32%,transparent),color-mix(in srgb,#f59e0b 14%,transparent));box-shadow:inset 0 0 0 1px color-mix(in srgb,#f59e0b 40%,transparent)}
2259.ovfd-post{display:grid;border-radius:18px;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));min-width:0;overflow:hidden}
2260.ovfd-post>.ovfd-card{border-radius:18px 18px 0 0;background:none;box-shadow:none;padding:12px 12px 8px}
2261.ovfd-post .ovfd-face{width:42px;height:42px}
2262.ovfd-post .ovfd-media{border-radius:14px}
2263.ovfd-acts{display:flex;align-items:center;gap:4px;padding:2px 6px 6px;border-top:1px solid color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}
2264.ovfd-act-b{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;gap:7px;min-height:40px;padding:0 12px;border-radius:999px;cursor:pointer;font-size:13.5px;font-weight:650;color:var(--text-secondary,#a8b3c4);text-decoration:none;transition:background-color .2s,color .2s,transform .18s cubic-bezier(.3,1.4,.4,1)}
2265.ovfd-act-b .ovfd-gl{width:19px;height:19px}
2266.ovfd-act-b:last-child{margin-left:auto}
2267.ovfd-act-b.is-static{cursor:default}
2268.ovfd-act-b:not(.is-static):active{transform:scale(.94)}
2269.ovfd-act-b:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
2270@media (hover:hover){.ovfd-act-b:not(.is-static):hover{color:var(--accent-light,#93c5fd);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}.ovfd-post:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 6%,transparent)}}
2271.ovfd-group.is-deals{gap:6px}
2272.ovfd-group.is-deals .ovfd-group-i{align-items:center;gap:10px;padding:6px;border-radius:12px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent)}
2273.ovfd-group.is-deals .ovfd-group-i::before{display:none}
2274.ovfd-group.is-deals .ovfd-group-i img,.ovfd-group-ph.is-deal{width:48px;height:48px;border-radius:10px}
2275.ovfd-group-ph.is-deal{display:grid;place-items:center;color:#facc15;background:color-mix(in srgb,#facc15 12%,transparent)}
2276.ovfd-group-ph.is-deal .ovfd-gl{width:22px;height:22px}
2277.ovfd-group-t{min-width:0;display:grid;gap:2px}
2278.ovfd-group-n{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:14px;font-weight:650;color:var(--text-primary,#e8ecf3)}
2279.ovfd-price{display:flex;align-items:baseline;gap:7px;min-width:0;font-size:13px;white-space:nowrap}
2280.ovfd-price b{font-size:14.5px;font-weight:800;color:#4ade80;font-variant-numeric:tabular-nums}
2281.ovfd-price s{color:var(--text-muted,#93a0b4);font-variant-numeric:tabular-nums}
2282.ovfd-price em{font-style:normal;font-weight:800;font-size:11.5px;padding:1px 6px;border-radius:6px;color:#4ade80;background:color-mix(in srgb,#22c55e 14%,transparent)}
2283.ovfd-price small{min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text-muted,#93a0b4)}
2284.ovfd-group.is-deals .ovfd-group-more{justify-self:start;padding:4px 2px;font-size:13px;font-weight:700;color:var(--accent-light,#93c5fd)}
2285@media (min-width:900px){.ovfd-group.is-deals{grid-template-columns:repeat(2,minmax(0,1fr))}.ovfd-group.is-deals .ovfd-group-more{grid-column:1/-1}}
2286@media (prefers-color-scheme:light){.ovfd-lvl{color:#92400e}.ovfd-price b,.ovfd-price em{color:#15803d}.ovfd-group-ph.is-deal{color:#a16207}}
2287:root[data-theme=light] .ovfd-lvl{color:#92400e} :root[data-theme=light] .ovfd-price b,:root[data-theme=light] .ovfd-price em{color:#15803d}
2288html[data-ov-motion=off] :is(.ovfd-chip,.ovfd-act-b){transition:none}
2289`;
2290 document.head.appendChild(st);
2291 }
2292
2293 // actorCard(box, o): your Actor's card (or the invitation to make one), drawn by the menu's top since 4.238.0.
2294 // youCard(box, o) (4.238.0; the owner, 11-13: "merge the actors … this little online thing … move up here … the colors
2295 // and other settings"): the menu's top sheet: your row (Display, your account's menu) and your Actor's card.
2296 function youCard(box, o) {
2297 styles();
2298 box.textContent = '';
2299 box.append(meRow(o));
2300 if (o.token) { const a = h('div', 'ovh-notice'); box.append(a); actor(a, o); }
2301 if (typeof o.look === 'function') { try { o.look(box); } catch { /* the Display button stays a link */ } }
2302 }
2303 root.OpenVibeHome = { home, wallTop, timeline, feed, loadPulse, youCard, actorCard: (box, o) => actor(box, o), _defaultHome: defaultHome };
2304})(typeof window !== 'undefined' ? window : globalThis);
2305