openvibers/openvibe.live Commits
Home polish from phone feedback: one family of Star card actions, a themed card rail for AI Moments, no tap flash, and 360/400px fixes
Changes
@@@ -817,13 +817,28 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }}/* ── from i18n-star.css ── */--.home-star-section { margin: 4px 0 28px; }++/* ═══ Star of OpenVibe (decoration and compact grid: css/i18n-star.css) ═══++ Gold is the card's one fixed hue, but text never uses it raw: --star-ink mixes it with the++ theme's text colour (light gold on dark themes, deep amber on light ones). */++.home-star-section {++ margin: 4px 0 28px;++ --star-gold: #fbbf24;++ --star-ink: color-mix(in srgb, #f59e0b 60%, var(--text-primary));++ --star-pink-ink: color-mix(in srgb, #ec4899 58%, var(--text-primary));++}++.home-star-section .section-header h2 > i.star-head-icon { color: var(--star-ink); }++++/* Border sweep: a rotating oversized square left a wide desktop card's border in patches. The++ gradient turns in place instead (registered angle, like the pitch banner), at any card shape. */++@property --star-sweep { syntax: '<angle>'; inherits: false; initial-value: 0deg; }++@keyframes starSweep { to { --star-sweep: 360deg; } }.star-card-border::before {-- content: ''; position: absolute; inset: -60%; z-index: -1;-- background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);-- animation: starSpin 7s linear infinite;++ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit;++ background: conic-gradient(from var(--star-sweep), var(--star-gold), #f472b6, var(--accent), var(--star-gold));++ animation: starSweep 7s linear infinite;}--.star-card.is-live { grid-template-columns: auto 1fr minmax(260px, 340px); }++.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }++.star-petals span:nth-child(1) { left: 6%; animation-delay: 0s; }.star-petals span:nth-child(2) { left: 22%; animation-delay: -2s; font-size: 0.9rem; }.star-petals span:nth-child(3) { left: 41%; animation-delay: -5s; }@@@ -833,8 +848,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.star-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }.star-body { min-width: 0; }--.star-kicker { display: inline-flex; align-items: center; gap: 7px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #fbbf24; margin-bottom: 4px; }--.star-name { margin: 0 0 6px; font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 900; line-height: 1.1; letter-spacing: -0.01em; }++.star-kicker { display: inline-flex; align-items: center; gap: 7px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--star-ink); margin-bottom: 4px; }++.star-name { margin: 0 0 6px; font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 900; line-height: 1.1; letter-spacing: -0.01em; overflow-wrap: anywhere; }.star-name a { color: inherit; text-decoration: none; }.star-name a:hover { color: var(--accent-light); }.star-name .star-jp { display: block; font-size: 0.55em; font-weight: 600; color: var(--text-secondary); margin-top: 2px; }@@@ -844,38 +859,46 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }font-size: 0.74rem; font-weight: 700; color: var(--text-primary);background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);}--.star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.12); border-color: rgba(251,191,36,0.45); }--.star-chip.pink { color: #fbcfe8; background: rgba(244,114,182,0.12); border-color: rgba(244,114,182,0.45); }++.star-chip.gold { color: var(--star-ink); background: color-mix(in srgb, var(--star-gold) 12%, transparent); border-color: color-mix(in srgb, var(--star-gold) 45%, transparent); }++.star-chip.pink { color: var(--star-pink-ink); background: color-mix(in srgb, #f472b6 12%, transparent); border-color: color-mix(in srgb, #f472b6 45%, transparent); }.star-bio { margin: 0 0 4px; color: var(--text-primary); line-height: 1.5; white-space: pre-wrap; font-size: 0.95rem; }.star-bio-src { margin: 0 0 8px; color: var(--text-secondary); font-size: 0.82rem; white-space: pre-wrap; line-height: 1.45; }.star-bio-label { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-light); margin: 6px 0 2px; }.star-overview { color: var(--text-secondary); font-size: 0.84rem; line-height: 1.45; margin: 8px 0 0; }--.star-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; margin-bottom: 6px; }--.star-actions .btn { font-weight: 700; }.star-btn-watch::after { content: ''; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.6) 50%, transparent 65%); background-size: 250% 100%; animation: starSheen 2.6s linear infinite; pointer-events: none; }.star-live-card { align-self: stretch; }.star-live-card .stream-card { height: 100%; }--.star-offline { color: var(--text-muted); font-size: 0.8rem; margin-top: 8px; }++/* Icon beside the sentence, not above a wrapped second line. */++.star-offline { display: flex; align-items: baseline; gap: 7px; margin-top: 10px; color: var(--text-muted); font-size: 0.8rem; line-height: 1.4; text-wrap: pretty; }++.star-offline > i { flex: none; }@media (max-width: 860px) {.home-star-section { margin-bottom: 34px; }-- .star-avatar-wrap { margin: 0 auto; }-- .star-chips, .star-actions { justify-content: center; }.star-live-card { max-width: 420px; width: 100%; margin: 0 auto; }}--@media (prefers-reduced-motion: reduce) {.star-petals { display: none; }}--.rs-lite .star-card-border::before { animation: none; background: linear-gradient(135deg, #fbbf24, #f472b6, var(--accent)); inset: 0; }++.rs-lite .star-card-border::before { animation: none; background: linear-gradient(135deg, var(--star-gold), #f472b6, var(--accent)); inset: 0; }.rs-lite .star-avatar-glow, .rs-lite .star-petals { display: none; }.rs-lite .star-avatar-ring { animation: none; }--.rs-lite .star-btn-watch { box-shadow: none; }--.star-why { margin: 10px 0 12px; padding: 12px 14px; border-radius: 12px; background: linear-gradient(135deg, rgba(251,191,36,0.12), rgba(244,114,182,0.08)); border: 1px solid rgba(251,191,36,0.35); }--.star-why-head { font-size: 1.05rem; font-weight: 900; color: #fff; margin-bottom: 4px; }++++/* Why they're today's star; "picked by" and the countdown each stay on one line. */++.star-why {++ margin: 0 0 12px; padding: 12px 14px; border-radius: 12px;++ background: linear-gradient(135deg, color-mix(in srgb, var(--star-gold) 12%, transparent), color-mix(in srgb, #f472b6 8%, transparent));++ border: 1px solid color-mix(in srgb, var(--star-gold) 35%, transparent);++}++.star-why-head { font-size: 1.05rem; font-weight: 900; color: var(--text-primary); margin-bottom: 4px; }.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }--.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }--.star-why-meta i { margin-right: 4px; }++.star-why-meta {++ display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 9px;++ font-size: 0.68rem; font-weight: 750; letter-spacing: 0.07em; text-transform: uppercase; color: var(--star-ink);++}++.star-why-meta > span { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }++.star-why-meta .star-why-next { color: var(--text-muted); }++++/* Compact card: avatar, who they are, and one action row. */.star-card.is-compact .star-avatar-wrap { grid-area: avatar; width: 62px; height: 62px; align-self: center; }.star-card.is-compact .star-avatar { width: 62px; height: 62px; font-size: 1.45rem; }.star-card.is-compact .star-body {@@@ -889,24 +912,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.star-card.is-compact .star-name .star-jp { font-size: 0.5em; margin-top: 0; }.star-card.is-compact .star-chips { gap: 5px; margin: 3px 0 0; }.star-card.is-compact .star-chip {-- font-size: 0.68rem; padding: 2px 9px; border-radius: 999px;-- background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.12); color: var(--text-secondary);--}--.star-card.is-compact .star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.1); border-color: rgba(251,191,36,0.3); }--.star-card.is-compact .star-actions {-- grid-area: actions; display: flex; flex-wrap: wrap; align-items: center;-- gap: 8px; margin: 4px 0 0;--}--.star-card.is-compact .star-actions .btn,--.star-card.is-compact .star-expand {-- height: 38px; padding: 0 15px; font-size: 0.82rem; border-radius: 11px;-- display: inline-flex; align-items: center; gap: 8px; line-height: 1;--}--.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }--.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }--.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }--.star-expand.is-open { padding-right: 16px; }--.star-expand.is-open span { font-weight: 800; }++ font-size: 0.68rem; padding: 2px 9px; border-radius: 999px; color: var(--text-secondary);++ background: color-mix(in srgb, var(--text-primary) 5%, transparent);++ border-color: color-mix(in srgb, var(--text-primary) 14%, transparent);++}++.star-card.is-compact .star-chip.gold { color: var(--star-ink); background: color-mix(in srgb, var(--star-gold) 10%, transparent); border-color: color-mix(in srgb, var(--star-gold) 32%, transparent); }.star-more[hidden] { display: none !important; }.star-more { display: block; grid-column: 1 / -1; }.star-card > .star-actions, .star-card > .star-more { grid-column: 1 / -1; }@@@ -914,7 +924,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }.star-card.is-compact .star-avatar-ring {inset: -3px; border-radius: 50%;-- background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);++ background: conic-gradient(from 0deg, var(--star-gold), #f472b6, var(--accent), var(--star-gold));animation: starRingSpin 6s linear infinite;opacity: 0.9;}@@@ -922,24 +932,53 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.star-card.is-compact .star-avatar { transition: transform 0.28s cubic-bezier(.2, 1.2, .3, 1); }.star-card.is-compact:hover .star-avatar { transform: scale(1.04); }--.star-expand {-- cursor: pointer; font: inherit; font-weight: 750;-- color: color-mix(in srgb, #fbbf24 82%, var(--text-primary));-- background: color-mix(in srgb, #fbbf24 9%, transparent);-- border: 1px solid color-mix(in srgb, #fbbf24 26%, transparent);-- transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;--}--.star-expand:hover {-- transform: translateY(-1px);-- background: color-mix(in srgb, #fbbf24 17%, transparent);-- border-color: color-mix(in srgb, #fbbf24 50%, transparent);-- box-shadow: 0 6px 16px color-mix(in srgb, #fbbf24 20%, transparent);--}--.star-expand:active { transform: translateY(0) scale(0.985); }--.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }--.star-expand.is-open i { transform: rotate(180deg); }--.star-card.is-compact ~ .star-card-border::before,--.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }++/* ── Action row: one family ──────────────────────────────────────────────────++ One height, radius, type size and icon placement: gold filled primary, two identical outline++ buttons, never wrapping. On a phone the toggle is a square chevron button (label kept for++ screen readers). It used to open into a box with the chevron stacked above "Show less". */++.star-actions { display: flex; align-items: center; gap: 8px; min-width: 0; margin: 4px 0 0; }++.star-card.is-compact .star-actions { grid-area: actions; }++.star-act {++ --star-act-h: 40px;++ display: inline-flex; align-items: center; justify-content: center; gap: 8px;++ flex: 0 0 auto; min-width: 0; height: var(--star-act-h); padding: 0 15px;++ border-radius: 12px; font: inherit; font-size: 0.84rem; font-weight: 750; line-height: 1;++ white-space: nowrap; text-decoration: none; cursor: pointer;++ color: var(--text-primary);++ background: color-mix(in srgb, var(--text-primary) 4%, transparent);++ border: 1px solid color-mix(in srgb, var(--text-primary) 16%, transparent);++ transition: background 0.18s, border-color 0.18s, box-shadow 0.2s, translate 0.2s cubic-bezier(.2, 1, .3, 1), scale 0.12s;++}++.star-act > i { flex: none; font-size: 0.92em; color: var(--star-ink); transition: rotate 0.3s cubic-bezier(.2, 1, .3, 1); }++.star-act > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }++.star-act:hover {++ color: var(--text-primary); translate: 0 -1px;++ background: color-mix(in srgb, var(--star-gold) 10%, transparent);++ border-color: color-mix(in srgb, var(--star-gold) 45%, transparent);++}++.star-act:active { translate: none; scale: var(--press-scale, 0.97); }++.star-act:focus-visible { outline: 2px solid var(--star-ink); outline-offset: 2px; }++.star-act.star-btn-watch {++ flex: 1 0 auto; position: relative; overflow: hidden;++ color: #1c1002; border: 0;++ background: linear-gradient(135deg, #fde68a, #fbbf24 50%, #f59e0b);++ box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 32%, transparent);++}++.star-act.star-btn-watch > i { color: inherit; }++.star-act.star-btn-watch:hover {++ color: #1c1002;++ background: linear-gradient(135deg, #fef3c7, #fcd34d 50%, #fbbf24);++ box-shadow: 0 8px 22px color-mix(in srgb, #fbbf24 42%, transparent);++}++.star-expand { flex: 0 1 auto; }++.star-expand.is-open {++ background: color-mix(in srgb, var(--star-gold) 12%, transparent);++ border-color: color-mix(in srgb, var(--star-gold) 42%, transparent);++}++.star-expand.is-open > i { rotate: 180deg; }++.rs-lite .star-act.star-btn-watch { box-shadow: none; }++++/* Expanded card: the compact card's shape, with the detail revealed under it. */.star-card:not(.is-compact) .star-avatar-wrap,.star-card:not(.is-compact) .star-avatar { width: 76px; height: 76px; }.star-card:not(.is-compact) .star-avatar { font-size: 1.7rem; }@@@ -951,37 +990,48 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.star-card:not(.is-compact) .star-badge { display: none; }.star-card:not(.is-compact) .star-more { text-align: left; }.star-card:not(.is-compact) .star-more > * { margin-left: 0; margin-right: 0; }--.star-card:not(.is-compact) .star-actions { justify-content: flex-start; }.star-more {margin-top: 12px; padding: 13px 15px; border-radius: 13px;background: color-mix(in srgb, var(--bg-primary) 45%, transparent);-- border: 1px solid color-mix(in srgb, #fbbf24 13%, var(--border));++ border: 1px solid color-mix(in srgb, var(--star-gold) 13%, var(--border));}.star-more > *:first-child { margin-top: 0; }.star-more > *:last-child { margin-bottom: 0; }++++/* Wide: avatar, who they are, and the actions on the right, on one line. */@media (min-width: 820px) {-- .star-card.is-compact .star-actions { margin: 0; justify-content: flex-end; flex-wrap: nowrap; }-- .star-card.is-compact .star-btn-watch { flex: 0 0 auto; min-width: 0; }-- .star-card.is-compact .star-expand { margin-left: 0; }-- .star-card.is-compact .star-more { grid-column: 1 / -1; }++ .star-card.is-compact .star-actions { margin: 0; justify-content: flex-end; }++ .star-card.is-compact .star-btn-watch { flex: 0 0 auto; }++ .star-card:not(.is-compact) .star-actions { justify-content: flex-start; }++ .star-card:not(.is-compact) .star-btn-watch { flex: 0 0 auto; }}--++/* Phone: no panel-in-a-panel, and the toggle's label is visually hidden (still its name). */@media (max-width: 560px) {.star-card.is-compact .star-avatar-wrap,.star-card.is-compact .star-avatar { width: 52px; height: 52px; }.star-card.is-compact .star-avatar { font-size: 1.2rem; }-- .star-card.is-compact .star-actions .btn,-- .star-card.is-compact .star-expand { height: 36px; font-size: 0.78rem; padding: 0 12px; }-- .star-card.is-compact .star-btn-watch { flex: 1 1 100%; }-- .star-card.is-compact .star-expand { margin-left: 0; flex: 1 1 auto; justify-content: center; }++ .star-act { padding: 0 14px; font-size: 0.82rem; }++ .star-act-extra { display: none; }++ .star-act.star-btn-watch { flex: 1 1 auto; }++ .star-expand { flex: none; width: var(--star-act-h); padding: 0; }++ .star-expand > i { font-size: 0.95rem; }++ .star-expand-label {++ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;++ }++ .star-more { padding: 12px 0 0; border: 0; border-top: 1px solid color-mix(in srgb, var(--star-gold) 16%, var(--border)); border-radius: 0; background: none; }}--@media (max-width: 380px) {-- .star-card.is-compact .star-actions .btn-outline { flex: 1 1 100%; justify-content: center; }++/* A 320px phone cannot fit three labelled buttons: "Say hi" goes to its icon as well. */++@media (max-width: 350px) {++ .star-act:not(.star-btn-watch) { flex: none; width: var(--star-act-h); padding: 0; }++ .star-act:not(.star-btn-watch) > span {++ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;++ }}@media (prefers-reduced-motion: reduce) {.star-more.is-entering { animation: none; }.star-card.is-swapping { transition: none; }.star-card.is-compact .star-avatar-ring { animation: none; }++ .star-act, .star-act > i { transition: none; }}/* The pitch banner: what kind of place this is, before the headline says it. */@@@ -1079,3 +1129,47 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }#page-home .hero .hero-philosophy-links { margin: var(--hero-step) auto 0; row-gap: 6px; }#page-home .hero .hero-latest { margin: var(--hero-step) auto 0; }#page-home .hero .hero-stats { margin-top: var(--hero-step); }++++/* ── Section header actions ("View all", "Play game") ─────────────────────────++ These were .badge links on var(--surface), a token no theme defines, so white text on nothing++ (invisible on light themes). One pill, tinted by the section's colour. */++.section-header > .section-action {++ display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px;++ border-radius: 999px; font: inherit; font-size: 0.74rem; font-weight: 750; line-height: 1;++ white-space: nowrap; text-decoration: none; cursor: pointer;++ color: color-mix(in srgb, var(--sc-on) 50%, var(--text-primary));++ background: color-mix(in srgb, var(--sc-on) 10%, transparent);++ border: 1px solid color-mix(in srgb, var(--sc-on) 28%, transparent);++ transition: background 0.15s, border-color 0.15s, color 0.15s, scale 0.12s;++}++.section-header > .section-action:hover {++ color: var(--text-primary);++ background: color-mix(in srgb, var(--sc-on) 18%, transparent);++ border-color: color-mix(in srgb, var(--sc-on) 50%, transparent);++}++/* Phone: a header's sentence gets its own line under the title instead of being cut off. */++@media (max-width: 560px) {++ .section-header:has(> span.muted) { flex-wrap: wrap; row-gap: 3px; }++ .section-header > span.muted {++ order: 5; flex: 1 0 100%; margin-left: 34px;++ white-space: normal; overflow: visible; line-height: 1.35; text-wrap: pretty;++ }++}++++/* The changelog's "show all" lives under the list it expands. */++.home-changelog-toggle { margin: 12px auto 0; }++.home-changelog-toggle > i { transition: rotate 0.3s cubic-bezier(.2, 1, .3, 1); }++.home-changelog-toggle[aria-expanded="true"] > i { rotate: 180deg; }++++/* Network hub two-up on a phone: the icon beside the text left ~90px, and "OpenVibe.Community"++ ran out of its card. Icon above text there; names may break after "OpenVibe." (<wbr>). */++.network-card-info strong { overflow-wrap: break-word; }++@media (min-width: 361px) and (max-width: 480px) {++ .network-card { flex-direction: column; align-items: flex-start; gap: 8px; }++ .network-card-info { width: 100%; }++}++/* The ≤480px rule hiding the arrow lost to Font Awesome's later .fa-solid display rule. */++@media (max-width: 480px) { .network-card > .network-card-arrow { display: none; } }++++/* Terms · Privacy · DMCA wrap as one group ("DMCA" was stranded alone at 360px). */++.hero-legal { display: inline-flex; align-items: center; gap: inherit; white-space: nowrap; }@@@ -109,11 +109,6 @@}@keyframes starSheen { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }--@media (max-width: 860px) {-- .star-card, .star-card.is-live { grid-template-columns: 1fr; text-align: center; gap: 14px; padding: 20px 16px 34px; }-- .star-actions .btn { flex: 1 1 140px; justify-content: center; }--}--@media (prefers-reduced-motion: reduce) {.chat-translation, .chat-translate-hint, .ch-captions, .ch-caption, .ch-captions-dot, .ch-about-bio-translated,.star-card-border, .star-card-border::before, .star-petals span, .star-avatar-ring, .star-avatar-glow, .star-live-pill,@@@ -172,13 +167,6 @@/* The avatar ring: a slow conic sweep rather than a static rainbow, so the card has one movingelement instead of several competing ones. Paused off-screen by the viewport gate. */@keyframes starRingSpin { to { transform: rotate(360deg); } }--.star-expand i {-- width: 19px; height: 19px; flex: none; border-radius: 50%; display: grid; place-items: center;-- font-size: 0.55rem; color: #1a1506; line-height: 1;-- background: linear-gradient(140deg, #fbbf24, #f59e0b);-- transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);--}--.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }/* The gold border sweep is the card's one loud effect; calm it down when compact so it framesthe card instead of shouting over it. */@@@ -886,6 +886,24 @@ main#app { position: relative; z-index: 1; }::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }++/* ── Touch & keyboard feedback ────────────────────────────────────++ No browser tap flash (a square blue box on every theme; inherited, so the shared chrome is++ covered too). A pressed control shrinks slightly instead, and keyboard focus gets one ring in++ the theme accent. :where() keeps it all at zero specificity, so a component's own :active or++ :focus-visible wins; `scale` composes with any transform. Opt in with .ov-press / .ov-press-card. */++html { -webkit-tap-highlight-color: transparent; }++:root { --press-scale: 0.97; --press-scale-card: 0.985; }++:where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap):not(:disabled, [aria-disabled="true"]):active { scale: var(--press-scale); }++:where(a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):active { scale: var(--press-scale-card); }++:where(a, button, summary, [role="button"], [tabindex]:not([tabindex="-1"]), input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {++ outline: 2px solid var(--accent-light, var(--accent)); outline-offset: 2px;++}++@media (prefers-reduced-motion: reduce) {++ /* No movement: the press is a brief dim instead. */++ :root { --press-scale: 1; --press-scale-card: 1; }++ :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }++}++/* ═══════════════════════════════════════════════════════════════NAVBAR═══════════════════════════════════════════════════════════════ */@@@ -1902,11 +1920,60 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.pulse-rank a:hover { color: var(--accent); }.pulse-rank b { font-variant-numeric: tabular-nums; }++/* ── Horizontal rail (public/js/ov-rail.js) ──────────────────────++ No native scrollbar; cards snap; the edge with more fades (a mask, so any background works); an++ accent bar shows position; mouse users get prev/next buttons. --rail-accent sets the bar colour. */++.ov-rail { position: relative; margin-bottom: 22px; --rail-fade: 32px; }++.ov-rail-track {++ display: flex; gap: 12px; overflow-x: auto; overflow-y: hidden;++ scroll-snap-type: x mandatory; scroll-behavior: smooth; overscroll-behavior-x: contain;++ scroll-padding-inline: 2px; scrollbar-width: none;++ /* Room for a card's hover lift and focus ring, without moving anything. */++ padding: 4px 2px; margin: -4px -2px 0;++ --fl: 0px; --fr: 0px;++}++.ov-rail-track::-webkit-scrollbar { display: none; }++.ov-rail-track > * { flex-shrink: 0; scroll-snap-align: start; }++.ov-rail-track > :focus-visible { outline-offset: -3px; }++.ov-rail:not(.is-start) > .ov-rail-track { --fl: var(--rail-fade); }++.ov-rail:not(.is-end) > .ov-rail-track { --fr: var(--rail-fade); }++.ov-rail.is-scrollable > .ov-rail-track {++ -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--fl), #000 calc(100% - var(--fr)), transparent);++ mask-image: linear-gradient(90deg, transparent, #000 var(--fl), #000 calc(100% - var(--fr)), transparent);++}++/* A mask would clip the rail's own focus ring. */++.ov-rail-track:focus-visible { -webkit-mask-image: none !important; mask-image: none !important; outline-offset: 1px; border-radius: 12px; }++.ov-rail-btn {++ position: absolute; z-index: 2; top: 0; bottom: 15px; margin: auto 0;++ width: 36px; height: 36px; padding: 0; border-radius: 50%;++ display: grid; place-items: center; font-size: 0.78rem; cursor: pointer;++ color: var(--text-primary);++ background: color-mix(in srgb, var(--bg-card) 90%, transparent);++ border: 1px solid var(--border-light); box-shadow: var(--shadow);++ transition: opacity 0.2s, background 0.15s, color 0.15s, border-color 0.15s, scale 0.12s;++}++.ov-rail-prev { left: 8px; }++.ov-rail-next { right: 8px; }++.ov-rail-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }++.ov-rail-btn:disabled { opacity: 0; pointer-events: none; }++.ov-rail:not(.is-scrollable) > .ov-rail-btn { display: none; }++.ov-rail-bar {++ position: relative; height: 3px; width: min(180px, 42%); margin: 8px auto 0;++ border-radius: 3px; overflow: hidden;++ background: color-mix(in srgb, var(--text-muted) 24%, transparent);++}++.ov-rail-bar[hidden] { display: none; }++.ov-rail-bar > span { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--rail-accent, var(--accent)); }++/* Touch screens swipe; buttons there would only cover the cards. */++@media (hover: none), (pointer: coarse) { .ov-rail-btn { display: none; } }++@media (prefers-reduced-motion: reduce) { .ov-rail-track { scroll-behavior: auto; } }++/* ── AI Moments showcase row ── */--.moments-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; margin-bottom: 22px; scrollbar-width: thin; }++.moments-row { display: flex; gap: 12px; overflow-x: auto; margin-bottom: 22px; }++.ov-rail > .moments-row { margin-bottom: 0; }.moment-card {position: relative; flex: 0 0 220px; height: 124px; border-radius: 12px; overflow: hidden;-- border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease;++ border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease, scale 0.12s ease;}.moment-card img { width: 100%; height: 100%; object-fit: cover; display: block; }.moment-overlay {@@@ -8,6 +8,7 @@"/css/home-tour.css"],"js": [++ "/js/ov-rail.js","/js/app-home.js","/js/ov-odometer.js","/js/ov-density.js",@@@ -218,11 +218,11 @@<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();"><i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)</a>-- <a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>++ <span class="hero-legal"><a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a><span aria-hidden="true">•</span><a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a><span aria-hidden="true">•</span>-- <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>++ <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a></span></div><!-- Newest shipped commit — built in the open, receipts included. Sits under thephilosophy row so the proof is next to the promise. -->@@@ -312,7 +312,7 @@<h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2><span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span></div>-- <div class="moments-row" id="home-moments-row"></div>++ <div class="moments-row ov-rail-track" id="home-moments-row"></div></div><!-- The two introductions travel together: what OpenVibe does (the restream diagram), then@@@ -473,7 +473,7 @@<!-- Recent VODs --><div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header"><h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>-- <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>++ <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="section-action">View all</a></div><div class="stream-grid" id="home-recent-vods-grid"></div><div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>@@@ -481,7 +481,7 @@<!-- Recent Clips --><div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header"><h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>-- <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>++ <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="section-action">View all</a></div><div class="stream-grid home-clips-grid" id="home-clips-grid"></div><div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>@@@ -489,7 +489,7 @@<!-- Recent Pastes --><div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header"><h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>-- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>++ <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="section-action">View all</a></div><div class="home-pastes-list" id="home-pastes-list"></div><div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>@@@ -497,14 +497,14 @@<!-- Scraplandia Leaderboards --><div class="section-header" style="--sc:#fbbf24;display:none" id="home-quest-header"><h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2>-- <a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a>++ <a href="https://openvibe.games/game" class="section-action">Play game</a></div><div class="home-leaderboards" id="home-leaderboards"></div><!-- Canvas Preview --><div class="section-header" style="--sc:#4ade80;display:none" id="home-canvas-header"><h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2>-- <a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a>++ <a href="https://openvibe.games/canvas" class="section-action">Open canvas</a></div><div class="home-canvas-preview" id="home-canvas-preview"></div>@@@ -520,7 +520,7 @@<a href="https://openvibe.live" class="network-card network-card-featured"><div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div><div class="network-card-info">-- <strong>OpenVibe.Live</strong>++ <strong>OpenVibe.<wbr>Live</strong><span>Live streaming, VODs, clips & chat</span></div><i class="fa-solid fa-arrow-right network-card-arrow"></i>@@@ -528,7 +528,7 @@<a href="https://openvibe.tools" class="network-card"><div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div><div class="network-card-info">-- <strong>OpenVibe.Tools</strong>++ <strong>OpenVibe.<wbr>Tools</strong><span>Every online tool in one index</span></div><i class="fa-solid fa-arrow-right network-card-arrow"></i>@@@ -536,7 +536,7 @@<a href="https://openvibe.community" class="network-card"><div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div><div class="network-card-info">-- <strong>OpenVibe.Community</strong>++ <strong>OpenVibe.<wbr>Community</strong><span>Pastes, posts and people</span></div><i class="fa-solid fa-arrow-right network-card-arrow"></i>@@@ -560,7 +560,7 @@<a href="https://openvibe.media" class="network-card"><div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div><div class="network-card-info">-- <strong>OpenVibe.Media</strong>++ <strong>OpenVibe.<wbr>Media</strong><span>VODs, clips and files</span></div><i class="fa-solid fa-arrow-right network-card-arrow"></i>@@@ -651,10 +651,7 @@<!-- OpenVibe.Live Changelog --><div class="section-header" style="--sc:#94a3b8;margin-top:32px;"><h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>-- <div style="display:flex;gap:8px;align-items:center;">-- <button id="home-changelog-toggle" class="badge" onclick="toggleHomeChangelog()" style="cursor:pointer;border:none;background:var(--surface);padding:4px 10px;border-radius:6px;color:var(--text);font-size:0.78rem;">Show All</button>-- <a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="badge" style="cursor:pointer;text-decoration:none;">View All</a>-- </div>++ <a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="section-action">View all</a></div><div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed"><div id="home-changelog" class="home-changelog">@@@ -662,6 +659,9 @@</div><div class="home-changelog-fade" id="home-changelog-fade"></div></div>++ <!-- Expands the preview in place; "View all" in the header opens /updates. It sits under++ the list it expands, in the same button style as the rails' "Load more". -->++ <button type="button" id="home-changelog-toggle" class="home-load-more home-changelog-toggle" onclick="toggleHomeChangelog()" aria-expanded="false" aria-controls="home-changelog-wrapper"><i class="fa-solid fa-chevron-down" aria-hidden="true"></i><span>Show all recent changes</span></button>@@@ -1032,6 +1032,8 @@ function _wireEggKeys() {if (e.ctrlKey || e.metaKey || e.altKey) return;if (e.key === 'Backspace') { _eggBuf.pop(); _renderEggSlots(); return; }let tok = _EGG_ARROW[e.key];++ // A focused card rail scrolls with the arrow keys; the secret must not swallow them.++ if (tok && t && t.closest && t.closest('.ov-rail-track')) return;if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();if (!tok) return;if (_EGG_ARROW[e.key]) e.preventDefault(); // don't scroll the page on arrows@@@ -1291,6 +1293,8 @@ function _renderHomePulse(p) {${m.username ? `<div class="moment-user">@${esc(m.username)}</div>` : ''}</div></a>`).join('');++ // Hidden scrollbar, snap, arrows, position bar (public/js/ov-rail.js). Idempotent.++ if (window.OVRail && ms.length) OVRail.attach(momentsRow, { label: 'AI Moments' });}}@@@ -1322,7 +1326,7 @@ async function loadHomeDigest() {const stats = [st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',-- st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',++ st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat line${st.chat_lines === 1 ? '' : 's'}</span>` : '',st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',st.mic_moments ? `<span class="digest-stat"><i class="fa-solid fa-microphone-lines"></i> <b>${n(st.mic_moments)}</b> Arena mic moment${st.mic_moments === 1 ? '' : 's'}</span>` : '',@@@ -2045,7 +2049,10 @@ function toggleHomeChangelog() {if (!wrapper || !btn) return;const expanded = wrapper.classList.toggle('expanded');wrapper.classList.toggle('collapsed', !expanded);-- btn.textContent = expanded ? 'Show Less' : 'Show All';++ btn.setAttribute('aria-expanded', String(expanded));++ const label = btn.querySelector('span');++ if (label) label.textContent = expanded ? 'Show fewer changes' : 'Show all recent changes';++ else btn.textContent = expanded ? 'Show Less' : 'Show All';}@@@ -2087,8 +2094,10 @@ function _renderHomeStar(data) {}const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';const pick = s.pick || null;++ // Who picked them and when the next star lands are two separate facts, each kept on one line:++ // as one uppercase run the countdown broke in half ("NEXT STAR / IN 3H") on a phone.const why = pick && (pick.headline || pick.reason)-- ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}${pick.next_at ? ` · next star ${esc(_starCountdown(pick.next_at))}` : ''}</div></div>`++ ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`: '';const chips = [foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',@@@ -2097,11 +2106,11 @@ function _renderHomeStar(data) {(s.follower_count > 0) ? `<span class="star-chip gold"><i class="fa-solid fa-heart"></i> ${esc(String(s.follower_count))} follower${s.follower_count === 1 ? '' : 's'}</span>` : '',].filter(Boolean).join('');const isLive = !!s.live;-- const offline = !isLive && s.last_live_at ? `<div class="star-offline"><i class="fa-regular fa-clock"></i> Last live ${esc(timeAgo(s.last_live_at))} — follow to get pinged next time.</div>` : '';++ const offline = !isLive && s.last_live_at ? `<div class="star-offline"><i class="fa-regular fa-clock"></i><span>Last live ${esc(timeAgo(s.last_live_at))} — follow to get pinged next time.</span></div>` : '';const liveCard = isLive ? `<div class="star-live-card">${streamCardHTML(s.live, true)}</div>` : '';sec.innerHTML = `<div class="section-header">-- <h2><i class="fa-solid fa-star" style="color:#fbbf24"></i> Star of OpenVibe</h2>++ <h2><i class="fa-solid fa-star star-head-icon"></i> Star of OpenVibe</h2><span class="muted" style="font-size:0.8rem">${s.rotates ? 'a new star every day — picked by the site\'s AI from who actually showed up' : 'the streamer we\'re rolling out the red carpet for'}</span></div><div class="star-card-border">@@@ -2126,11 +2135,11 @@ function _renderHomeStar(data) {${offline}</div><div class="star-actions">-- <a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>-- <a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>-- <button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">-- <i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>-- </button>++ <a class="star-act star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}" aria-hidden="true"></i><span>${isLive ? 'Watch now' : 'Visit channel'}</span></a>++ <a class="star-act" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments" aria-hidden="true"></i><span>Say hi${foreign ? '<span class="star-act-extra"> — any language works</span>' : ''}</span></a>++ <button type="button" class="star-act star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more" title="More about ${name}">++ <i class="fa-solid fa-chevron-down" aria-hidden="true"></i><span class="star-expand-label">More about ${name}</span>++ </button></div></div></div>`;@@@ -2142,8 +2151,12 @@ function _renderHomeStar(data) {const btn = sec.querySelector('#star-expand');if (card && btn && more) {card.classList.add('is-compact');++ // On a phone the toggle is an icon button; the label stays in the DOM (visually hidden)++ // so it is still the button's accessible name, and the title is the tooltip.const label = () => {-- btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Show less';++ const text = more.hidden ? `More about ${s.display_name || s.username}` : 'Show less';++ btn.querySelector('.star-expand-label').textContent = text;++ btn.title = text;btn.classList.toggle('is-open', !more.hidden);btn.setAttribute('aria-expanded', String(!more.hidden));};nnew file mode 100644@@@ -0,0 +1,106 @@++/**++ * ov-rail.js — one treatment for every horizontal card rail (styles: .ov-rail in css/style.css).++ * Replaces the browser's grey scrollbar with: snapping cards (native swipe on touch), a fade on++ * the edge that has more, a thin accent position bar, and prev/next buttons for mouse users that++ * disappear at the ends. The rail is focusable (arrow keys scroll it); the buttons are real buttons.++ *++ * OVRail.attach(trackElement, { label: 'AI Moments' })++ *++ * Idempotent; the track keeps its id and children, and re-measures when they change.++ */++(function () {++ 'use strict';++ if (typeof window === 'undefined' || window.OVRail) return;++++ let seq = 0;++ const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);++++ function button(dir, track, label) {++ const b = document.createElement('button');++ b.type = 'button';++ b.className = `ov-rail-btn ov-rail-${dir}`;++ b.setAttribute('aria-label', `${dir === 'prev' ? 'Scroll back' : 'Scroll forward'}${label ? ` in ${label}` : ''}`);++ b.setAttribute('aria-controls', track.id);++ b.disabled = true;++ b.innerHTML = `<i class="fa-solid fa-chevron-${dir === 'prev' ? 'left' : 'right'}" aria-hidden="true"></i>`;++ return b;++ }++++ function attach(track, opts = {}) {++ if (!track) return null;++ if (track._ovRail) { track._ovRail.refresh(); return track._ovRail; }++++ let rail = track.parentElement;++ if (!rail || !rail.classList.contains('ov-rail')) {++ rail = document.createElement('div');++ rail.className = 'ov-rail';++ track.before(rail);++ rail.appendChild(track);++ }++ if (!track.id) track.id = `ov-rail-${++seq}`;++ track.classList.add('ov-rail-track');++ if (!track.hasAttribute('tabindex')) track.tabIndex = 0;++ if (opts.label) {++ track.setAttribute('role', 'region');++ track.setAttribute('aria-label', opts.label);++ }++++ const prev = button('prev', track, opts.label);++ const next = button('next', track, opts.label);++ const bar = document.createElement('div');++ bar.className = 'ov-rail-bar';++ bar.setAttribute('aria-hidden', 'true');++ bar.hidden = true;++ const thumb = document.createElement('span');++ bar.appendChild(thumb);++ track.before(prev);++ track.after(next);++ rail.appendChild(bar);++++ const page = (dir) => {++ // Most of a screen at a time; snap settles it on a card edge.++ const step = Math.max(160, track.clientWidth * 0.85);++ track.scrollBy({ left: dir * step, behavior: reducedMotion() ? 'auto' : 'smooth' });++ };++ prev.addEventListener('click', () => page(-1));++ next.addEventListener('click', () => page(1));++++ let raf = 0;++ const setDisabled = (b, off) => {++ // A focused button that disables itself would drop keyboard focus onto <body>.++ if (off && document.activeElement === b) track.focus({ preventScroll: true });++ b.disabled = off;++ };++ const update = () => {++ raf = 0;++ const max = track.scrollWidth - track.clientWidth;++ const scrollable = max > 2;++ const x = Math.min(max, Math.max(0, track.scrollLeft));++ // A few pixels of slack: snapping to the first card can leave scrollLeft at 2-4px.++ const atStart = !scrollable || x <= 8;++ const atEnd = !scrollable || x >= max - 8;++ rail.classList.toggle('is-scrollable', scrollable);++ rail.classList.toggle('is-start', atStart);++ rail.classList.toggle('is-end', atEnd);++ setDisabled(prev, atStart);++ setDisabled(next, atEnd);++ bar.hidden = !scrollable;++ if (scrollable) {++ const size = Math.max(10, Math.min(100, (track.clientWidth / track.scrollWidth) * 100));++ thumb.style.width = `${size}%`;++ // translateX(%) is relative to the thumb itself: travel = (100 - size)% of the bar.++ thumb.style.transform = `translateX(${((x / max) * (100 - size) / size) * 100}%)`;++ }++ };++ const refresh = () => { if (!raf) raf = requestAnimationFrame(update); };++ track.addEventListener('scroll', refresh, { passive: true });++ if (window.ResizeObserver) new ResizeObserver(refresh).observe(track);++ if (window.MutationObserver) new MutationObserver(refresh).observe(track, { childList: true });++ refresh();++++ track._ovRail = { rail, track, refresh };++ return track._ovRail;++ }++++ window.OVRail = { attach };++})();
@@ -817,13 +817,28 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -817,13 +817,28 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
}
/* ── from i18n-star.css ── */
/* ── from i18n-star.css ── */
-.home-star-section { margin: 4px 0 28px; }
+/* ═══ Star of OpenVibe (decoration and compact grid: css/i18n-star.css) ═══
+ Gold is the card's one fixed hue, but text never uses it raw: --star-ink mixes it with the
+ theme's text colour (light gold on dark themes, deep amber on light ones). */
+.home-star-section {
+ margin: 4px 0 28px;
+ --star-gold: #fbbf24;
+ --star-ink: color-mix(in srgb, #f59e0b 60%, var(--text-primary));
+ --star-pink-ink: color-mix(in srgb, #ec4899 58%, var(--text-primary));
+}
+.home-star-section .section-header h2 > i.star-head-icon { color: var(--star-ink); }
+
+/* Border sweep: a rotating oversized square left a wide desktop card's border in patches. The
+ gradient turns in place instead (registered angle, like the pitch banner), at any card shape. */
+@property --star-sweep { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
+@keyframes starSweep { to { --star-sweep: 360deg; } }
.star-card-border::before {
.star-card-border::before {
- content: ''; position: absolute; inset: -60%; z-index: -1;
- background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);
- animation: starSpin 7s linear infinite;
+ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
+ background: conic-gradient(from var(--star-sweep), var(--star-gold), #f472b6, var(--accent), var(--star-gold));
+ animation: starSweep 7s linear infinite;
}
}
-.star-card.is-live { grid-template-columns: auto 1fr minmax(260px, 340px); }
+.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }
+
.star-petals span:nth-child(1) { left: 6%; animation-delay: 0s; }
.star-petals span:nth-child(1) { left: 6%; animation-delay: 0s; }
.star-petals span:nth-child(2) { left: 22%; animation-delay: -2s; font-size: 0.9rem; }
.star-petals span:nth-child(2) { left: 22%; animation-delay: -2s; font-size: 0.9rem; }
.star-petals span:nth-child(3) { left: 41%; animation-delay: -5s; }
.star-petals span:nth-child(3) { left: 41%; animation-delay: -5s; }
@@ -833,8 +848,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -833,8 +848,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.star-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.star-body { min-width: 0; }
.star-body { min-width: 0; }
-.star-kicker { display: inline-flex; align-items: center; gap: 7px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #fbbf24; margin-bottom: 4px; }
-.star-name { margin: 0 0 6px; font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 900; line-height: 1.1; letter-spacing: -0.01em; }
+.star-kicker { display: inline-flex; align-items: center; gap: 7px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--star-ink); margin-bottom: 4px; }
+.star-name { margin: 0 0 6px; font-size: clamp(1.4rem, 3vw, 2rem); font-weight: 900; line-height: 1.1; letter-spacing: -0.01em; overflow-wrap: anywhere; }
.star-name a { color: inherit; text-decoration: none; }
.star-name a { color: inherit; text-decoration: none; }
.star-name a:hover { color: var(--accent-light); }
.star-name a:hover { color: var(--accent-light); }
.star-name .star-jp { display: block; font-size: 0.55em; font-weight: 600; color: var(--text-secondary); margin-top: 2px; }
.star-name .star-jp { display: block; font-size: 0.55em; font-weight: 600; color: var(--text-secondary); margin-top: 2px; }
@@ -844,38 +859,46 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -844,38 +859,46 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
font-size: 0.74rem; font-weight: 700; color: var(--text-primary);
font-size: 0.74rem; font-weight: 700; color: var(--text-primary);
background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
}
-.star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.12); border-color: rgba(251,191,36,0.45); }
-.star-chip.pink { color: #fbcfe8; background: rgba(244,114,182,0.12); border-color: rgba(244,114,182,0.45); }
+.star-chip.gold { color: var(--star-ink); background: color-mix(in srgb, var(--star-gold) 12%, transparent); border-color: color-mix(in srgb, var(--star-gold) 45%, transparent); }
+.star-chip.pink { color: var(--star-pink-ink); background: color-mix(in srgb, #f472b6 12%, transparent); border-color: color-mix(in srgb, #f472b6 45%, transparent); }
.star-bio { margin: 0 0 4px; color: var(--text-primary); line-height: 1.5; white-space: pre-wrap; font-size: 0.95rem; }
.star-bio { margin: 0 0 4px; color: var(--text-primary); line-height: 1.5; white-space: pre-wrap; font-size: 0.95rem; }
.star-bio-src { margin: 0 0 8px; color: var(--text-secondary); font-size: 0.82rem; white-space: pre-wrap; line-height: 1.45; }
.star-bio-src { margin: 0 0 8px; color: var(--text-secondary); font-size: 0.82rem; white-space: pre-wrap; line-height: 1.45; }
.star-bio-label { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-light); margin: 6px 0 2px; }
.star-bio-label { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-light); margin: 6px 0 2px; }
.star-overview { color: var(--text-secondary); font-size: 0.84rem; line-height: 1.45; margin: 8px 0 0; }
.star-overview { color: var(--text-secondary); font-size: 0.84rem; line-height: 1.45; margin: 8px 0 0; }
-.star-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; margin-bottom: 6px; }
-.star-actions .btn { font-weight: 700; }
.star-btn-watch::after { content: ''; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.6) 50%, transparent 65%); background-size: 250% 100%; animation: starSheen 2.6s linear infinite; pointer-events: none; }
.star-btn-watch::after { content: ''; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.6) 50%, transparent 65%); background-size: 250% 100%; animation: starSheen 2.6s linear infinite; pointer-events: none; }
.star-live-card { align-self: stretch; }
.star-live-card { align-self: stretch; }
.star-live-card .stream-card { height: 100%; }
.star-live-card .stream-card { height: 100%; }
-.star-offline { color: var(--text-muted); font-size: 0.8rem; margin-top: 8px; }
+/* Icon beside the sentence, not above a wrapped second line. */
+.star-offline { display: flex; align-items: baseline; gap: 7px; margin-top: 10px; color: var(--text-muted); font-size: 0.8rem; line-height: 1.4; text-wrap: pretty; }
+.star-offline > i { flex: none; }
@media (max-width: 860px) {
@media (max-width: 860px) {
.home-star-section { margin-bottom: 34px; }
.home-star-section { margin-bottom: 34px; }
- .star-avatar-wrap { margin: 0 auto; }
- .star-chips, .star-actions { justify-content: center; }
.star-live-card { max-width: 420px; width: 100%; margin: 0 auto; }
.star-live-card { max-width: 420px; width: 100%; margin: 0 auto; }
}
}
-
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.star-petals { display: none; }
.star-petals { display: none; }
}
}
-.rs-lite .star-card-border::before { animation: none; background: linear-gradient(135deg, #fbbf24, #f472b6, var(--accent)); inset: 0; }
+.rs-lite .star-card-border::before { animation: none; background: linear-gradient(135deg, var(--star-gold), #f472b6, var(--accent)); inset: 0; }
.rs-lite .star-avatar-glow, .rs-lite .star-petals { display: none; }
.rs-lite .star-avatar-glow, .rs-lite .star-petals { display: none; }
.rs-lite .star-avatar-ring { animation: none; }
.rs-lite .star-avatar-ring { animation: none; }
-.rs-lite .star-btn-watch { box-shadow: none; }
-.star-why { margin: 10px 0 12px; padding: 12px 14px; border-radius: 12px; background: linear-gradient(135deg, rgba(251,191,36,0.12), rgba(244,114,182,0.08)); border: 1px solid rgba(251,191,36,0.35); }
-.star-why-head { font-size: 1.05rem; font-weight: 900; color: #fff; margin-bottom: 4px; }
+
+/* Why they're today's star; "picked by" and the countdown each stay on one line. */
+.star-why {
+ margin: 0 0 12px; padding: 12px 14px; border-radius: 12px;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--star-gold) 12%, transparent), color-mix(in srgb, #f472b6 8%, transparent));
+ border: 1px solid color-mix(in srgb, var(--star-gold) 35%, transparent);
+}
+.star-why-head { font-size: 1.05rem; font-weight: 900; color: var(--text-primary); margin-bottom: 4px; }
.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }
.star-why-body { font-size: 0.92rem; line-height: 1.45; color: var(--text-secondary); }
-.star-why-meta { margin-top: 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #fbbf24; }
-.star-why-meta i { margin-right: 4px; }
+.star-why-meta {
+ display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 9px;
+ font-size: 0.68rem; font-weight: 750; letter-spacing: 0.07em; text-transform: uppercase; color: var(--star-ink);
+}
+.star-why-meta > span { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
+.star-why-meta .star-why-next { color: var(--text-muted); }
+
+/* Compact card: avatar, who they are, and one action row. */
.star-card.is-compact .star-avatar-wrap { grid-area: avatar; width: 62px; height: 62px; align-self: center; }
.star-card.is-compact .star-avatar-wrap { grid-area: avatar; width: 62px; height: 62px; align-self: center; }
.star-card.is-compact .star-avatar { width: 62px; height: 62px; font-size: 1.45rem; }
.star-card.is-compact .star-avatar { width: 62px; height: 62px; font-size: 1.45rem; }
.star-card.is-compact .star-body {
.star-card.is-compact .star-body {
@@ -889,24 +912,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -889,24 +912,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-card.is-compact .star-name .star-jp { font-size: 0.5em; margin-top: 0; }
.star-card.is-compact .star-name .star-jp { font-size: 0.5em; margin-top: 0; }
.star-card.is-compact .star-chips { gap: 5px; margin: 3px 0 0; }
.star-card.is-compact .star-chips { gap: 5px; margin: 3px 0 0; }
.star-card.is-compact .star-chip {
.star-card.is-compact .star-chip {
- font-size: 0.68rem; padding: 2px 9px; border-radius: 999px;
- background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.12); color: var(--text-secondary);
-}
-.star-card.is-compact .star-chip.gold { color: #fde68a; background: rgba(251,191,36,0.1); border-color: rgba(251,191,36,0.3); }
-.star-card.is-compact .star-actions {
- grid-area: actions; display: flex; flex-wrap: wrap; align-items: center;
- gap: 8px; margin: 4px 0 0;
-}
-.star-card.is-compact .star-actions .btn,
-.star-card.is-compact .star-expand {
- height: 38px; padding: 0 15px; font-size: 0.82rem; border-radius: 11px;
- display: inline-flex; align-items: center; gap: 8px; line-height: 1;
-}
-.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }
-.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }
-.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }
-.star-expand.is-open { padding-right: 16px; }
-.star-expand.is-open span { font-weight: 800; }
+ font-size: 0.68rem; padding: 2px 9px; border-radius: 999px; color: var(--text-secondary);
+ background: color-mix(in srgb, var(--text-primary) 5%, transparent);
+ border-color: color-mix(in srgb, var(--text-primary) 14%, transparent);
+}
+.star-card.is-compact .star-chip.gold { color: var(--star-ink); background: color-mix(in srgb, var(--star-gold) 10%, transparent); border-color: color-mix(in srgb, var(--star-gold) 32%, transparent); }
.star-more[hidden] { display: none !important; }
.star-more[hidden] { display: none !important; }
.star-more { display: block; grid-column: 1 / -1; }
.star-more { display: block; grid-column: 1 / -1; }
.star-card > .star-actions, .star-card > .star-more { grid-column: 1 / -1; }
.star-card > .star-actions, .star-card > .star-more { grid-column: 1 / -1; }
@@ -914,7 +924,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -914,7 +924,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }
.star-card.is-swapping { overflow: hidden; transition: height 0.42s cubic-bezier(.2, 1, .3, 1); }
.star-card.is-compact .star-avatar-ring {
.star-card.is-compact .star-avatar-ring {
inset: -3px; border-radius: 50%;
inset: -3px; border-radius: 50%;
- background: conic-gradient(from 0deg, #fbbf24, #f472b6, var(--accent), #fbbf24);
+ background: conic-gradient(from 0deg, var(--star-gold), #f472b6, var(--accent), var(--star-gold));
animation: starRingSpin 6s linear infinite;
animation: starRingSpin 6s linear infinite;
opacity: 0.9;
opacity: 0.9;
}
}
@@ -922,24 +932,53 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -922,24 +932,53 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-card.is-compact .star-avatar { transition: transform 0.28s cubic-bezier(.2, 1.2, .3, 1); }
.star-card.is-compact .star-avatar { transition: transform 0.28s cubic-bezier(.2, 1.2, .3, 1); }
.star-card.is-compact:hover .star-avatar { transform: scale(1.04); }
.star-card.is-compact:hover .star-avatar { transform: scale(1.04); }
-.star-expand {
- cursor: pointer; font: inherit; font-weight: 750;
- color: color-mix(in srgb, #fbbf24 82%, var(--text-primary));
- background: color-mix(in srgb, #fbbf24 9%, transparent);
- border: 1px solid color-mix(in srgb, #fbbf24 26%, transparent);
- transition: transform 0.2s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;
-}
-.star-expand:hover {
- transform: translateY(-1px);
- background: color-mix(in srgb, #fbbf24 17%, transparent);
- border-color: color-mix(in srgb, #fbbf24 50%, transparent);
- box-shadow: 0 6px 16px color-mix(in srgb, #fbbf24 20%, transparent);
-}
-.star-expand:active { transform: translateY(0) scale(0.985); }
-.star-expand:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; }
-.star-expand.is-open i { transform: rotate(180deg); }
-.star-card.is-compact ~ .star-card-border::before,
-.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }
+/* ── Action row: one family ──────────────────────────────────────────────────
+ One height, radius, type size and icon placement: gold filled primary, two identical outline
+ buttons, never wrapping. On a phone the toggle is a square chevron button (label kept for
+ screen readers). It used to open into a box with the chevron stacked above "Show less". */
+.star-actions { display: flex; align-items: center; gap: 8px; min-width: 0; margin: 4px 0 0; }
+.star-card.is-compact .star-actions { grid-area: actions; }
+.star-act {
+ --star-act-h: 40px;
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+ flex: 0 0 auto; min-width: 0; height: var(--star-act-h); padding: 0 15px;
+ border-radius: 12px; font: inherit; font-size: 0.84rem; font-weight: 750; line-height: 1;
+ white-space: nowrap; text-decoration: none; cursor: pointer;
+ color: var(--text-primary);
+ background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+ border: 1px solid color-mix(in srgb, var(--text-primary) 16%, transparent);
+ transition: background 0.18s, border-color 0.18s, box-shadow 0.2s, translate 0.2s cubic-bezier(.2, 1, .3, 1), scale 0.12s;
+}
+.star-act > i { flex: none; font-size: 0.92em; color: var(--star-ink); transition: rotate 0.3s cubic-bezier(.2, 1, .3, 1); }
+.star-act > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+.star-act:hover {
+ color: var(--text-primary); translate: 0 -1px;
+ background: color-mix(in srgb, var(--star-gold) 10%, transparent);
+ border-color: color-mix(in srgb, var(--star-gold) 45%, transparent);
+}
+.star-act:active { translate: none; scale: var(--press-scale, 0.97); }
+.star-act:focus-visible { outline: 2px solid var(--star-ink); outline-offset: 2px; }
+.star-act.star-btn-watch {
+ flex: 1 0 auto; position: relative; overflow: hidden;
+ color: #1c1002; border: 0;
+ background: linear-gradient(135deg, #fde68a, #fbbf24 50%, #f59e0b);
+ box-shadow: 0 6px 18px color-mix(in srgb, #fbbf24 32%, transparent);
+}
+.star-act.star-btn-watch > i { color: inherit; }
+.star-act.star-btn-watch:hover {
+ color: #1c1002;
+ background: linear-gradient(135deg, #fef3c7, #fcd34d 50%, #fbbf24);
+ box-shadow: 0 8px 22px color-mix(in srgb, #fbbf24 42%, transparent);
+}
+.star-expand { flex: 0 1 auto; }
+.star-expand.is-open {
+ background: color-mix(in srgb, var(--star-gold) 12%, transparent);
+ border-color: color-mix(in srgb, var(--star-gold) 42%, transparent);
+}
+.star-expand.is-open > i { rotate: 180deg; }
+.rs-lite .star-act.star-btn-watch { box-shadow: none; }
+
+/* Expanded card: the compact card's shape, with the detail revealed under it. */
.star-card:not(.is-compact) .star-avatar-wrap,
.star-card:not(.is-compact) .star-avatar-wrap,
.star-card:not(.is-compact) .star-avatar { width: 76px; height: 76px; }
.star-card:not(.is-compact) .star-avatar { width: 76px; height: 76px; }
.star-card:not(.is-compact) .star-avatar { font-size: 1.7rem; }
.star-card:not(.is-compact) .star-avatar { font-size: 1.7rem; }
@@ -951,37 +990,48 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -951,37 +990,48 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-card:not(.is-compact) .star-badge { display: none; }
.star-card:not(.is-compact) .star-badge { display: none; }
.star-card:not(.is-compact) .star-more { text-align: left; }
.star-card:not(.is-compact) .star-more { text-align: left; }
.star-card:not(.is-compact) .star-more > * { margin-left: 0; margin-right: 0; }
.star-card:not(.is-compact) .star-more > * { margin-left: 0; margin-right: 0; }
-.star-card:not(.is-compact) .star-actions { justify-content: flex-start; }
.star-more {
.star-more {
margin-top: 12px; padding: 13px 15px; border-radius: 13px;
margin-top: 12px; padding: 13px 15px; border-radius: 13px;
background: color-mix(in srgb, var(--bg-primary) 45%, transparent);
background: color-mix(in srgb, var(--bg-primary) 45%, transparent);
- border: 1px solid color-mix(in srgb, #fbbf24 13%, var(--border));
+ border: 1px solid color-mix(in srgb, var(--star-gold) 13%, var(--border));
}
}
.star-more > *:first-child { margin-top: 0; }
.star-more > *:first-child { margin-top: 0; }
.star-more > *:last-child { margin-bottom: 0; }
.star-more > *:last-child { margin-bottom: 0; }
+
+/* Wide: avatar, who they are, and the actions on the right, on one line. */
@media (min-width: 820px) {
@media (min-width: 820px) {
- .star-card.is-compact .star-actions { margin: 0; justify-content: flex-end; flex-wrap: nowrap; }
- .star-card.is-compact .star-btn-watch { flex: 0 0 auto; min-width: 0; }
- .star-card.is-compact .star-expand { margin-left: 0; }
- .star-card.is-compact .star-more { grid-column: 1 / -1; }
+ .star-card.is-compact .star-actions { margin: 0; justify-content: flex-end; }
+ .star-card.is-compact .star-btn-watch { flex: 0 0 auto; }
+ .star-card:not(.is-compact) .star-actions { justify-content: flex-start; }
+ .star-card:not(.is-compact) .star-btn-watch { flex: 0 0 auto; }
}
}
-
+/* Phone: no panel-in-a-panel, and the toggle's label is visually hidden (still its name). */
@media (max-width: 560px) {
@media (max-width: 560px) {
.star-card.is-compact .star-avatar-wrap,
.star-card.is-compact .star-avatar-wrap,
.star-card.is-compact .star-avatar { width: 52px; height: 52px; }
.star-card.is-compact .star-avatar { width: 52px; height: 52px; }
.star-card.is-compact .star-avatar { font-size: 1.2rem; }
.star-card.is-compact .star-avatar { font-size: 1.2rem; }
- .star-card.is-compact .star-actions .btn,
- .star-card.is-compact .star-expand { height: 36px; font-size: 0.78rem; padding: 0 12px; }
- .star-card.is-compact .star-btn-watch { flex: 1 1 100%; }
- .star-card.is-compact .star-expand { margin-left: 0; flex: 1 1 auto; justify-content: center; }
+ .star-act { padding: 0 14px; font-size: 0.82rem; }
+ .star-act-extra { display: none; }
+ .star-act.star-btn-watch { flex: 1 1 auto; }
+ .star-expand { flex: none; width: var(--star-act-h); padding: 0; }
+ .star-expand > i { font-size: 0.95rem; }
+ .star-expand-label {
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
+ }
+ .star-more { padding: 12px 0 0; border: 0; border-top: 1px solid color-mix(in srgb, var(--star-gold) 16%, var(--border)); border-radius: 0; background: none; }
}
}
-@media (max-width: 380px) {
- .star-card.is-compact .star-actions .btn-outline { flex: 1 1 100%; justify-content: center; }
+/* A 320px phone cannot fit three labelled buttons: "Say hi" goes to its icon as well. */
+@media (max-width: 350px) {
+ .star-act:not(.star-btn-watch) { flex: none; width: var(--star-act-h); padding: 0; }
+ .star-act:not(.star-btn-watch) > span {
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
+ }
}
}
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.star-more.is-entering { animation: none; }
.star-more.is-entering { animation: none; }
.star-card.is-swapping { transition: none; }
.star-card.is-swapping { transition: none; }
.star-card.is-compact .star-avatar-ring { animation: none; }
.star-card.is-compact .star-avatar-ring { animation: none; }
+ .star-act, .star-act > i { transition: none; }
}
}
/* The pitch banner: what kind of place this is, before the headline says it. */
/* The pitch banner: what kind of place this is, before the headline says it. */
@@ -1079,3 +1129,47 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1079,3 +1129,47 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
#page-home .hero .hero-philosophy-links { margin: var(--hero-step) auto 0; row-gap: 6px; }
#page-home .hero .hero-philosophy-links { margin: var(--hero-step) auto 0; row-gap: 6px; }
#page-home .hero .hero-latest { margin: var(--hero-step) auto 0; }
#page-home .hero .hero-latest { margin: var(--hero-step) auto 0; }
#page-home .hero .hero-stats { margin-top: var(--hero-step); }
#page-home .hero .hero-stats { margin-top: var(--hero-step); }
+
+/* ── Section header actions ("View all", "Play game") ─────────────────────────
+ These were .badge links on var(--surface), a token no theme defines, so white text on nothing
+ (invisible on light themes). One pill, tinted by the section's colour. */
+.section-header > .section-action {
+ display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px;
+ border-radius: 999px; font: inherit; font-size: 0.74rem; font-weight: 750; line-height: 1;
+ white-space: nowrap; text-decoration: none; cursor: pointer;
+ color: color-mix(in srgb, var(--sc-on) 50%, var(--text-primary));
+ background: color-mix(in srgb, var(--sc-on) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--sc-on) 28%, transparent);
+ transition: background 0.15s, border-color 0.15s, color 0.15s, scale 0.12s;
+}
+.section-header > .section-action:hover {
+ color: var(--text-primary);
+ background: color-mix(in srgb, var(--sc-on) 18%, transparent);
+ border-color: color-mix(in srgb, var(--sc-on) 50%, transparent);
+}
+/* Phone: a header's sentence gets its own line under the title instead of being cut off. */
+@media (max-width: 560px) {
+ .section-header:has(> span.muted) { flex-wrap: wrap; row-gap: 3px; }
+ .section-header > span.muted {
+ order: 5; flex: 1 0 100%; margin-left: 34px;
+ white-space: normal; overflow: visible; line-height: 1.35; text-wrap: pretty;
+ }
+}
+
+/* The changelog's "show all" lives under the list it expands. */
+.home-changelog-toggle { margin: 12px auto 0; }
+.home-changelog-toggle > i { transition: rotate 0.3s cubic-bezier(.2, 1, .3, 1); }
+.home-changelog-toggle[aria-expanded="true"] > i { rotate: 180deg; }
+
+/* Network hub two-up on a phone: the icon beside the text left ~90px, and "OpenVibe.Community"
+ ran out of its card. Icon above text there; names may break after "OpenVibe." (<wbr>). */
+.network-card-info strong { overflow-wrap: break-word; }
+@media (min-width: 361px) and (max-width: 480px) {
+ .network-card { flex-direction: column; align-items: flex-start; gap: 8px; }
+ .network-card-info { width: 100%; }
+}
+/* The ≤480px rule hiding the arrow lost to Font Awesome's later .fa-solid display rule. */
+@media (max-width: 480px) { .network-card > .network-card-arrow { display: none; } }
+
+/* Terms · Privacy · DMCA wrap as one group ("DMCA" was stranded alone at 360px). */
+.hero-legal { display: inline-flex; align-items: center; gap: inherit; white-space: nowrap; }
@@ -109,11 +109,6 @@
@@ -109,11 +109,6 @@
}
}
@keyframes starSheen { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
@keyframes starSheen { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
-@media (max-width: 860px) {
- .star-card, .star-card.is-live { grid-template-columns: 1fr; text-align: center; gap: 14px; padding: 20px 16px 34px; }
- .star-actions .btn { flex: 1 1 140px; justify-content: center; }
-}
-
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.chat-translation, .chat-translate-hint, .ch-captions, .ch-caption, .ch-captions-dot, .ch-about-bio-translated,
.chat-translation, .chat-translate-hint, .ch-captions, .ch-caption, .ch-captions-dot, .ch-about-bio-translated,
.star-card-border, .star-card-border::before, .star-petals span, .star-avatar-ring, .star-avatar-glow, .star-live-pill,
.star-card-border, .star-card-border::before, .star-petals span, .star-avatar-ring, .star-avatar-glow, .star-live-pill,
@@ -172,13 +167,6 @@
@@ -172,13 +167,6 @@
/* The avatar ring: a slow conic sweep rather than a static rainbow, so the card has one moving
/* The avatar ring: a slow conic sweep rather than a static rainbow, so the card has one moving
element instead of several competing ones. Paused off-screen by the viewport gate. */
element instead of several competing ones. Paused off-screen by the viewport gate. */
@keyframes starRingSpin { to { transform: rotate(360deg); } }
@keyframes starRingSpin { to { transform: rotate(360deg); } }
-.star-expand i {
- width: 19px; height: 19px; flex: none; border-radius: 50%; display: grid; place-items: center;
- font-size: 0.55rem; color: #1a1506; line-height: 1;
- background: linear-gradient(140deg, #fbbf24, #f59e0b);
- transition: transform 0.3s cubic-bezier(.2, 1, .3, 1);
-}
-.star-expand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The gold border sweep is the card's one loud effect; calm it down when compact so it frames
/* The gold border sweep is the card's one loud effect; calm it down when compact so it frames
the card instead of shouting over it. */
the card instead of shouting over it. */
@@ -886,6 +886,24 @@ main#app { position: relative; z-index: 1; }
@@ -886,6 +886,24 @@ main#app { position: relative; z-index: 1; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
+/* ── Touch & keyboard feedback ────────────────────────────────────
+ No browser tap flash (a square blue box on every theme; inherited, so the shared chrome is
+ covered too). A pressed control shrinks slightly instead, and keyboard focus gets one ring in
+ the theme accent. :where() keeps it all at zero specificity, so a component's own :active or
+ :focus-visible wins; `scale` composes with any transform. Opt in with .ov-press / .ov-press-card. */
+html { -webkit-tap-highlight-color: transparent; }
+:root { --press-scale: 0.97; --press-scale-card: 0.985; }
+:where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap):not(:disabled, [aria-disabled="true"]):active { scale: var(--press-scale); }
+:where(a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):active { scale: var(--press-scale-card); }
+:where(a, button, summary, [role="button"], [tabindex]:not([tabindex="-1"]), input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {
+ outline: 2px solid var(--accent-light, var(--accent)); outline-offset: 2px;
+}
+@media (prefers-reduced-motion: reduce) {
+ /* No movement: the press is a brief dim instead. */
+ :root { --press-scale: 1; --press-scale-card: 1; }
+ :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }
+}
+
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
NAVBAR
NAVBAR
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
@@ -1902,11 +1920,60 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -1902,11 +1920,60 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.pulse-rank a:hover { color: var(--accent); }
.pulse-rank a:hover { color: var(--accent); }
.pulse-rank b { font-variant-numeric: tabular-nums; }
.pulse-rank b { font-variant-numeric: tabular-nums; }
+/* ── Horizontal rail (public/js/ov-rail.js) ──────────────────────
+ No native scrollbar; cards snap; the edge with more fades (a mask, so any background works); an
+ accent bar shows position; mouse users get prev/next buttons. --rail-accent sets the bar colour. */
+.ov-rail { position: relative; margin-bottom: 22px; --rail-fade: 32px; }
+.ov-rail-track {
+ display: flex; gap: 12px; overflow-x: auto; overflow-y: hidden;
+ scroll-snap-type: x mandatory; scroll-behavior: smooth; overscroll-behavior-x: contain;
+ scroll-padding-inline: 2px; scrollbar-width: none;
+ /* Room for a card's hover lift and focus ring, without moving anything. */
+ padding: 4px 2px; margin: -4px -2px 0;
+ --fl: 0px; --fr: 0px;
+}
+.ov-rail-track::-webkit-scrollbar { display: none; }
+.ov-rail-track > * { flex-shrink: 0; scroll-snap-align: start; }
+.ov-rail-track > :focus-visible { outline-offset: -3px; }
+.ov-rail:not(.is-start) > .ov-rail-track { --fl: var(--rail-fade); }
+.ov-rail:not(.is-end) > .ov-rail-track { --fr: var(--rail-fade); }
+.ov-rail.is-scrollable > .ov-rail-track {
+ -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--fl), #000 calc(100% - var(--fr)), transparent);
+ mask-image: linear-gradient(90deg, transparent, #000 var(--fl), #000 calc(100% - var(--fr)), transparent);
+}
+/* A mask would clip the rail's own focus ring. */
+.ov-rail-track:focus-visible { -webkit-mask-image: none !important; mask-image: none !important; outline-offset: 1px; border-radius: 12px; }
+.ov-rail-btn {
+ position: absolute; z-index: 2; top: 0; bottom: 15px; margin: auto 0;
+ width: 36px; height: 36px; padding: 0; border-radius: 50%;
+ display: grid; place-items: center; font-size: 0.78rem; cursor: pointer;
+ color: var(--text-primary);
+ background: color-mix(in srgb, var(--bg-card) 90%, transparent);
+ border: 1px solid var(--border-light); box-shadow: var(--shadow);
+ transition: opacity 0.2s, background 0.15s, color 0.15s, border-color 0.15s, scale 0.12s;
+}
+.ov-rail-prev { left: 8px; }
+.ov-rail-next { right: 8px; }
+.ov-rail-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
+.ov-rail-btn:disabled { opacity: 0; pointer-events: none; }
+.ov-rail:not(.is-scrollable) > .ov-rail-btn { display: none; }
+.ov-rail-bar {
+ position: relative; height: 3px; width: min(180px, 42%); margin: 8px auto 0;
+ border-radius: 3px; overflow: hidden;
+ background: color-mix(in srgb, var(--text-muted) 24%, transparent);
+}
+.ov-rail-bar[hidden] { display: none; }
+.ov-rail-bar > span { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--rail-accent, var(--accent)); }
+/* Touch screens swipe; buttons there would only cover the cards. */
+@media (hover: none), (pointer: coarse) { .ov-rail-btn { display: none; } }
+@media (prefers-reduced-motion: reduce) { .ov-rail-track { scroll-behavior: auto; } }
+
/* ── AI Moments showcase row ── */
/* ── AI Moments showcase row ── */
-.moments-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; margin-bottom: 22px; scrollbar-width: thin; }
+.moments-row { display: flex; gap: 12px; overflow-x: auto; margin-bottom: 22px; }
+.ov-rail > .moments-row { margin-bottom: 0; }
.moment-card {
.moment-card {
position: relative; flex: 0 0 220px; height: 124px; border-radius: 12px; overflow: hidden;
position: relative; flex: 0 0 220px; height: 124px; border-radius: 12px; overflow: hidden;
- border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease;
+ border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease, scale 0.12s ease;
}
}
.moment-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.moment-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.moment-overlay {
.moment-overlay {
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
"/css/home-tour.css"
"/css/home-tour.css"
],
],
"js": [
"js": [
+ "/js/ov-rail.js",
"/js/app-home.js",
"/js/app-home.js",
"/js/ov-odometer.js",
"/js/ov-odometer.js",
"/js/ov-density.js",
"/js/ov-density.js",
@@ -218,11 +218,11 @@
@@ -218,11 +218,11 @@
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
<i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
<i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
</a>
</a>
- <a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
+ <span class="hero-legal"><a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
<span aria-hidden="true">•</span>
<span aria-hidden="true">•</span>
<a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
<a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
<span aria-hidden="true">•</span>
<span aria-hidden="true">•</span>
- <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a>
+ <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a></span>
</div>
</div>
<!-- Newest shipped commit — built in the open, receipts included. Sits under the
<!-- Newest shipped commit — built in the open, receipts included. Sits under the
philosophy row so the proof is next to the promise. -->
philosophy row so the proof is next to the promise. -->
@@ -312,7 +312,7 @@
@@ -312,7 +312,7 @@
<h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
<h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
<span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
<span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
</div>
</div>
- <div class="moments-row" id="home-moments-row"></div>
+ <div class="moments-row ov-rail-track" id="home-moments-row"></div>
</div>
</div>
<!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
<!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
@@ -473,7 +473,7 @@
@@ -473,7 +473,7 @@
<!-- Recent VODs -->
<!-- Recent VODs -->
<div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
<div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
- <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
+ <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="section-action">View all</a>
</div>
</div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
@@ -481,7 +481,7 @@
@@ -481,7 +481,7 @@
<!-- Recent Clips -->
<!-- Recent Clips -->
<div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
<div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
- <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
+ <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="section-action">View all</a>
</div>
</div>
<div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
<div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
@@ -489,7 +489,7 @@
@@ -489,7 +489,7 @@
<!-- Recent Pastes -->
<!-- Recent Pastes -->
<div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
<div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
+ <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="section-action">View all</a>
</div>
</div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
@@ -497,14 +497,14 @@
@@ -497,14 +497,14 @@
<!-- Scraplandia Leaderboards -->
<!-- Scraplandia Leaderboards -->
<div class="section-header" style="--sc:#fbbf24;display:none" id="home-quest-header">
<div class="section-header" style="--sc:#fbbf24;display:none" id="home-quest-header">
<h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2>
<h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2>
- <a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a>
+ <a href="https://openvibe.games/game" class="section-action">Play game</a>
</div>
</div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<!-- Canvas Preview -->
<!-- Canvas Preview -->
<div class="section-header" style="--sc:#4ade80;display:none" id="home-canvas-header">
<div class="section-header" style="--sc:#4ade80;display:none" id="home-canvas-header">
<h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2>
<h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2>
- <a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a>
+ <a href="https://openvibe.games/canvas" class="section-action">Open canvas</a>
</div>
</div>
<div class="home-canvas-preview" id="home-canvas-preview"></div>
<div class="home-canvas-preview" id="home-canvas-preview"></div>
@@ -520,7 +520,7 @@
@@ -520,7 +520,7 @@
<a href="https://openvibe.live" class="network-card network-card-featured">
<a href="https://openvibe.live" class="network-card network-card-featured">
<div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div>
<div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div>
<div class="network-card-info">
<div class="network-card-info">
- <strong>OpenVibe.Live</strong>
+ <strong>OpenVibe.<wbr>Live</strong>
<span>Live streaming, VODs, clips & chat</span>
<span>Live streaming, VODs, clips & chat</span>
</div>
</div>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
@@ -528,7 +528,7 @@
@@ -528,7 +528,7 @@
<a href="https://openvibe.tools" class="network-card">
<a href="https://openvibe.tools" class="network-card">
<div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div>
<div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div>
<div class="network-card-info">
<div class="network-card-info">
- <strong>OpenVibe.Tools</strong>
+ <strong>OpenVibe.<wbr>Tools</strong>
<span>Every online tool in one index</span>
<span>Every online tool in one index</span>
</div>
</div>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
@@ -536,7 +536,7 @@
@@ -536,7 +536,7 @@
<a href="https://openvibe.community" class="network-card">
<a href="https://openvibe.community" class="network-card">
<div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div>
<div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div>
<div class="network-card-info">
<div class="network-card-info">
- <strong>OpenVibe.Community</strong>
+ <strong>OpenVibe.<wbr>Community</strong>
<span>Pastes, posts and people</span>
<span>Pastes, posts and people</span>
</div>
</div>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
@@ -560,7 +560,7 @@
@@ -560,7 +560,7 @@
<a href="https://openvibe.media" class="network-card">
<a href="https://openvibe.media" class="network-card">
<div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div>
<div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div>
<div class="network-card-info">
<div class="network-card-info">
- <strong>OpenVibe.Media</strong>
+ <strong>OpenVibe.<wbr>Media</strong>
<span>VODs, clips and files</span>
<span>VODs, clips and files</span>
</div>
</div>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
<i class="fa-solid fa-arrow-right network-card-arrow"></i>
@@ -651,10 +651,7 @@
@@ -651,10 +651,7 @@
<!-- OpenVibe.Live Changelog -->
<!-- OpenVibe.Live Changelog -->
<div class="section-header" style="--sc:#94a3b8;margin-top:32px;">
<div class="section-header" style="--sc:#94a3b8;margin-top:32px;">
<h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
<h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
- <div style="display:flex;gap:8px;align-items:center;">
- <button id="home-changelog-toggle" class="badge" onclick="toggleHomeChangelog()" style="cursor:pointer;border:none;background:var(--surface);padding:4px 10px;border-radius:6px;color:var(--text);font-size:0.78rem;">Show All</button>
- <a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="badge" style="cursor:pointer;text-decoration:none;">View All</a>
- </div>
+ <a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="section-action">View all</a>
</div>
</div>
<div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
<div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
<div id="home-changelog" class="home-changelog">
<div id="home-changelog" class="home-changelog">
@@ -662,6 +659,9 @@
@@ -662,6 +659,9 @@
</div>
</div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</div>
</div>
+ <!-- Expands the preview in place; "View all" in the header opens /updates. It sits under
+ the list it expands, in the same button style as the rails' "Load more". -->
+ <button type="button" id="home-changelog-toggle" class="home-load-more home-changelog-toggle" onclick="toggleHomeChangelog()" aria-expanded="false" aria-controls="home-changelog-wrapper"><i class="fa-solid fa-chevron-down" aria-hidden="true"></i><span>Show all recent changes</span></button>
@@ -1032,6 +1032,8 @@ function _wireEggKeys() {
@@ -1032,6 +1032,8 @@ function _wireEggKeys() {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === 'Backspace') { _eggBuf.pop(); _renderEggSlots(); return; }
if (e.key === 'Backspace') { _eggBuf.pop(); _renderEggSlots(); return; }
let tok = _EGG_ARROW[e.key];
let tok = _EGG_ARROW[e.key];
+ // A focused card rail scrolls with the arrow keys; the secret must not swallow them.
+ if (tok && t && t.closest && t.closest('.ov-rail-track')) return;
if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();
if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();
if (!tok) return;
if (!tok) return;
if (_EGG_ARROW[e.key]) e.preventDefault(); // don't scroll the page on arrows
if (_EGG_ARROW[e.key]) e.preventDefault(); // don't scroll the page on arrows
@@ -1291,6 +1293,8 @@ function _renderHomePulse(p) {
@@ -1291,6 +1293,8 @@ function _renderHomePulse(p) {
${m.username ? `<div class="moment-user">@${esc(m.username)}</div>` : ''}
${m.username ? `<div class="moment-user">@${esc(m.username)}</div>` : ''}
</div>
</div>
</a>`).join('');
</a>`).join('');
+ // Hidden scrollbar, snap, arrows, position bar (public/js/ov-rail.js). Idempotent.
+ if (window.OVRail && ms.length) OVRail.attach(momentsRow, { label: 'AI Moments' });
}
}
}
}
@@ -1322,7 +1326,7 @@ async function loadHomeDigest() {
@@ -1322,7 +1326,7 @@ async function loadHomeDigest() {
const stats = [
const stats = [
st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',
st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',
- st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',
+ st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat line${st.chat_lines === 1 ? '' : 's'}</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
st.mic_moments ? `<span class="digest-stat"><i class="fa-solid fa-microphone-lines"></i> <b>${n(st.mic_moments)}</b> Arena mic moment${st.mic_moments === 1 ? '' : 's'}</span>` : '',
st.mic_moments ? `<span class="digest-stat"><i class="fa-solid fa-microphone-lines"></i> <b>${n(st.mic_moments)}</b> Arena mic moment${st.mic_moments === 1 ? '' : 's'}</span>` : '',
@@ -2045,7 +2049,10 @@ function toggleHomeChangelog() {
@@ -2045,7 +2049,10 @@ function toggleHomeChangelog() {
if (!wrapper || !btn) return;
if (!wrapper || !btn) return;
const expanded = wrapper.classList.toggle('expanded');
const expanded = wrapper.classList.toggle('expanded');
wrapper.classList.toggle('collapsed', !expanded);
wrapper.classList.toggle('collapsed', !expanded);
- btn.textContent = expanded ? 'Show Less' : 'Show All';
+ btn.setAttribute('aria-expanded', String(expanded));
+ const label = btn.querySelector('span');
+ if (label) label.textContent = expanded ? 'Show fewer changes' : 'Show all recent changes';
+ else btn.textContent = expanded ? 'Show Less' : 'Show All';
}
}
@@ -2087,8 +2094,10 @@ function _renderHomeStar(data) {
@@ -2087,8 +2094,10 @@ function _renderHomeStar(data) {
}
}
const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
const pick = s.pick || null;
const pick = s.pick || null;
+ // Who picked them and when the next star lands are two separate facts, each kept on one line:
+ // as one uppercase run the countdown broke in half ("NEXT STAR / IN 3H") on a phone.
const why = pick && (pick.headline || pick.reason)
const why = pick && (pick.headline || pick.reason)
- ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}${pick.next_at ? ` · next star ${esc(_starCountdown(pick.next_at))}` : ''}</div></div>`
+ ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`
: '';
: '';
const chips = [
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
@@ -2097,11 +2106,11 @@ function _renderHomeStar(data) {
@@ -2097,11 +2106,11 @@ function _renderHomeStar(data) {
(s.follower_count > 0) ? `<span class="star-chip gold"><i class="fa-solid fa-heart"></i> ${esc(String(s.follower_count))} follower${s.follower_count === 1 ? '' : 's'}</span>` : '',
(s.follower_count > 0) ? `<span class="star-chip gold"><i class="fa-solid fa-heart"></i> ${esc(String(s.follower_count))} follower${s.follower_count === 1 ? '' : 's'}</span>` : '',
].filter(Boolean).join('');
].filter(Boolean).join('');
const isLive = !!s.live;
const isLive = !!s.live;
- const offline = !isLive && s.last_live_at ? `<div class="star-offline"><i class="fa-regular fa-clock"></i> Last live ${esc(timeAgo(s.last_live_at))} — follow to get pinged next time.</div>` : '';
+ const offline = !isLive && s.last_live_at ? `<div class="star-offline"><i class="fa-regular fa-clock"></i><span>Last live ${esc(timeAgo(s.last_live_at))} — follow to get pinged next time.</span></div>` : '';
const liveCard = isLive ? `<div class="star-live-card">${streamCardHTML(s.live, true)}</div>` : '';
const liveCard = isLive ? `<div class="star-live-card">${streamCardHTML(s.live, true)}</div>` : '';
sec.innerHTML = `
sec.innerHTML = `
<div class="section-header">
<div class="section-header">
- <h2><i class="fa-solid fa-star" style="color:#fbbf24"></i> Star of OpenVibe</h2>
+ <h2><i class="fa-solid fa-star star-head-icon"></i> Star of OpenVibe</h2>
<span class="muted" style="font-size:0.8rem">${s.rotates ? 'a new star every day — picked by the site\'s AI from who actually showed up' : 'the streamer we\'re rolling out the red carpet for'}</span>
<span class="muted" style="font-size:0.8rem">${s.rotates ? 'a new star every day — picked by the site\'s AI from who actually showed up' : 'the streamer we\'re rolling out the red carpet for'}</span>
</div>
</div>
<div class="star-card-border">
<div class="star-card-border">
@@ -2126,11 +2135,11 @@ function _renderHomeStar(data) {
@@ -2126,11 +2135,11 @@ function _renderHomeStar(data) {
${offline}
${offline}
</div>
</div>
<div class="star-actions">
<div class="star-actions">
- <a class="btn btn-lg star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}"></i> ${isLive ? 'Watch now' : 'Visit channel'}</a>
- <a class="btn btn-outline btn-lg" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments"></i> Say hi${foreign ? ' — any language works' : ''}</a>
- <button type="button" class="star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more">
- <i class="fa-solid fa-chevron-down"></i><span>More about ${name}</span>
- </button>
+ <a class="star-act star-btn-watch" href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')"><i class="fa-solid ${isLive ? 'fa-play' : 'fa-user'}" aria-hidden="true"></i><span>${isLive ? 'Watch now' : 'Visit channel'}</span></a>
+ <a class="star-act" href="${esc(path)}#about" onclick="return handleLinkClick(event, '${esc(path)}#about')"><i class="fa-solid fa-comments" aria-hidden="true"></i><span>Say hi${foreign ? '<span class="star-act-extra"> — any language works</span>' : ''}</span></a>
+ <button type="button" class="star-act star-expand" id="star-expand" aria-expanded="false" aria-controls="star-more" title="More about ${name}">
+ <i class="fa-solid fa-chevron-down" aria-hidden="true"></i><span class="star-expand-label">More about ${name}</span>
+ </button>
</div>
</div>
</div>
</div>
</div>`;
</div>`;
@@ -2142,8 +2151,12 @@ function _renderHomeStar(data) {
@@ -2142,8 +2151,12 @@ function _renderHomeStar(data) {
const btn = sec.querySelector('#star-expand');
const btn = sec.querySelector('#star-expand');
if (card && btn && more) {
if (card && btn && more) {
card.classList.add('is-compact');
card.classList.add('is-compact');
+ // On a phone the toggle is an icon button; the label stays in the DOM (visually hidden)
+ // so it is still the button's accessible name, and the title is the tooltip.
const label = () => {
const label = () => {
- btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Show less';
+ const text = more.hidden ? `More about ${s.display_name || s.username}` : 'Show less';
+ btn.querySelector('.star-expand-label').textContent = text;
+ btn.title = text;
btn.classList.toggle('is-open', !more.hidden);
btn.classList.toggle('is-open', !more.hidden);
btn.setAttribute('aria-expanded', String(!more.hidden));
btn.setAttribute('aria-expanded', String(!more.hidden));
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+/**
+ * ov-rail.js — one treatment for every horizontal card rail (styles: .ov-rail in css/style.css).
+ * Replaces the browser's grey scrollbar with: snapping cards (native swipe on touch), a fade on
+ * the edge that has more, a thin accent position bar, and prev/next buttons for mouse users that
+ * disappear at the ends. The rail is focusable (arrow keys scroll it); the buttons are real buttons.
+ *
+ * OVRail.attach(trackElement, { label: 'AI Moments' })
+ *
+ * Idempotent; the track keeps its id and children, and re-measures when they change.
+ */
+(function () {
+ 'use strict';
+ if (typeof window === 'undefined' || window.OVRail) return;
+
+ let seq = 0;
+ const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+
+ function button(dir, track, label) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = `ov-rail-btn ov-rail-${dir}`;
+ b.setAttribute('aria-label', `${dir === 'prev' ? 'Scroll back' : 'Scroll forward'}${label ? ` in ${label}` : ''}`);
+ b.setAttribute('aria-controls', track.id);
+ b.disabled = true;
+ b.innerHTML = `<i class="fa-solid fa-chevron-${dir === 'prev' ? 'left' : 'right'}" aria-hidden="true"></i>`;
+ return b;
+ }
+
+ function attach(track, opts = {}) {
+ if (!track) return null;
+ if (track._ovRail) { track._ovRail.refresh(); return track._ovRail; }
+
+ let rail = track.parentElement;
+ if (!rail || !rail.classList.contains('ov-rail')) {
+ rail = document.createElement('div');
+ rail.className = 'ov-rail';
+ track.before(rail);
+ rail.appendChild(track);
+ }
+ if (!track.id) track.id = `ov-rail-${++seq}`;
+ track.classList.add('ov-rail-track');
+ if (!track.hasAttribute('tabindex')) track.tabIndex = 0;
+ if (opts.label) {
+ track.setAttribute('role', 'region');
+ track.setAttribute('aria-label', opts.label);
+ }
+
+ const prev = button('prev', track, opts.label);
+ const next = button('next', track, opts.label);
+ const bar = document.createElement('div');
+ bar.className = 'ov-rail-bar';
+ bar.setAttribute('aria-hidden', 'true');
+ bar.hidden = true;
+ const thumb = document.createElement('span');
+ bar.appendChild(thumb);
+ track.before(prev);
+ track.after(next);
+ rail.appendChild(bar);
+
+ const page = (dir) => {
+ // Most of a screen at a time; snap settles it on a card edge.
+ const step = Math.max(160, track.clientWidth * 0.85);
+ track.scrollBy({ left: dir * step, behavior: reducedMotion() ? 'auto' : 'smooth' });
+ };
+ prev.addEventListener('click', () => page(-1));
+ next.addEventListener('click', () => page(1));
+
+ let raf = 0;
+ const setDisabled = (b, off) => {
+ // A focused button that disables itself would drop keyboard focus onto <body>.
+ if (off && document.activeElement === b) track.focus({ preventScroll: true });
+ b.disabled = off;
+ };
+ const update = () => {
+ raf = 0;
+ const max = track.scrollWidth - track.clientWidth;
+ const scrollable = max > 2;
+ const x = Math.min(max, Math.max(0, track.scrollLeft));
+ // A few pixels of slack: snapping to the first card can leave scrollLeft at 2-4px.
+ const atStart = !scrollable || x <= 8;
+ const atEnd = !scrollable || x >= max - 8;
+ rail.classList.toggle('is-scrollable', scrollable);
+ rail.classList.toggle('is-start', atStart);
+ rail.classList.toggle('is-end', atEnd);
+ setDisabled(prev, atStart);
+ setDisabled(next, atEnd);
+ bar.hidden = !scrollable;
+ if (scrollable) {
+ const size = Math.max(10, Math.min(100, (track.clientWidth / track.scrollWidth) * 100));
+ thumb.style.width = `${size}%`;
+ // translateX(%) is relative to the thumb itself: travel = (100 - size)% of the bar.
+ thumb.style.transform = `translateX(${((x / max) * (100 - size) / size) * 100}%)`;
+ }
+ };
+ const refresh = () => { if (!raf) raf = requestAnimationFrame(update); };
+ track.addEventListener('scroll', refresh, { passive: true });
+ if (window.ResizeObserver) new ResizeObserver(refresh).observe(track);
+ if (window.MutationObserver) new MutationObserver(refresh).observe(track, { childList: true });
+ refresh();
+
+ track._ovRail = { rail, track, refresh };
+ return track._ovRail;
+ }
+
+ window.OVRail = { attach };
+})();