Compare commits

..

6 Commits

Author SHA1 Message Date
Jonathan Sykes
1754933f7e Polish Bento scrolling and controls with full screen coverage 2026-10-04 23:44:51 +08:00
Jonathan Sykes
4e5b1584c6 Restore Bento theme integration lost during recovery 2026-10-04 22:47:33 +08:00
Jonathan Sykes
9589c38773 Apply Bento tiles to Settings, sheets and empty states 2026-10-04 22:46:23 +08:00
Jonathan Sykes
3baf67349f Bring Bento playback and Service mode into the new theme 2026-10-04 22:44:54 +08:00
Jonathan Sykes
e100d47143 Style Bento library rows and download recovery controls 2026-10-04 22:42:22 +08:00
Jonathan Sykes
838798ca9b Build the new Bento Hub shell and browsing tiles 2026-10-04 03:09:15 +08:00
17 changed files with 735 additions and 87 deletions

80
docs/theme-bento-hub.md Normal file
View File

@@ -0,0 +1,80 @@
# New Bento Hub theme
The old Bento rules in `frontend/styles.css` have been removed. The replacement
is `frontend/theme-bento-hub.css`, scoped to `html[data-layout="bento-hub"]`.
The stylesheet, small theme helper, and self-hosted Plus Jakarta Sans fonts are
included in the service-worker shell cache. Classic remains the default.
## Match to the reference
The reference is `docs/mockups/bento-hub/index.html` and `SPEC.md` (local design
sources). The implementation uses their obsidian surfaces, solar-gold primary
controls, cyan listening/status cues, Plus Jakarta Sans and JetBrains Mono,
16px outer / 10px inner corners, stroke SVG icons, search capsule, playlist
artwork collages, bordered collection rows, and floating mini player.
Coverage includes the desktop sidebar and phone navigation, Home and search,
Now Playing, all three notes tabs, stage lyrics, up next, playlist and channel
views, Queue and batch selection, History, Saved, all download states and their
Retry/Cancel controls, Settings overview and sections, Service mode, modals,
control sheets, and empty states. Floating chrome has measured clearance;
content scrolls inside the remaining viewport. `#sectionRail` stays fixed and
attached directly to `body`. It is never moved into a panel or scroll container.
## Deliberate differences
- Content and navigation remain the real app's. Home shows actual playlists
and recommendations. Counts, artwork, progress, lyrics and wake-lock state
come from existing data; there are no invented verification badges, transfer
speeds, subscriber counts, or guarantees that the screen will stay awake.
- The desktop sidebar uses the spec's 260px width rather than the HTML mockup's
240px. The drawer extends through 1023px. The existing app stacks its player
at phone widths; its browsing and player controls remain accessible there.
- Now Playing keeps the real control deck, playlist panel, volume, looping,
fullscreen, notes and More controls. Notes remain with the player instead of
reproducing the mockup's separate static lyrics column. Service keeps its
view, autoscroll, floating lyrics, reporting and text-size controls; these
wrap when necessary rather than overlapping.
- Settings keeps its section registry, back/history behavior, live summaries,
search and desktop two-pane navigation. Layout selection remains the existing
select rather than duplicating it with mockup-only theme chips.
- Functional text has an 11px floor and controls have 44px minimum targets.
Muted dark text and the light-theme gold were adjusted for contrast. Existing
font scaling, density, contrast, reduced-motion and performance preferences
continue to apply. Service lyrics keep their adjustable sizing.
- Mockup frames omit the shell on several screens and contain empty space or
sample rows. Real pages retain the full shell and scrollable content. Video
fixture art is synthetic; a real playing video fills the video stage.
## Verification and screenshots
Run `node scripts/test-bento-theme.js` for a reproducible comparison. It extracts
pre-Bento commit `04b620e` into a temporary directory, captures Classic and Glass
before shots, then checks the current app. It does not modify the checkout.
`BENTO_BASE_COMMIT` can select another baseline; `BENTO_SCREENSHOT_DIR` can select
an output directory. Default output is `~/deliverables/ytplayer-done13/`.
The Playwright matrix covers 23 screens at 390px and 1440px in Chromium and
WebKit. It also checks centered navigation icons, download action appearance,
fixed rail ownership, sidebar and mini-player navigation, floating clearance,
theme switching restoration, and light/high-contrast appearance. The reference
is opened at both widths and each reference frame is captured when the local
mockup is present. No local mockup files are added to these implementation
commits. Static fixtures freeze the Classic mini-player's initial observer and
native indeterminate progress to avoid unrelated animation/scroll races.
Before/after comparisons allow at most 40 subpixel rasterization differences
in a whole screenshot; substantial geometry or styling differences fail. The
additional theme-switch check tolerates one RGB level per channel to account
for Chromium recompositing rounded corners, while retaining the 40-pixel limit.
Frontend unit tests include floating-chrome clearance with and without safe
areas/hidden bars and Bento-only SVG mapping without changing Glass glyphs.
The usual frontend tests, app syntax check and server build are run after each
screen group. Server source and imports are unchanged.
On a real iPhone, review safe-area clearance in Safari and installed mode,
keyboard and orientation changes, the floating rail, mini-player return,
Service controls, and actual video playback. On desktop, review long titles,
sidebar drawer at tablet widths, keyboard focus, Settings navigation and theme
switching. WebKit automation checks layout; it does not emulate all iOS media
or safe-area behavior. Nothing was pushed or deployed.

View File

