openvibers/openvibe.shared ov-home.js
ov-home.js
| 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 | ; |
| 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; } |
| 111 | html[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); } } |
| 139 | html[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. */ |
| 248 | html[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; } } |
| 271 | html[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; } |
| 326 | html[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); } } |
| 348 | html[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 | } |
| 396 | html[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; } } |
| 416 | html[data-ov-motion=reduced] .ovh-hrow { animation-name: ovh-rise-calm; animation-delay: 0s; } |
| 417 | html[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); } |
| 429 | html[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; } |
| 457 | html[data-ov-motion=reduced] .ovh-tabrow .ovh-tab, html[data-ov-motion=reduced] .ovh-tabrow .ovh-tab .ovh-tab-t { transition-duration: .15s; } |
| 458 | html[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); } } |
| 470 | html[data-ov-motion=reduced] .ovh[data-enter] > :not(.ovh-top):not(.ovh-tabrow) { animation-name: ovh-rise-calm; } |
| 471 | html[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); } } |
| 521 | html[data-ov-motion=reduced] .ovh-face.is-glyph:has(.is-working) { animation-duration: 5s; } |
| 522 | html[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); } } |
| 544 | html[data-ov-motion=reduced] .ovh-tab.has-new .ovh-tab-g > svg { animation-duration: .9s; } |
| 545 | html[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} |
| 2288 | html[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 |