diff --git a/frontend/app.js b/frontend/app.js index 25ff7d4..82a86b2 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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'); } diff --git a/frontend/bento-hub.js b/frontend/bento-hub.js new file mode 100644 index 0000000..8de6fd5 --- /dev/null +++ b/frontend/bento-hub.js @@ -0,0 +1,35 @@ +/* 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').forEach(n=>n.remove());}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 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); diff --git a/frontend/bento-hub.test.js b/frontend/bento-hub.test.js new file mode 100644 index 0000000..17e15d3 --- /dev/null +++ b/frontend/bento-hub.test.js @@ -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); +}); diff --git a/frontend/fonts/PlusJakartaSans-OFL.txt b/frontend/fonts/PlusJakartaSans-OFL.txt new file mode 100644 index 0000000..ea69b0c --- /dev/null +++ b/frontend/fonts/PlusJakartaSans-OFL.txt @@ -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. diff --git a/frontend/fonts/PlusJakartaSans-latin-ext.woff2 b/frontend/fonts/PlusJakartaSans-latin-ext.woff2 new file mode 100644 index 0000000..f82597c Binary files /dev/null and b/frontend/fonts/PlusJakartaSans-latin-ext.woff2 differ diff --git a/frontend/fonts/PlusJakartaSans-latin.woff2 b/frontend/fonts/PlusJakartaSans-latin.woff2 new file mode 100644 index 0000000..a180dc4 Binary files /dev/null and b/frontend/fonts/PlusJakartaSans-latin.woff2 differ diff --git a/frontend/glass-controls.js b/frontend/glass-controls.js index 3d79954..1de7b48 100644 --- a/frontend/glass-controls.js +++ b/frontend/glass-controls.js @@ -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 = { @@ -45,10 +45,11 @@ } 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':''))) { // 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]) { diff --git a/frontend/index.html b/frontend/index.html index f75954a..4449bc8 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,6 +38,7 @@ +
@@ -678,6 +679,7 @@ + diff --git a/frontend/styles.css b/frontend/styles.css index fe1407b..613f9fa 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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. */ diff --git a/frontend/sw.js b/frontend/sw.js index b1756e7..2aada81 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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', diff --git a/frontend/theme-bento-hub.css b/frontend/theme-bento-hub.css new file mode 100644 index 0000000..4cb314f --- /dev/null +++ b/frontend/theme-bento-hub.css @@ -0,0 +1,130 @@ +/* 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; } diff --git a/playwright.bento.config.js b/playwright.bento.config.js new file mode 100644 index 0000000..47edac2 --- /dev/null +++ b/playwright.bento.config.js @@ -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} }); diff --git a/tests/bento-hub.spec.js b/tests/bento-hub.spec.js new file mode 100644 index 0000000..920f119 --- /dev/null +++ b/tests/bento-hub.spec.js @@ -0,0 +1,52 @@ +const {test,expect}=require('@playwright/test'); +const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture'); +const {mkdirSync,readFileSync}=require('node:fs'); +const {differentPixels}=require('./helpers/png-diff'); +const out='/home/josh/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','empty-queue','empty-saved','empty-downloads']; +async function scene(page,layout,screen){ + 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'].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='
Because You are God
You can do anything
You can do anything
Your grace is enough
'; + $('chapterList').innerHTML=''; + } + if(screen==='service'){data.settings.serviceLyrics=true;enterServiceMode(data.playlists[0]);$('serviceLyrics').classList.remove('hidden');$('serviceLyricsList').innerHTML='
Because You are God
You can do anything
';} + 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(); + await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!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); +} +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); + expect(await page.locator('#sectionRail').evaluate(el=>el.parentElement===document.body)).toBe(true); + if(!['service','modal'].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)=>{ + 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}); +});