@@ -8669,6 +8669,7 @@ function renderSidebar() {
// the fallback so a first-time visitor still gets the landing.
// Driven from renderSidebar() so every playlist mutation refreshes both.
function renderHomePlaylists() {
if (window.BentoHub) BentoHub.playlists = () => data.playlists || [];
const wrap = document.getElementById('homePlaylists');
const hero = document.querySelector('#playerPlaceholder .hero');
const grid = document.getElementById('homePlGrid');
@@ -8818,6 +8819,8 @@ function resetListHeaderExtras() {
}
function renderList() {
// Theme composition follows the real view without changing its controls.
els.cards.dataset.view = view.type;
resetListHeaderExtras();
{ const ls = $('listSub'); if (ls) ls.textContent = ''; const ps = $('p2pStrip'); if (ps) ps.classList.add('hidden'); }
if (view.type !== 'search') { const sm = $('searchMeta'); if (sm) sm.classList.add('hidden'); }
@@ -10884,6 +10887,9 @@ function isStackedPlayerLayout() {
if (document.documentElement.dataset.layout === 'classic') {
return getComputedStyle(document.documentElement).getPropertyValue('--classic-stacked').trim() === '1';
}
if (document.documentElement.dataset.layout === 'bento-hub') {
return getComputedStyle(document.documentElement).getPropertyValue('--bento-stacked').trim() === '1';
}
return isPortraitPWA();
}

40
frontend/bento-hub.js Normal file
View File

@@ -0,0 +1,40 @@
/* Bento-only artwork and measured clearance. Navigation and player nodes stay put. */
(function(root){
'use strict';
function chromeClearance(height,boxes){
const occupied=boxes.filter(b=>b.visible&&b.fixed&&b.height>0&&b.top>=height/2).map(b=>height-b.top);
return Math.max(0,...occupied)+16;
}
function mount(doc){
let frame=0,active=false;const html=doc.documentElement;
const resize=new root.ResizeObserver(schedule),observer=new root.MutationObserver(schedule);
function paint(){
frame=0;const on=html.dataset.layout==='bento-hub';
if(!on){if(active){resize.disconnect();observer.disconnect();html.style.removeProperty('--bento-bottom-clearance');doc.querySelectorAll('.bento-collage,.bento-search-icon').forEach(n=>n.remove());doc.querySelectorAll('.bento-search-label').forEach(n=>n.replaceWith(...n.childNodes));}active=false;return;}
if(!active){active=true;observer.observe(doc.body,{childList:true,subtree:true,attributes:true,attributeFilter:['class','hidden']});for(const id of ['bottomNav','miniBar','sectionRail']){const el=doc.getElementById(id);if(el)resize.observe(el);}}
const search=doc.querySelector('.search-btn');
if(search&&!search.querySelector('.bento-search-icon')){
const label=doc.createElement('span');label.className='bento-search-label';label.append(...search.childNodes);
const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg');icon.classList.add('bento-search-icon');icon.setAttribute('aria-hidden','true');icon.setAttribute('viewBox','0 0 24 24');icon.innerHTML='<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>';search.append(label,icon);
}
const boxes=['bottomNav','miniBar','sectionRail'].map(id=>doc.getElementById(id)).filter(Boolean).map(el=>{const r=el.getBoundingClientRect(),s=root.getComputedStyle(el);return {top:r.top,height:r.height,fixed:s.position==='fixed',visible:s.display!=='none'&&s.visibility!=='hidden'};});
const clearance=chromeClearance(root.innerHeight,boxes)+'px';if(html.style.getPropertyValue('--bento-bottom-clearance')!==clearance)html.style.setProperty('--bento-bottom-clearance',clearance);
for(const card of doc.querySelectorAll('.home-pl-card')){
if(card.querySelector('.bento-collage'))continue;
// Use actual playlist artwork; never invent an image or a verification badge.
const name=card.querySelector('.home-pl-name')?.textContent;
const playlist=root.BentoHub.playlists?.().find(p=>p.name===name);
const images=(playlist?.videos||[]).filter(v=>v.thumbnail).slice(0,4);
if(images.length<4)continue;
const collage=doc.createElement('span');collage.className='bento-collage';collage.setAttribute('aria-hidden','true');
for(const video of images){const img=doc.createElement('img');img.src=video.thumbnail;img.alt='';img.loading='lazy';collage.append(img);}
card.querySelector('.home-pl-thumb')?.prepend(collage);
}
}
function schedule(){if(!frame)frame=root.requestAnimationFrame(paint);}
new root.MutationObserver(schedule).observe(html,{attributes:true,attributeFilter:['data-layout']});root.addEventListener('resize',schedule);paint();
}
const api={chromeClearance,playlists:null};
if(typeof module!=='undefined')module.exports=api;
else {root.BentoHub=api; if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});else mount(root.document);}
})(typeof window==='undefined'?globalThis:window);

View File

@@ -0,0 +1,12 @@
const test=require('node:test'),assert=require('node:assert/strict');
const {chromeClearance}=require('./bento-hub');
test('clearance follows the highest visible floating control, including safe area',()=>{
assert.equal(chromeClearance(844,[{visible:true,fixed:true,top:746,height:98},{visible:true,fixed:true,top:666,height:72},{visible:true,fixed:true,top:602,height:56}]),258);
});
test('hidden and scrolling content cannot reserve fullscreen bottom space',()=>{
assert.equal(chromeClearance(844,[{visible:false,fixed:true,top:602,height:56},{visible:true,fixed:false,top:500,height:100},{visible:true,fixed:true,top:0,height:64}]),16);
});
test('desktop mini player reserves its real height and margin; no chrome is 16px',()=>{
assert.equal(chromeClearance(844,[{visible:true,fixed:true,top:760,height:72}]),100);
assert.equal(chromeClearance(844,[]),16);
});

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,4 @@
/* Draw legacy control glyphs only in Glass; restore them on theme changes. */
/* Draw legacy control glyphs in Glass and Bento; restore them on theme changes. */
(function(root) {
'use strict';
const paths = {
@@ -20,15 +20,17 @@
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
shuffle:'<path d="m18 3 3 3-3 3M3 6h3c5 0 7 12 12 12h3m-3-3 3 3-3 3M3 18h3c2 0 4-3 6-6s4-6 6-6h3"/>',
down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
television:'<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>', pin:'<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
};
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more','⋮':'more','🗑':'trash','🎵':'music'};
function leadingGlyph(text) {
function leadingGlyph(text,bento=false) {
const mapping=bento?{...glyphs,'📺':'television','⏱':'clock','⇅':'settings','📌':'pin','🎧':'music','📚':'folder'}:glyphs;
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
const glyph = Object.keys(glyphs).find(key => rest.startsWith(key));
const glyph = Object.keys(mapping).find(key => rest.startsWith(key));
if (!glyph) return null;
const length = glyph.length + (rest[glyph.length] === '\uFE0F' ? 1 : 0);
return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
return { kind:mapping[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
}
function mount(doc) {
const decorations = new Set(), tabLabels = new Map(); let scheduled=false;
@@ -45,14 +47,15 @@
}
function paint() {
scheduled=false;observer.disconnect();
const active=doc.documentElement.dataset.layout==='glass-stage';
const bento=doc.documentElement.dataset.layout==='bento-hub';
const active=bento || doc.documentElement.dataset.layout==='glass-stage';
for(const [parent,record] of tabLabels)if(!active || !parent.isConnected){restoreTab(parent,record);tabLabels.delete(parent);}
for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);}
if(active) for(const parent of doc.querySelectorAll(selector)) {
if(active) for(const parent of doc.querySelectorAll(selector+(bento?', .service-btn, .np-btn, .ctrl, .empty-icon, .dl-saved-cta, .brand-mark, .upnext-head button, .ph-logo, .saved-badge':''))) {
// Existing SVGs already express the action; only legacy glyphs change.
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
for(const node of [...parent.childNodes]) {
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent,bento);if(!match)continue;
const icon=svg(match.kind);
if(parent.matches('.pl-offline-badge,.home-pl-offline')) { icon.removeAttribute('aria-hidden'); icon.setAttribute('role','img'); icon.setAttribute('aria-label',parent.title || 'Available offline'); }
const original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;

View File

@@ -13,3 +13,10 @@ test('playlist trash, menu and music glyphs use drawn glass icons',()=>{
assert.equal(leadingGlyph('⋮').kind,'more');
assert.equal(leadingGlyph('🎵').kind,'music');
});
test('Bento-only legacy icons leave the Glass glyph set unchanged',()=>{
assert.equal(leadingGlyph('📺 Show video'),null);
assert.deepEqual(leadingGlyph('📺 Show video',true),{kind:'television',original:'📺',rest:' Show video'});
assert.equal(leadingGlyph('⏱ Tap-sync',true).kind,'clock');
assert.equal(leadingGlyph('📌 Bookmark',true).kind,'pin');
});

View File

@@ -38,6 +38,7 @@
<link rel="stylesheet" href="fullscreen-orientation.css" />
<link rel="stylesheet" href="related-videos.css" />
<link rel="stylesheet" href="download-actions.css" />
<link rel="stylesheet" href="theme-bento-hub.css" />
</head>
<body>
<div class="app">
@@ -678,6 +679,7 @@
<script src="saved-page.js"></script>
<script src="server-backfill.js"></script>
<script src="glass-controls.js"></script>
<script src="bento-hub.js"></script>
<script src="glass-panel-layout.js"></script>
<script src="party-dj.js"></script>
<script src="lyrics-window.js"></script>

View File

@@ -3345,84 +3345,6 @@ html[data-layout="contemplative"] .brand-mark {
box-shadow: 0 6px 18px -4px rgba(45, 212, 191, 0.4), inset 0 1px 0 rgba(255,255,255,0.25);
}
/* --- Bento Hub -------------------------------------------------------------
* GitHub-dark-ish with honey-gold accent. Tighter borders give a
* "dashboard card" feel throughout.
* ------------------------------------------------------------------------- */
html[data-layout="bento-hub"] {
--bg: #0d1117;
--bg-1: #13181f;
--bg-2: #1c2333;
--bg-3: #252d3d;
--line: #30363d;
--line-soft: #21262d;
--text: #e6edf3;
--text-2: #8b949e;
--text-dim: #484f58;
--accent: #eab308;
--accent-bright: #fbbf24;
--accent-deep: #ca8a04;
--accent-glow: rgba(234, 179, 8, 0.4);
--radius: 10px;
--radius-sm: 6px;
}
html[data-layout="bento-hub"] body::before {
background:
radial-gradient(900px 600px at 18% -10%, rgba(234, 179, 8, 0.06), transparent 60%),
radial-gradient(700px 500px at 100% 110%, rgba(234, 179, 8, 0.03), transparent 55%);
}
html[data-layout="bento-hub"] .brand-mark {
background: linear-gradient(145deg, #fbbf24, #ca8a04);
box-shadow: 0 6px 18px -4px rgba(234, 179, 8, 0.45), inset 0 1px 0 rgba(255,255,255,0.25);
}
html[data-layout="bento-hub"] .sidebar {
background: #13181f;
border-right: 1px solid #30363d;
}
html[data-layout="bento-hub"] .brand {
border-bottom: 1px solid #21262d;
padding-bottom: 18px;
}
html[data-layout="bento-hub"] .nav-item {
border-radius: 6px;
}
html[data-layout="bento-hub"] .nav-item.active {
background: rgba(234, 179, 8, 0.08);
color: #fbbf24;
}
html[data-layout="bento-hub"] .nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: #eab308;
border-radius: 0 3px 3px 0;
}
html[data-layout="bento-hub"] .playlist-item {
background: #0d1117;
border: 1px solid #21262d;
border-radius: 8px;
margin: 3px 0;
padding: 8px 12px;
}
html[data-layout="bento-hub"] .playlist-item.active {
border-color: #eab308;
background: rgba(234, 179, 8, 0.05);
}
html[data-layout="bento-hub"] .topbar {
background: #13181f;
border-bottom: 2px solid #21262d;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
html[data-layout="bento-hub"] .mini-bar-inner {
background: linear-gradient(180deg, #1c2333, #13181f);
border-top: 1px solid #30363d;
}
/* Grid-column overrides only apply on screens wide enough for an inline sidebar
(≥861px). Below that the sidebar is position:fixed (out of grid flow) so
a two-column grid would leave .main stranded in the narrow first column. */

View File

@@ -59,6 +59,9 @@ const SHELL = [
'/service-ui.css',
'/refresh.css',
'/theme-glass.css',
'/theme-bento-hub.css',
'/fonts/PlusJakartaSans-latin.woff2',
'/fonts/PlusJakartaSans-latin-ext.woff2',
'/theme-glass-panel.css',
'/glass-panel-layout.js',
'/theme-classic.css',
@@ -105,6 +108,7 @@ const SHELL = [
'/saved-page.js',
'/server-backfill.js',
'/glass-controls.js',
'/bento-hub.js',
'/theme-glass-controls.css',
'/saved-page.css',
'/party-dj.js',

View File

@@ -0,0 +1,363 @@
/* Bento Hub — obsidian tiles, solar controls and cyan listening cues.
Reference: docs/mockups/bento-hub. No shared-theme overrides live here. */
@font-face { font-family:'Plus Jakarta Sans'; font-style:normal; font-weight:400 800; font-display:swap; src:url('fonts/PlusJakartaSans-latin.woff2') format('woff2'); unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family:'Plus Jakarta Sans'; font-style:normal; font-weight:400 800; font-display:swap; src:url('fonts/PlusJakartaSans-latin-ext.woff2') format('woff2'); unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
html[data-layout="bento-hub"] {
--bg:#090b0e; --bg-1:#11151c; --bg-2:#19202d; --bg-3:#222c3e; --bg-glass:rgba(17,21,28,.85);
--line:rgba(255,255,255,.09); --line-soft:rgba(255,255,255,.04); --line-bright:rgba(255,255,255,.18);
--text:#f1f5f9; --text-2:#94a3b8; --text-dim:#8594aa; --on-accent:#090b0e;
--accent:#ffb703; --accent-bright:#ffc527; --accent-deep:#d97706; --accent-glow:rgba(255,183,3,.22);
--accent-sec:#00f5d4; --accent-sec-deep:#00bbf9; --danger:#ef4444; --danger-text:#ff9393; --danger-bg:rgba(239,68,68,.12); --success:#10b981;
--radius:16px; --radius-outer:16px; --radius-inner:10px; --radius-sm:6px; --tile-gap:16px;
--shadow-sm:0 2px 8px rgb(0 0 0/.3); --shadow-md:0 8px 24px rgb(0 0 0/.45); --shadow-lg:0 16px 40px rgb(0 0 0/.6); --shadow:var(--shadow-md);
--ui:'Plus Jakarta Sans',system-ui,sans-serif; --display:var(--ui); --mono:'JetBrains Mono',monospace; color-scheme:dark;
}
html[data-layout="bento-hub"][data-theme="light"] {
--bg:#f4f6f8; --bg-1:#fff; --bg-2:#e9ecef; --bg-3:#dee2e6; --bg-glass:rgba(255,255,255,.88);
--line:rgba(0,0,0,.08); --line-soft:rgba(0,0,0,.04); --line-bright:rgba(0,0,0,.16);
--text:#0f172a; --text-2:#475569; --text-dim:#64748b; --on-accent:#fff;
--accent:#b65f04; --accent-bright:#d97706; --accent-deep:#b45309; --accent-glow:rgba(217,119,6,.18);
--accent-sec:#0d9488; --accent-sec-deep:#0f766e; --danger-text:#b91c1c;
--shadow-sm:0 2px 6px rgb(0 0 0/.05); --shadow-md:0 8px 20px rgb(0 0 0/.08); --shadow-lg:0 16px 32px rgb(0 0 0/.12); color-scheme:light;
}
html[data-layout="bento-hub"][data-theme="contrast"] { --bg:#000; --bg-1:#080808; --bg-2:#161616; --line:#aaa; --line-soft:#666; --text:#fff; --text-2:#ddd; --text-dim:#bbb; }
html[data-layout="bento-hub"] body { background:var(--bg); color:var(--text); font-family:var(--ui); }
html[data-layout="bento-hub"] body::before { display:none; }
html[data-layout="bento-hub"] ::selection { background:var(--accent); color:var(--on-accent); }
html[data-layout="bento-hub"] :is(input,textarea) { caret-color:var(--accent-sec); }
html[data-layout="bento-hub"] * { scrollbar-color:var(--bg-3) var(--bg-1); scrollbar-width:thin; }
html[data-layout="bento-hub"] ::-webkit-scrollbar-thumb { background:var(--bg-3); border-radius:6px; }
html[data-layout="bento-hub"] :is(button,a,input,select,textarea,summary,[tabindex]):focus-visible { outline:2px solid var(--accent-sec); outline-offset:3px; }
html[data-layout="bento-hub"] :is(button,input,select,textarea) { font-family:var(--ui); }
html[data-layout="bento-hub"] :is(button,select,summary) { min-height:44px; }
html[data-layout="bento-hub"] button:disabled { opacity:.45; cursor:not-allowed; }
html[data-layout="bento-hub"] .glass-original-glyph,
html[data-layout="bento-hub"] .legacy-nav-icon { display:none; }
html[data-layout="bento-hub"] :is(.classic-nav-icon,.glass-control-icon) { display:inline-block; width:20px; height:20px; flex:none; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; vertical-align:middle; }
html[data-layout="bento-hub"] .sidebar { background:var(--bg-1); border-right:1px solid var(--line); padding:20px 16px; gap:16px; }
html[data-layout="bento-hub"] .brand { padding:0 0 16px; border-bottom:1px solid var(--line); gap:10px; font-size:18px; font-weight:800; letter-spacing:-.02em; }
html[data-layout="bento-hub"] .brand-mark { width:34px; height:34px; border-radius:10px; background:linear-gradient(135deg,var(--accent),var(--accent-deep)); color:var(--on-accent); box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] .nav { gap:6px; }
html[data-layout="bento-hub"] .nav-item { display:flex; align-items:center; gap:12px; min-height:44px; padding:10px 12px; border:1px solid transparent; border-radius:10px; color:var(--text-2); font-size:13px; font-weight:600; }
html[data-layout="bento-hub"] .nav-item:hover { background:var(--bg-2); color:var(--text); }
html[data-layout="bento-hub"] .nav-item.active { background:var(--bg-2); border-color:var(--line-bright); color:var(--accent); box-shadow:inset 3px 0 var(--accent); }
html[data-layout="bento-hub"] .nav-item.active::before { display:none; }
html[data-layout="bento-hub"] :is(.nav-badge,.pl-count) { background:var(--bg-3); color:var(--text-2); font:600 11px var(--mono); }
html[data-layout="bento-hub"] .pl-header { font:700 12px var(--ui); color:var(--text-2); letter-spacing:0; }
html[data-layout="bento-hub"] .playlist-item { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid var(--line); border-radius:10px; background:var(--bg); margin:6px 0; }
html[data-layout="bento-hub"] .playlist-item.active { background:var(--bg-2); border-color:var(--accent); }
html[data-layout="bento-hub"] .pl-name { min-width:0; font-size:13px; }
html[data-layout="bento-hub"] .sidebar-footer { border-top:1px solid var(--line); color:var(--text-2); font-size:12px; }
html[data-layout="bento-hub"] .topbar { background:var(--bg-1); border-bottom:1px solid var(--line); padding:12px 20px; gap:12px; backdrop-filter:none; }
html[data-layout="bento-hub"] .search-form { max-width:640px; gap:8px; }
html[data-layout="bento-hub"] #searchInput { border:1px solid var(--line); border-radius:999px; background:var(--bg-2); padding:12px 18px; font-size:14px; min-height:44px; box-shadow:none; }
html[data-layout="bento-hub"] .search-btn { border:0; border-radius:999px; min-height:44px; background:var(--accent); color:var(--on-accent); font:700 13px var(--ui); box-shadow:none; }
html[data-layout="bento-hub"] :is(.sidebar-toggle,.icon-btn,.update-icon-btn) { min-width:44px; width:44px; min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text-2); }
html[data-layout="bento-hub"] .bottom-nav { height:calc(64px + env(safe-area-inset-bottom)); background:var(--bg-glass); border-top:1px solid var(--line); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); padding:0 4px env(safe-area-inset-bottom); }
html[data-layout="bento-hub"] .bottom-nav-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; padding:8px 0; min-height:64px; color:var(--text-2); }
html[data-layout="bento-hub"] .bottom-nav-icon { display:grid; place-items:center; width:24px; height:24px; margin:0; transform:none; }
html[data-layout="bento-hub"] .bottom-nav-label { font-size:11px; font-weight:600; letter-spacing:0; }
html[data-layout="bento-hub"] .bottom-nav-btn.active { color:var(--accent); background:transparent; }
html[data-layout="bento-hub"] .bottom-nav-btn.active::before { content:''; position:absolute; top:0; width:24px; height:2px; border-radius:2px; background:var(--accent); }
html[data-layout="bento-hub"] .bottom-nav-badge { left:calc(50% + 6px); top:4px; margin:0; height:18px; min-width:18px; font:700 11px var(--mono); color:var(--on-accent); background:var(--accent); }
html[data-layout="bento-hub"] .player-pane { padding:20px; gap:16px; background:var(--bg); }
html[data-layout="bento-hub"] .list-pane { background:var(--bg); border-left:1px solid var(--line); width:440px; }
html[data-layout="bento-hub"] .player-placeholder { background:var(--bg); border:0; padding:0; }
html[data-layout="bento-hub"] .hero { padding:24px; background:var(--bg-1); border:1px solid var(--line); border-radius:16px; }
html[data-layout="bento-hub"] .hero-title { font:800 32px/1.2 var(--ui); color:var(--text); letter-spacing:-.03em; }
html[data-layout="bento-hub"] .hero-tagline { font-size:14px; color:var(--text-2); max-width:65ch; line-height:1.6; }
html[data-layout="bento-hub"] :is(.hero-pill,.chip) { background:var(--bg-2); color:var(--text-2); border:1px solid var(--line); border-radius:999px; font-size:12px; }
html[data-layout="bento-hub"] .hero-pill::before { background:var(--accent-sec); }
html[data-layout="bento-hub"] .home-pl { padding:0; }
html[data-layout="bento-hub"] .home-pl-title { font:800 22px var(--ui); color:var(--text); }
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:16px; padding:0 0 16px; }
html[data-layout="bento-hub"] .home-pl-card { padding:12px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] .home-pl-thumb { border-radius:10px; aspect-ratio:16/9; }
html[data-layout="bento-hub"] .home-pl-name { padding:10px 0 0; font:700 14px/1.4 var(--ui); }
html[data-layout="bento-hub"] .home-pl-count { padding:4px 0 0; font-size:12px; color:var(--text-2); }
html[data-layout="bento-hub"] .home-pl-play { width:44px; height:44px; color:var(--on-accent); background:var(--accent); border-radius:999px; }
html[data-layout="bento-hub"] :is(.home-pl-new,.list-actions button,.np-btn,.dl-saved-cta,.dl-saved-link) { display:inline-flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:10px 14px; background:var(--bg-2); color:var(--text); font:600 13px var(--ui); }
html[data-layout="bento-hub"] :is(.home-pl-new,.list-actions .primary-btn,.primary-btn) { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
html[data-layout="bento-hub"] .recommendations { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); padding:16px; }
html[data-layout="bento-hub"] .recommendations h2 { font:800 22px var(--ui); }
html[data-layout="bento-hub"] .rec-card { background:var(--bg-2); border:1px solid var(--line); border-radius:10px; }
html[data-layout="bento-hub"] .rec-title { font-size:13px; }
html[data-layout="bento-hub"] .rec-channel { font-size:12px; color:var(--text-2); }
html[data-layout="bento-hub"] .list-header { padding:20px 16px 12px; gap:12px; }
html[data-layout="bento-hub"] .list-header h2 { font:800 22px/1.3 var(--ui); color:var(--text); }
html[data-layout="bento-hub"] .list-filter-wrap { padding:0 16px 12px; }
html[data-layout="bento-hub"] .list-filter { min-height:44px; background:var(--bg-2); border:1px solid var(--line); border-radius:10px; font-size:13px; }
html[data-layout="bento-hub"] .cards { padding:4px 16px 24px; gap:12px; }
html[data-layout="bento-hub"] .cards[data-view="search"],
html[data-layout="bento-hub"] .cards[data-view="channel"],
html[data-layout="bento-hub"] .cards[data-view="library"] { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); align-content:start; gap:16px; }
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .card { display:flex; flex-direction:column; align-items:stretch; gap:10px; padding:12px; }
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .thumb { width:100%; aspect-ratio:16/9; }
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .card-actions { position:static; display:flex; justify-content:flex-end; }
html[data-layout="bento-hub"] .card { animation:none; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] .card-title { font:700 13px/1.5 var(--ui); -webkit-line-clamp:2; }
html[data-layout="bento-hub"] .card-channel { font-size:12px; color:var(--text-2); }
html[data-layout="bento-hub"] .thumb { border-radius:10px; }
html[data-layout="bento-hub"] .duration { font:500 11px var(--mono); background:rgba(0,0,0,.8); }
html[data-layout="bento-hub"] .thumb::after { background:none; }
html[data-layout="bento-hub"] .card:hover .thumb img { transform:none; }
html[data-layout="bento-hub"] .card.playing { border-color:var(--accent-sec); background:var(--bg-2); }
html[data-layout="bento-hub"] .card.playing::after { background:var(--accent-sec); }
@media (hover:hover) {
html[data-layout="bento-hub"] :is(.home-pl-card,.cards[data-view="search"] .card):hover { transform:translateY(-2px); border-color:var(--accent); box-shadow:var(--shadow-md); transition:transform .18s ease-out,box-shadow .18s ease-out; }
}
@media (min-width:1024px) { html[data-layout="bento-hub"] .app { grid-template-columns:260px minmax(0,1fr); } }
@media (max-width:860px) {
html[data-layout="bento-hub"] .topbar { padding:12px; gap:8px; }
html[data-layout="bento-hub"] .player-pane { padding:12px; gap:12px; }
html[data-layout="bento-hub"] .list-pane { width:100%; border-left:0; }
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:12px; }
html[data-layout="bento-hub"] .home-pl-title { font-size:18px; }
html[data-layout="bento-hub"] .list-header { padding:16px 12px 12px; }
html[data-layout="bento-hub"] .list-header h2 { font-size:18px; }
html[data-layout="bento-hub"] .cards { padding-inline:12px; }
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) { grid-template-columns:1fr; gap:12px; }
html[data-layout="bento-hub"] .hero-title { font-size:24px; }
}
@media (prefers-reduced-motion:reduce) { html[data-layout="bento-hub"] *,html[data-layout="bento-hub"] *::before,html[data-layout="bento-hub"] *::after { animation:none!important; transition:none!important; } }
/* Actual playlist artwork keeps the reference's collage without synthetic data. */
html[data-layout="bento-hub"] .bento-collage { position:absolute; inset:0; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; gap:2px; }
html[data-layout="bento-hub"] .bento-collage img { width:100%; height:100%; object-fit:cover; }
html[data-layout="bento-hub"] .home-pl-thumb:has(.bento-collage) > img { visibility:hidden; }
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) .card-menu { align-self:flex-end; }
@media(max-width:860px) { html[data-layout="bento-hub"] .bottom-nav { display:flex; } }
html[data-layout="bento-hub"] body.service-open .bottom-nav { display:none; }
/* Library collections — compact rows, deliberate actions and truthful status. */
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card { padding:12px; gap:12px; align-items:center; }
html[data-layout="bento-hub"] :is(.card-menu,.card-del,.card-note-btn,.card-act,.list-icon-btn,.saved-file-action) { position:static; flex:none; display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; min-width:44px; margin:0; padding:10px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text-2); opacity:1; }
html[data-layout="bento-hub"] :is(.card-menu,.list-icon-btn):hover { border-color:var(--accent); color:var(--accent); background:var(--bg-3); }
html[data-layout="bento-hub"] :is(.saved-clear,.card-del,.saved-file-action.danger,.list-icon-btn.danger) { color:var(--danger-text); background:var(--danger-bg); border-color:transparent; }
html[data-layout="bento-hub"] :is(.saved-clear,.card-del,.saved-file-action.danger):hover { border-color:var(--danger-text); color:var(--danger-text); }
html[data-layout="bento-hub"] :is(.saved-storage-summary,.dl-summary) { padding:12px 0 16px; color:var(--text-2); font-size:13px; }
html[data-layout="bento-hub"] .saved-clear { border-radius:10px; padding:10px 12px; font-size:13px; }
html[data-layout="bento-hub"] .card.saved-card { grid-template-columns:72px minmax(0,1fr) 96px; min-height:96px; }
html[data-layout="bento-hub"] .saved-file-actions { gap:8px; }
html[data-layout="bento-hub"] :is(.card .dur,.upnext-count,.saved-storage-text,.set-stat) { font-family:var(--mono); font-size:12px; font-variant-numeric:tabular-nums; }
html[data-layout="bento-hub"] .card.playing::after { content:''; width:6px; height:6px; top:8px; right:8px; border-radius:50%; background:var(--accent-sec); }
html[data-layout="bento-hub"] .saved-badge { color:var(--accent-sec); background:var(--bg-1); border:1px solid var(--line); }
html[data-layout="bento-hub"] .pl-offline-badge { color:var(--accent-sec); font-size:11px; }
html[data-layout="bento-hub"] .batch-bar { background:var(--bg-2); border:1px solid var(--accent); border-radius:10px; margin:0 16px 12px; padding:12px; gap:8px; color:var(--accent); box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] .batch-bar button { min-width:44px; background:var(--bg-1); color:var(--text); border:1px solid var(--line); border-radius:10px; font-size:13px; }
html[data-layout="bento-hub"] .batch-bar .danger { color:var(--danger-text); background:var(--danger-bg); }
html[data-layout="bento-hub"] .card.selected { border-color:var(--accent); background:var(--bg-2); }
html[data-layout="bento-hub"] .batch-check { accent-color:var(--accent); }
html[data-layout="bento-hub"] .dl-group { margin-bottom:24px; }
html[data-layout="bento-hub"] .dl-group-title { font:700 16px var(--ui); margin:16px 0 10px; }
html[data-layout="bento-hub"] .card.dl-row { grid-template-columns:72px minmax(0,1fr); padding:16px; border-radius:10px; gap:12px; }
html[data-layout="bento-hub"] .card.dl-failed { border-color:color-mix(in srgb,var(--danger) 40%,var(--line)); }
html[data-layout="bento-hub"] .dl-failed .card-channel { color:var(--danger-text); }
html[data-layout="bento-hub"] .dl-progress { height:4px; border-radius:999px; overflow:hidden; background:var(--bg-3); }
html[data-layout="bento-hub"] .dl-bar { background:var(--accent-sec); }
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action { border-radius:10px; border-color:var(--line); background:var(--bg-2); color:var(--text); font-size:13px; }
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action[aria-label^="Retry "] { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action.danger { background:var(--danger-bg); color:var(--danger-text); border-color:transparent; }
html[data-layout="bento-hub"] :is(.dl-row,.download-job) .download-action:hover { border-color:var(--line-bright); filter:brightness(1.08); }
html[data-layout="bento-hub"] .download-error-details { font-size:12px; }
html[data-layout="bento-hub"] .download-error-details summary { color:var(--text-2); }
html[data-layout="bento-hub"] .download-storage { border-top:1px solid var(--line); padding-top:16px; }
html[data-layout="bento-hub"] :is(.channel-header,.saved-search-item,.p2p-row,.notes-video,.stat-card) { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); padding:16px; }
html[data-layout="bento-hub"] :is(.channel-header h2,.notes-video-title) { color:var(--text); font:700 18px var(--ui); }
@media (max-width:640px) {
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card:not(.saved-card):not(.dl-row) { display:grid; grid-template-columns:72px minmax(0,1fr) 44px; gap:10px; }
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card:not(.saved-card):not(.dl-row) .thumb { width:72px; grid-column:1; grid-row:1; }
html[data-layout="bento-hub"] .cards:not([data-view="search"]):not([data-view="channel"]):not([data-view="library"]) .card:not(.saved-card):not(.dl-row) .card-info { grid-column:2; grid-row:1; }
html[data-layout="bento-hub"] .card-menu { grid-column:3; grid-row:1; }
html[data-layout="bento-hub"] :is(.card-del,.card-note-btn) { grid-column:3; }
html[data-layout="bento-hub"] .card.saved-card { gap:8px; padding:10px; grid-template-columns:56px minmax(0,1fr) 96px; }
html[data-layout="bento-hub"] .saved-card .thumb { width:56px; }
html[data-layout="bento-hub"] .card.dl-row { padding:12px; grid-template-columns:56px minmax(0,1fr); }
html[data-layout="bento-hub"] .dl-row .thumb { width:56px; }
html[data-layout="bento-hub"] .batch-bar { margin-inline:12px; flex-wrap:wrap; }
}
/* Now Playing: a solar transport deck with cyan listening cues. */
html[data-layout="bento-hub"] .player-stage { border-radius:16px; border:1px solid var(--line-bright); background:#000; box-shadow:var(--shadow-md); }
html[data-layout="bento-hub"] .player-pane.empty .player-stage { border:0; border-radius:0; box-shadow:none; }
html[data-layout="bento-hub"] :is(#controls,.now-meta,.notes-panel,.upnext,.related-panel) { flex-shrink:0; padding:16px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] #controls { gap:12px; }
html[data-layout="bento-hub"] #controls .deck-main { gap:8px; }
html[data-layout="bento-hub"] #controls .deck-main .ctrl { width:44px; height:44px; min-width:44px; padding:10px; background:var(--bg-2); color:var(--text); border:1px solid var(--line); border-radius:999px; box-shadow:none; }
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play { width:56px; height:56px; padding:14px; background:var(--accent); color:var(--on-accent); border:0; box-shadow:0 6px 18px -4px var(--accent-glow); }
html[data-layout="bento-hub"] #controls .deck-main .ctrl.play svg { width:28px; height:28px; }
html[data-layout="bento-hub"] #controls .deck-main .ctrl.active { color:var(--accent-sec); border-color:var(--accent-sec); }
html[data-layout="bento-hub"] :is(.time,.mini-time,.vol-pct,.lyr-time,.marker-time) { font:500 12px var(--mono); color:var(--text-2); }
html[data-layout="bento-hub"] #controls :is(.dk-more,.ctrl:not(.play)) { background:var(--bg-2); border-radius:10px; color:var(--text-2); }
html[data-layout="bento-hub"] :is(.dk-more small,.dk-more i,.np-more small) { color:var(--text-2); font-size:12px; letter-spacing:0; }
html[data-layout="bento-hub"] .np-title { font:800 22px/1.4 var(--ui); letter-spacing:-.02em; }
html[data-layout="bento-hub"] .np-channel { font-size:13px; color:var(--text-2); }
html[data-layout="bento-hub"] .np-actions { gap:8px; }
html[data-layout="bento-hub"] .np-btn svg { width:20px; height:20px; stroke-width:1.8; }
html[data-layout="bento-hub"] .np-more { background:var(--bg-2); border:1px solid var(--line); border-radius:10px; color:var(--text); }
html[data-layout="bento-hub"] #notesPanel { padding:0; overflow:hidden; container-type:inline-size; container-name:bento-notes; }
html[data-layout="bento-hub"] .notes-tabs { display:flex; flex-wrap:nowrap; align-items:center; gap:4px; padding:8px; border-bottom:1px solid var(--line); background:var(--bg-1); }
html[data-layout="bento-hub"] .notes-tab { display:inline-flex; align-items:center; gap:8px; min-width:44px; min-height:44px; flex:0 1 auto; padding:10px; border:1px solid transparent; border-radius:10px; background:transparent; color:var(--text-2); font:600 12px var(--ui); text-transform:none; letter-spacing:0; white-space:nowrap; }
html[data-layout="bento-hub"] .notes-tab.active { background:var(--bg-2); color:var(--accent-sec); border-color:var(--line); }
html[data-layout="bento-hub"] .glass-tab-label { overflow:hidden; text-overflow:ellipsis; }
html[data-layout="bento-hub"] .notes-status { min-width:0; flex:1 1 0; font-size:11px; }
html[data-layout="bento-hub"] .notes-fold { display:grid; place-items:center; min-width:44px; width:44px; height:44px; margin-left:auto; border-radius:10px; background:var(--bg-2); color:var(--text-2); }
html[data-layout="bento-hub"] .notes-pane { padding:12px; }
html[data-layout="bento-hub"] :is(.lyrics-toolbar,.markers-toolbar) { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px; }
html[data-layout="bento-hub"] .lyrics-list { padding:0; max-height:320px; }
html[data-layout="bento-hub"] .lyr-line { padding:12px; border-radius:10px; border-left:3px solid transparent; color:var(--text-2); font:500 14px/1.6 var(--ui); }
html[data-layout="bento-hub"] .lyr-line.active { border-left-color:var(--accent-sec); background:color-mix(in srgb,var(--accent-sec) 8%,transparent); color:var(--accent-sec); font-weight:700; }
html[data-layout="bento-hub"] :is(.lyr-off-btn,.marker-row,.ts-query,.ts-source) { min-height:44px; border:1px solid var(--line); border-radius:10px; color:var(--text); background:var(--bg-2); }
html[data-layout="bento-hub"] .upnext-header { color:var(--text); font:700 16px var(--ui); text-transform:none; letter-spacing:0; }
html[data-layout="bento-hub"] .upnext-count { background:var(--bg-2); color:var(--accent-sec); }
html[data-layout="bento-hub"] .upnext-list { display:flex; flex-direction:column; gap:8px; }
html[data-layout="bento-hub"] .upnext-item { min-height:64px; padding:8px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); }
html[data-layout="bento-hub"] .upnext-title { font-size:13px; font-weight:700; }
html[data-layout="bento-hub"] .upnext-channel { color:var(--text-2); font-size:12px; }
html[data-layout="bento-hub"] .mini-bar { bottom:12px; left:calc(260px + 16px); right:16px; border:1px solid var(--line-bright); border-radius:16px; overflow:hidden; background:var(--bg-glass); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); box-shadow:var(--shadow-lg); }
html[data-layout="bento-hub"] .mini-bar-inner { padding:10px 12px; gap:10px; border:0; background:transparent; backdrop-filter:none; }
html[data-layout="bento-hub"] .mini-btn { display:grid; place-items:center; width:44px; height:44px; min-width:44px; border-radius:999px; color:var(--text-2); border:1px solid var(--line); background:var(--bg-2); }
html[data-layout="bento-hub"] #miniPlayBtn { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
html[data-layout="bento-hub"] .mini-title { color:var(--text); font:700 13px var(--ui); }
html[data-layout="bento-hub"] .mini-channel { color:var(--text-2); font-size:12px; }
html[data-layout="bento-hub"] .mini-progress { height:3px; margin:0; background:var(--bg-3); }
html[data-layout="bento-hub"] .mini-progress-fill { background:var(--accent-sec); }
html[data-layout="bento-hub"] :is(.player-pane,.cards) { padding-bottom:var(--bento-bottom-clearance,16px); scroll-padding-bottom:var(--bento-bottom-clearance,16px); }
html[data-layout="bento-hub"] .sec-rail,
html[data-layout="bento-hub"] body:has(#miniBar:not(.hidden)) .sec-rail { position:fixed; top:auto; right:auto; left:50%; bottom:var(--rail-bottom,160px); transform:translateX(-50%); flex-direction:row; padding:6px; gap:4px; border-radius:999px; border:1px solid var(--line-bright); background:var(--bg-glass); }
html[data-layout="bento-hub"] .sec-rail-btn { display:grid; place-items:center; min-width:44px; width:44px; height:44px; border-radius:999px; background:transparent; color:var(--text-2); }
html[data-layout="bento-hub"] .sec-rail-btn.active { color:var(--accent); background:var(--bg-2); box-shadow:none; }
html[data-layout="bento-hub"] .sec-rail-label { left:50%; transform:translateX(-50%); color:var(--text); background:var(--bg-2); font:600 12px var(--ui); }
@container bento-notes (max-width:460px) { html[data-layout="bento-hub"] .notes-tab[data-tab="markers"] .glass-tab-label { display:none; } }
@container bento-notes (max-width:330px) { html[data-layout="bento-hub"] .notes-tab .glass-tab-label { display:none; } }
@media(max-width:860px) {
html[data-layout="bento-hub"] .mini-bar { left:12px; right:12px; bottom:calc(64px + env(safe-area-inset-bottom) + 8px); }
html[data-layout="bento-hub"] .mini-time { display:none; }
html[data-layout="bento-hub"] .body { padding-bottom:var(--bento-bottom-clearance,96px); scroll-padding-bottom:var(--bento-bottom-clearance,96px); }
html[data-layout="bento-hub"] :is(.player-pane,.cards) { padding-bottom:16px; }
html[data-layout="bento-hub"] :is(#controls,.now-meta,.upnext,.related-panel) { padding:12px; }
html[data-layout="bento-hub"] .np-title { font-size:18px; }
}
/* Service stays a full-screen performance surface, with the user's text scale. */
html[data-layout="bento-hub"] .service-mode { background:#05070a; color:#f1f5f9; --svc-ink:#f1f5f9; --svc-ink-2:#94a3b8; --svc-cyan:#00f5d4; }
html[data-layout="bento-hub"] .service-mode::before { display:none; }
html[data-layout="bento-hub"] :is(.svc-dock,.svc-tile,.svc-exit,.svc-font,.svc-wake) { background:#11151c; border-color:rgba(255,255,255,.09); color:#94a3b8; border-radius:10px; }
html[data-layout="bento-hub"] .svc-wake.awake { color:#00f5d4; }
html[data-layout="bento-hub"] .svc-label { font-size:12px; }
html[data-layout="bento-hub"] .service-title { font:800 clamp(26px,3vw,36px)/1.3 var(--ui); color:#f1f5f9; }
html[data-layout="bento-hub"] .service-channel { color:#94a3b8; font-size:14px; }
html[data-layout="bento-hub"] .service-lyrics .sl-line.active { color:#00f5d4; text-shadow:none; }
html[data-layout="bento-hub"] .service-btn { width:56px; height:56px; border:1px solid rgba(255,255,255,.09); border-radius:999px; background:#19202d; color:#f1f5f9; box-shadow:none; }
html[data-layout="bento-hub"] .service-btn-play { width:64px; height:64px; background:#ffb703; color:#090b0e; border:0; }
html[data-layout="bento-hub"] .svc-progress > i { background:#00f5d4; }
html[data-layout="bento-hub"] .service-time { font:500 12px var(--mono); color:#94a3b8; }
/* Settings, sheets and empty states use the same tile and type system. */
html[data-layout="bento-hub"] .settings-browser { padding-top:8px; }
html[data-layout="bento-hub"] .settings-search input { min-height:44px; padding:12px 16px; border-radius:999px; border-color:var(--line); background:var(--bg-2); font-size:14px; }
html[data-layout="bento-hub"] .settings-overview h3 { margin:20px 0 10px; font:700 13px var(--ui); letter-spacing:0; color:var(--text-2); }
html[data-layout="bento-hub"] .settings-section-row { padding:14px; gap:12px; min-height:72px; margin-bottom:10px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
html[data-layout="bento-hub"] .settings-section-row svg { width:24px; height:24px; color:var(--accent); stroke-width:1.8; }
html[data-layout="bento-hub"] .settings-section-row b { font:700 14px var(--ui); }
html[data-layout="bento-hub"] .settings-section-row small { color:var(--text-2); font-size:12px; }
html[data-layout="bento-hub"] .settings-section-row[aria-current="true"] { background:var(--bg-2); border-color:var(--accent); }
html[data-layout="bento-hub"] .settings-back { border-radius:10px; background:var(--bg-2); color:var(--text); font-size:13px; }
html[data-layout="bento-hub"] .set-group { padding:16px; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); margin-bottom:16px; }
html[data-layout="bento-hub"] .set-group-title { padding:0 0 12px; font:700 16px var(--ui); color:var(--text); text-transform:none; letter-spacing:0; }
html[data-layout="bento-hub"] .set-row { padding:14px 0; gap:12px; border-color:var(--line); }
html[data-layout="bento-hub"] .set-row > span { font-size:14px; }
html[data-layout="bento-hub"] .set-row small { color:var(--text-2); font-size:12px; line-height:1.6; }
html[data-layout="bento-hub"] :is(.set-select,.set-input,.rename-input,.ts-query,.ts-source,.modal input,.modal textarea,.modal select) { min-height:44px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); font-size:14px; box-shadow:none; }
html[data-layout="bento-hub"] input[type="range"] { accent-color:var(--accent); }
html[data-layout="bento-hub"] input[type="checkbox"] { accent-color:var(--accent-sec); }
html[data-layout="bento-hub"] .download-job { padding:16px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); margin:12px 0; }
html[data-layout="bento-hub"] .download-job b { font-size:13px; }
html[data-layout="bento-hub"] .download-job small { color:var(--text-2); font-size:12px; }
html[data-layout="bento-hub"] .download-job progress { height:6px; accent-color:var(--accent-sec); }
html[data-layout="bento-hub"] .modal-backdrop { background:rgba(0,0,0,.75); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); }
html[data-layout="bento-hub"] .modal { padding:24px; border:1px solid var(--line-bright); border-radius:16px; background:var(--bg-1); box-shadow:var(--shadow-lg); }
html[data-layout="bento-hub"] .modal h3 { font:800 22px/1.3 var(--ui); color:var(--text); }
html[data-layout="bento-hub"] :is(.modal p,.modal-subhead,.modal-char-count) { color:var(--text-2); font-size:13px; line-height:1.6; }
html[data-layout="bento-hub"] :is(.modal-list button,.modal-actions button,.sp-chip,.set-actions button,.piano-actions button,.setlist-import button) { min-height:44px; padding:10px 14px; border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); font-size:13px; }
html[data-layout="bento-hub"] .modal-actions .primary-btn { background:var(--accent); color:var(--on-accent); border-color:var(--accent); }
html[data-layout="bento-hub"] .modal-actions .danger { color:var(--danger-text); background:var(--danger-bg); border-color:transparent; }
html[data-layout="bento-hub"] .sp-chip.on { color:var(--on-accent); background:var(--accent); }
html[data-layout="bento-hub"] .toast { border:1px solid var(--line-bright); border-radius:10px; background:var(--bg-2); color:var(--text); box-shadow:var(--shadow-md); font-size:13px; }
html[data-layout="bento-hub"] .empty-state { width:100%; max-width:440px; padding:32px 24px; margin:24px auto; border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
html[data-layout="bento-hub"] .empty-icon { display:grid; place-items:center; width:56px; height:56px; border:1px solid var(--line); border-radius:999px; background:var(--bg-2); color:var(--accent-sec); box-shadow:none; transform:none; }
html[data-layout="bento-hub"] .empty-icon svg { width:24px; height:24px; }
html[data-layout="bento-hub"] .empty-title { font:800 18px/1.3 var(--ui); color:var(--text); }
html[data-layout="bento-hub"] :is(.empty-desc,.dl-how-to-save,.dl-storage-note) { font:400 13px/1.6 var(--ui); color:var(--text-2); }
html[data-layout="bento-hub"] .empty-cta { min-height:44px; padding:10px 18px; border:0; border-radius:10px; background:var(--accent); color:var(--on-accent); font-weight:700; }
html[data-layout="bento-hub"] .status { border-radius:10px; color:var(--text-2); font-size:14px; }
@media(max-width:860px) {
html[data-layout="bento-hub"] .modal { padding:20px 16px; }
html[data-layout="bento-hub"] .modal h3 { font-size:18px; }
html[data-layout="bento-hub"] .set-row { flex-wrap:wrap; }
html[data-layout="bento-hub"] .set-row > span { flex:1 1 180px; }
html[data-layout="bento-hub"] .set-row :is(select,input[type="text"],input[type="password"]) { max-width:100%; }
html[data-layout="bento-hub"] .empty-state { padding:24px 16px; }
}
/* Browsing is a full workspace; playback continues in the floating mini player. */
html[data-layout="bento-hub"] .body:has(#listPane:not(.hidden) .cards:is([data-view="search"],[data-view="channel"],[data-view="library"],[data-view="settings"],[data-view="queue"],[data-view="history"],[data-view="saved"],[data-view="downloads"])) .player-pane { display:none; }
html[data-layout="bento-hub"] .body:has(.cards:is([data-view="search"],[data-view="channel"],[data-view="library"],[data-view="settings"],[data-view="queue"],[data-view="history"],[data-view="saved"],[data-view="downloads"])) .list-pane { flex:1; width:100%; border-left:0; }
@media(min-width:900px) {
html[data-layout="bento-hub"] .settings-grid { grid-template-columns:minmax(240px,.65fr) minmax(0,1.35fr); gap:24px; }
html[data-layout="bento-hub"] .cards:is([data-view="search"],[data-view="channel"],[data-view="library"]) { grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); }
}
/* Batched reference polish: one mobile scroller, generous controls, no clipping. */
html[data-layout="bento-hub"] { --bento-stacked:0; --ease-bento:cubic-bezier(.16,1,.3,1); }
html[data-layout="bento-hub"] button { min-width:44px; }
html[data-layout="bento-hub"] .switch { min-height:44px; }
html[data-layout="bento-hub"] .hero-title { background:none; -webkit-text-fill-color:var(--text); }
html[data-layout="bento-hub"] .ph-logo { width:56px; height:56px; border-radius:16px; color:var(--on-accent); animation:none; }
html[data-layout="bento-hub"] .player-pane.empty :is(#controls,#nowPlayingMeta,#notesPanel,.upnext) { display:none; }
html[data-layout="bento-hub"] .body:has(.cards[data-view="settings"]) #listFilterBar { display:none; }
html[data-layout="bento-hub"] #controls .deck-main .ctrl { border-radius:999px; }
html[data-layout="bento-hub"] #controls input[type="range"]::-webkit-slider-thumb { border:0; box-shadow:var(--shadow-sm); }
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions :is(#addBtn,#saveBtn,#exportBtn,#serviceBtn) { border:1px solid var(--line); border-radius:10px; background:var(--bg-2); color:var(--text); box-shadow:none; min-width:44px; }
html[data-layout="bento-hub"] #nowPlayingMeta .np-actions #saveBtn.done { color:var(--accent-sec); }
html[data-layout="bento-hub"] .stage-lyrics { background:#05070a; }
html[data-layout="bento-hub"] :is(.stage-lyrics,.service-lyrics) .sl-line.active { color:#00f5d4; background:rgba(0,245,212,.08); text-shadow:none; }
html[data-layout="bento-hub"] .svc-dock { flex-wrap:wrap; gap:8px; padding:8px; }
html[data-layout="bento-hub"] .svc-dock > :is(.svc-tile,.np-btn) { flex:1 0 96px; min-width:96px; min-height:56px; }
html[data-layout="bento-hub"] .svc-dock > .np-btn { background:#19202d; border-color:rgba(255,255,255,.09); color:#f1f5f9; }
html[data-layout="bento-hub"] .svc-font { flex:1 0 112px; justify-content:center; min-height:56px; }
html[data-layout="bento-hub"] .svc-label { white-space:nowrap; }
html[data-layout="bento-hub"] .shortcut-overlay { background:rgba(0,0,0,.75); backdrop-filter:blur(8px); }
html[data-layout="bento-hub"] .shortcut-panel { border:1px solid var(--line); border-radius:16px; background:var(--bg-1); }
html[data-layout="bento-hub"] .shortcut-key { border-radius:6px; background:var(--bg-2); color:var(--text); font:500 12px var(--mono); }
html[data-layout="bento-hub"] :is(.modal-list button,.mc-row,.export-actions .btn) { min-height:44px; border-radius:10px; background:var(--bg-2); color:var(--text); border-color:var(--line); }
html[data-layout="bento-hub"] .bento-search-label { position:absolute; width:1px; height:1px; padding:0; margin:-1px; clip-path:inset(50%); overflow:hidden; white-space:nowrap; }
html[data-layout="bento-hub"] .bento-search-icon { display:none; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
html[data-layout="bento-hub"] .saved-badge .glass-control-icon { width:14px; height:14px; }
html[data-layout="bento-hub"] .card.downloading .saved-badge::before { content:''; }
@media(max-width:1023px) {
html[data-layout="bento-hub"] .app { grid-template-columns:minmax(0,1fr); }
html[data-layout="bento-hub"] .sidebar { position:fixed; inset:0 auto 0 0; width:260px; max-width:calc(100vw - 48px); z-index:350; transform:translateX(-100%); transition:transform .25s var(--ease-bento); }
html[data-layout="bento-hub"] .app.sidebar-open .sidebar { transform:translateX(0); }
html[data-layout="bento-hub"] .app.sidebar-open .sidebar-backdrop { display:block; opacity:1; z-index:340; }
html[data-layout="bento-hub"] .sidebar-toggle { display:inline-flex; }
html[data-layout="bento-hub"] .mini-bar { left:12px; }
}
@media(max-width:860px) {
html[data-layout="bento-hub"] { --bento-stacked:1; }
html[data-layout="bento-hub"] .body { flex-direction:column; overflow-y:auto; overflow-x:hidden; min-height:0; }
html[data-layout="bento-hub"] .player-pane { flex:none; width:100%; overflow:visible; }
html[data-layout="bento-hub"] .list-pane { flex:none!important; min-height:100%; overflow:visible; padding-bottom:16px; }
html[data-layout="bento-hub"] .cards { flex:none; overflow:visible; }
html[data-layout="bento-hub"] .player-pane.empty.home-playlists .player-stage { max-height:none; }
html[data-layout="bento-hub"] .search-btn { display:grid; place-items:center; flex:0 0 44px; width:44px; min-width:44px; padding:0; border-radius:999px; }
html[data-layout="bento-hub"] .bento-search-icon { display:block; }
html[data-layout="bento-hub"] .search-form { min-width:0; flex:1; gap:8px; }
html[data-layout="bento-hub"] #searchInput { min-width:0; padding-inline:12px; }
html[data-layout="bento-hub"] .home-pl-grid { grid-template-columns:1fr; }
}
@media(min-width:861px) { html[data-layout="bento-hub"] .bento-search-label { position:static; width:auto; height:auto; margin:0; clip-path:none; overflow:visible; } }
/* Bound the scroll viewport above floating chrome, rather than painting rows
underneath it and relying only on extra padding at the end of the list. */
html[data-layout="bento-hub"] .main { padding-bottom:var(--bento-bottom-clearance,16px); min-height:0; }
html[data-layout="bento-hub"] :is(.body,.player-pane,.cards) { padding-bottom:16px; }
html[data-layout="bento-hub"] .modal-backdrop { z-index:600; }
html[data-layout="bento-hub"] .shortcut-overlay { z-index:600; }

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ testDir:'./tests', testMatch:/bento-hub\.spec\.js/, timeout:30000, workers:2, projects:[{name:'chromium',use:{browserName:'chromium'}},{name:'webkit',use:{browserName:'webkit'}}], use:{baseURL:'http://localhost:8105',serviceWorkers:'block'}, webServer:{command:`npx serve ${process.env.BENTO_BASELINE || 'frontend'} -l 8105 --no-clipboard`,port:8105} });

View File

@@ -0,0 +1,12 @@
/* Reproducible before/after comparison without changing the working tree. */
const {execFileSync,spawnSync}=require('node:child_process');
const {mkdtempSync,rmSync}=require('node:fs');
const {join}=require('node:path');
const {tmpdir}=require('node:os');
const baseline=mkdtempSync(join(tmpdir(),'ytplayer-bento-baseline-'));
try {
const archive=execFileSync('git',['archive',process.env.BENTO_BASE_COMMIT||'04b620e','frontend']);
execFileSync('tar',['-x','-C',baseline],{input:archive});
const run=env=>{const result=spawnSync('npx',['playwright','test','--config','playwright.bento.config.js','--workers','4'],{stdio:'inherit',env:{...process.env,...env}});if(result.status!==0)process.exitCode=result.status||1;return result.status===0;};
if(run({CAPTURE_BEFORE:'1',BENTO_BASELINE:join(baseline,'frontend')}))run({CAPTURE_BEFORE:'0',BENTO_BASELINE:''});
} finally {rmSync(baseline,{recursive:true,force:true});}

102
tests/bento-hub.spec.js Normal file
View File

@@ -0,0 +1,102 @@
const {test,expect}=require('@playwright/test');
const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture');
const {mkdirSync,readFileSync,existsSync}=require('node:fs');
const {join}=require('node:path');
const {homedir}=require('node:os');
const {differentPixels}=require('./helpers/png-diff');
const out=process.env.BENTO_SCREENSHOT_DIR||join(homedir(),'deliverables','ytplayer-done13');mkdirSync(out,{recursive:true});
const before=process.env.CAPTURE_BEFORE==='1';
const screens=['home','search','playlist','queue','history','saved','downloads','channel','settings','settings-detail','now-playing','lyrics','chapters','lyric-search','service','modal','sheet','stage-lyrics','batch','library','empty-queue','empty-saved','empty-downloads'];
async function scene(page,layout,screen){
// Freeze the automatic Classic mini-player observer in these static scenes.
// Otherwise its pending first callback races our intentionally visible mini bar.
await page.addInitScript(()=>{const Observer=window.IntersectionObserver;window.IntersectionObserver=class extends Observer{observe(el){if(el.id!=='controls')super.observe(el);}};});
await openClassic(page,{populated:true,standalone:page.viewportSize().width<900});await showPlayingFixture(page);
await page.evaluate(({layout,screen})=>{
data.settings.layout=layout;applyAppearance();
const videos=data.playlists[0].videos;
API.cacheList=async()=>({ok:true,items:screen==='empty-saved'?[]:videos.map(v=>({...v,size:84000000})),total:screen==='empty-saved'?0:1008000000});
Downloads.configure({slots:()=>4,files:async()=>[],playlists:()=>data.playlists,jobs:()=>!['downloads','settings-detail'].includes(screen)?[]:['active','queued','paused','failed'].map((status,i)=>({...videos[i],status,error:status==='failed'?'ERROR: [youtube] Video unavailable':null,progress:status==='active'?{received:24000000,total:84000000}:null})),action:()=>{}});
channelData={name:'Preview fixtures',url:'https://www.youtube.com/@preview',key:'preview',results:videos,loading:false};
searchResults=screen==='home'?[]:videos; searchAttempted=screen!=='home';
if(screen==='empty-queue'){queue=[];data.queue=[];}
view=screen==='home'?{type:'search'}:screen==='playlist'?{type:'playlist',id:'studio'}:{type:screen.replace('empty-','')};
if(screen==='home'){current=null;els.playerPane.classList.add('empty');els.placeholder.classList.remove('hidden');}
if(['now-playing','lyrics','chapters','lyric-search','service','modal','sheet','stage-lyrics','batch','library'].includes(screen))view={type:'playlist',id:'studio'};
if(screen==='settings-detail')view={type:'settings'};
render();renderUpNext();
if(screen!=='home')showMiniBar();
if(['lyrics','chapters','lyric-search'].includes(screen)){
$('notesPanel').classList.remove('hidden');
const tab=screen==='lyrics'?'lyrics':screen==='chapters'?'markers':'search';
document.querySelectorAll('.notes-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
['lyrics','markers','search'].forEach(t=>$(t+'Pane').classList.toggle('hidden',t!==tab));
$('lyricsList').innerHTML='<div class="lyr-line"><span class="lyr-text">Because You are God</span></div><div class="lyr-line active"><span class="lyr-text">You can do anything<br>You can do anything</span></div><div class="lyr-line"><span class="lyr-text">Your grace is enough</span></div>';
$('chapterList').innerHTML='<button class="marker-row"><span>0:00</span><b>Opening verse</b></button><button class="marker-row"><span>1:24</span><b>Chorus</b></button>';
}
if(screen==='service'){data.settings.serviceLyrics=true;enterServiceMode(data.playlists[0]);$('serviceLyrics').classList.remove('hidden');$('serviceLyricsList').innerHTML='<div class="sl-line">Because You are God</div><div class="sl-line active">You can do anything</div>';}
if(screen==='batch'){selectMode=true;selectedIds=new Set(videos.slice(0,2).map(v=>v.id));renderList();}
if(screen==='sheet')openMoreControls();
if(screen==='stage-lyrics'){ $('stageLyrics').classList.remove('hidden');$('stageLyricsList').innerHTML='<div class="sl-line">Because You are God</div><div class="sl-line active">You can do anything</div>'; }
if(screen==='modal')showModal('Add to playlist',document.createTextNode('Keep this song in your Sunday setlist.'),[{label:'Cancel',onClick:closeModal},{label:'Add',primary:true,onClick:closeModal}]);
},{layout,screen});
if(screen==='settings-detail')await page.getByRole('button',{name:/Downloads & storage/}).click();
if(layout==='bento-hub'&&['now-playing','lyrics','chapters','lyric-search','stage-lyrics'].includes(screen))await page.evaluate(()=>document.querySelector('.body').scrollTop=0);
await page.evaluate(()=>document.querySelectorAll('progress:not([value])').forEach(el=>el.value=0));
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}'});
await page.evaluate(async()=>{ await document.fonts.ready; await Promise.all([...document.images].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); if(document.activeElement instanceof HTMLElement)document.activeElement.blur(); });await page.waitForTimeout(300);
// Short Classic empty lists otherwise clamp navigation one pixel before
// their header on WebKit. An offscreen spacer makes the static route exact.
if(layout==='classic'&&screen==='empty-saved'&&page.viewportSize().width===390)await page.evaluate(()=>{const body=document.querySelector('.body');$('listPane').style.minHeight=(body.clientHeight+8)+'px';});
// render() navigates before fonts finish; settle the real list route once
// after font metrics are known, so WebKit cannot capture an old scroll offset.
if(!['home','now-playing','lyrics','chapters','lyric-search','stage-lyrics','service','modal','sheet','batch'].includes(screen)){await page.evaluate(()=>scrollListIntoViewPortrait());await page.waitForTimeout(100);}
}
for(const width of [390,1440])for(const screen of screens)for(const layout of before?['classic','glass-stage']:['classic','glass-stage','bento-hub'])test(`${layout} ${width} ${screen}`,async({page},info)=>{
await page.setViewportSize({width,height:844});await scene(page,layout,screen);
const name=`${layout}-${width}-${screen}-${info.project.name}.png`;
const buffer=await page.screenshot({path:`${out}/${before?'before':'after'}-${name}`});
if(before)return;
if(layout!=='bento-hub'){expect(differentPixels(readFileSync(`${out}/before-${name}`),buffer)).toBeLessThanOrEqual(40);return;}
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
if(['lyrics','chapters','lyric-search'].includes(screen)){await page.locator('#notesPanel').scrollIntoViewIfNeeded();await page.screenshot({path:`${out}/panel-${name}`});}
if(screen==='now-playing')await expect(page.locator('#controls')).toBeVisible();
if(screen==='downloads'){const retry=page.getByRole('button',{name:/^Retry /});await expect(retry).toBeVisible();expect(await retry.evaluate(el=>getComputedStyle(el).backgroundColor)).toBe('rgb(255, 183, 3)');}
if(width===390&&!['service','modal','sheet'].includes(screen)){await expect(page.locator('#bottomNav')).toBeVisible(); for(const button of await page.locator('#bottomNav button').all()){const icon=await button.locator('.bottom-nav-icon').boundingBox(),label=await button.locator('.bottom-nav-label').boundingBox();expect(Math.abs(icon.x+icon.width/2-label.x-label.width/2)).toBeLessThan(1);}}
expect(await page.locator('#sectionRail').evaluate(el=>el.parentElement===document.body)).toBe(true);
if(!['service','modal','sheet','stage-lyrics','batch','library'].includes(screen)&&width===390){const tabs=page.locator('#bottomNav button');for(const b of await tabs.all()){if(await b.isVisible()){const r=await b.boundingBox();expect(r.height).toBeGreaterThanOrEqual(44);}}}
});
for(const width of [390,1440])test(`reference ${width}`,async({page},info)=>{
test.skip(!existsSync('docs/mockups/bento-hub/index.html'),'The design reference is an optional local deliverable.');
await page.setViewportSize({width,height:844});await page.route('**/__bento-reference',r=>r.fulfill({contentType:'text/html',body:readFileSync('docs/mockups/bento-hub/index.html','utf8')}));await page.goto('/__bento-reference');await page.evaluate(()=>document.fonts.ready);
await page.screenshot({path:`${out}/reference-${width}-${info.project.name}.png`,fullPage:true});
for(const section of await page.locator('.screen-section').all()){
await section.evaluate(el=>{document.querySelectorAll('.screen-section').forEach(s=>s.classList.remove('active'));el.classList.add('active');});
const id=await section.getAttribute('id');
const frame=section.locator(width===390?'.frame-phone':'.frame-desktop');
if(await frame.count())await frame.screenshot({path:`${out}/reference-${id}-${width}-${info.project.name}.png`});
}
});
if(!before){
for(const width of [390,960])test(`Bento ${width} navigation and floating clearance`,async({page})=>{
await page.setViewportSize({width,height:844});await scene(page,'bento-hub','downloads');
await page.locator('#sidebarToggle').click();await expect(page.locator('.app')).toHaveClass(/sidebar-open/);await page.locator('#sidebarBackdrop').click({position:{x:width-8,y:20}});
const chrome=await page.evaluate(()=>{const body=document.querySelector('.body').getBoundingClientRect(),mini=$('miniBar').getBoundingClientRect();return {bodyBottom:body.bottom,miniTop:mini.top};});
expect(chrome.bodyBottom).toBeLessThan(chrome.miniTop);
if(width===390){await page.locator('#miniTitle').click();await expect(page.locator('#controls')).toBeInViewport();}
});
for(const layout of ['classic','glass-stage'])test(`Bento restores ${layout} after switching`,async({page})=>{
await page.setViewportSize({width:390,height:844});await scene(page,layout,'home');await page.evaluate(()=>document.querySelector('.body').scrollTop=0);const original=await page.screenshot();
await page.evaluate(()=>{data.settings.layout='bento-hub';applyAppearance();});await page.waitForTimeout(100);
await page.evaluate(layout=>{data.settings.layout=layout;applyAppearance();},layout);await page.waitForTimeout(100);await page.evaluate(()=>document.querySelector('.body').scrollTop=0);
// Chromium may rerasterize composited corners by one RGB level after a theme switch.
expect(differentPixels(original,await page.screenshot(),1)).toBeLessThanOrEqual(40);
});
for(const theme of ['light','contrast'])test(`Bento ${theme} appearance`,async({page},info)=>{
await page.setViewportSize({width:390,height:844});await scene(page,'bento-hub','saved');await page.evaluate(theme=>{data.settings.theme=theme;applyAppearance();},theme);
await page.screenshot({path:`${out}/after-bento-hub-390-${theme}-${info.project.name}.png`});
await expect(page.locator('.saved-file-action').first()).toBeVisible();
});
}

View File

@@ -19,9 +19,9 @@ function decode(buffer) {
}
return {width,height,channels,pixels};
}
function differentPixels(first,second){
function differentPixels(first,second,channelTolerance=0){
const a=decode(first),b=decode(second);if(a.width!==b.width||a.height!==b.height||a.channels!==b.channels)return Infinity;
let count=0;for(let i=0;i<a.pixels.length;i+=a.channels)if(!a.pixels.subarray(i,i+a.channels).equals(b.pixels.subarray(i,i+a.channels)))count++;
let count=0;for(let i=0;i<a.pixels.length;i+=a.channels)if([...a.pixels.subarray(i,i+a.channels)].some((value,channel)=>Math.abs(value-b.pixels[i+channel])>channelTolerance))count++;
return count;
}
module.exports={differentPixels};