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

OP OpenVibers committed 23 Sept 2026 · 6a30112658 · 7 files, +381 −112

Star of OpenVibe: the three actions share one height, radius, type size and inline icon (gold filled primary, two identical outline buttons) and the row never wraps; "Visit channel" no longer breaks onto two lines. The disclosure toggle was a square box with its chevron stacked above "Show less" once opened; on a phone it is now a square chevron button at the end of the row (label visually hidden, still its accessible name and title), on wider screens the same outline button with its label. At 350px and below "Say hi" goes to its icon too, so 320px fits. The AI-pick line splits into "Picked by OpenVibe's AI" and "Next star in 3h" pieces that each stay on one line, "Last live 3d ago" sits beside its icon, and on a phone the disclosure is no longer a panel inside a panel. Colours: gold text now goes through --star-ink (gold mixed with --text-primary), so the kicker, chips, pick line and toggle are legible on light themes, and the pick headline uses --text-primary (it was #fff, invisible on light themes); compact chips use text-primary mixes instead of rgba(255,255,255). The border sweep turns a registered angle in place (like the pitch banner) instead of rotating an oversized square, which left a wide desktop card's border in patches. Dead legacy rules (.star-card.is-live three columns, the <=860px centred layout, the gold-circle chevron) are gone.

Card rails: public/js/ov-rail.js (OVRail.attach(track, { label })) plus .ov-rail in style.css, used by the AI Moments row, which is the only sideways rail on the home page. Native scrollbar hidden, x mandatory snap, overscroll contained, the edge with more to show fades (a mask, so any theme or background works), a thin accent bar shows size and position, prev/next buttons on hover/fine-pointer devices only that disable at the ends (focus moves to the rail instead of dropping to body), and the rail is a focusable labelled region. The daily secret's document keydown handler called preventDefault on every arrow key, so a focused rail could not scroll; arrows inside a rail now belong to the rail.

Tap and focus: -webkit-tap-highlight-color is transparent everywhere (inherited, so the shared navbar and footer on Live are covered). Buttons, .btn, badge links, hero buttons, chips, section actions and link cards get a small press (the independent `scale` property, so it composes with existing transforms) and one accent :focus-visible ring, all inside :where() so components that style their own states still win; with prefers-reduced-motion the press is a brief dim instead.

Other 360/400px defects: section header sentences ("a new star every day - ...", "wild seconds the AI clipped fr...") were cut off; on a phone they take their own line under the title. Header links used var(--surface), which no theme defines (white text on nothing on light themes), and Recent Changes paired an unstyled "Show All" with a "View All" badge that ran off a 360px screen; they are one .section-action pill, and the changelog's expand button sits under the list in the .home-load-more style. Network hub names ran out of two-up cards at 400px ("OpenVibe.Commu"): the icon sits above the text there, names may break after "OpenVibe." (<wbr>), and the hover arrow that the <=480px rule meant to hide (Font Awesome's later .fa-solid display rule won) is actually hidden. Terms/Privacy/DMCA wrap as one group, and the digest says "1 chat line".

Budgets (scripts/perf/check-budgets.js), before -> after: eager JS 1111.9 -> 1117.6 KB raw, 252.1 -> 253.9 KB brotli, 23 -> 24 files; blocking CSS 690.2 -> 699.9 KB raw, 116.1 -> 118.4 KB brotli; HTML 85.7 KB raw unchanged, 15.3 -> 15.4 KB brotli.

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex ca0b65bc..adb5a650 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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; }
ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex 61a5c83e..30f0808b 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -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 moving
element 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 frames
the card instead of shouting over it. */
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 62e97fcc..bd16cfe3 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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 {
ddiff --git a/public/features.json b/public/features.json
iindex 8ad1203e..951babf5 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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",
ddiff --git a/public/index.html b/public/index.html
iindex e8d45151..9029affd 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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">&bull;</span>
<a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
<span aria-hidden="true">&bull;</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 the
philosophy 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 &amp; 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>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 513054a7..4540883b 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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));
};
ddiff --git a/public/js/ov-rail.js b/public/js/ov-rail.js
nnew file mode 100644
iindex 00000000..3e96ed3d
---- /dev/null
++++ b/public/js/ov-rail.js
@@@ -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 };
++})();