Compare commits

..

10 Commits

Author SHA1 Message Date
Syco ab42494d97 Add Audiobookshelf and Readarr to app quicklinks
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-31 13:00:24 +02:00
Syco 5ac98d966c Fix qBittorrent 5.x session cookie name (SID -> QBT_SID_{port})
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-20 19:28:08 +02:00
Syco d2a4f3dbad Log full qBittorrent login response headers
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-20 19:27:01 +02:00
Syco dca9312753 Add qBittorrent login diagnostics
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-20 19:19:17 +02:00
Syco dfcce073db Show qBittorrent auth response in error message for debugging
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-20 19:15:24 +02:00
Syco be16444e93 Add refresh button to Docker widget with server cache bust
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-16 18:11:49 +02:00
Syco fc0ad6e68e Responsive header: two-row layout on mobile
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-16 18:10:23 +02:00
Syco 638c7d524f Make header sticky at top of viewport
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-16 18:06:17 +02:00
Syco 92cb6a86e2 Route lscr.io update checks through ghcr.io
ci/woodpecker/push/woodpecker Pipeline was successful
lscr.io is a mirror of ghcr.io — same manifests and auth service.
Using the ghcr.io token flow (with GITHUB_TOKEN) is reliable; calling
the lscr.io token endpoint with service=lscr.io silently fails.
2026-05-16 18:03:29 +02:00
Syco 367a2d8a27 Fix lscr.io image update verification; tighten Docker widget
lscr.io is backed by ghcr.io and requires a GitHub token for registry
auth — passing it fixes the silent null return from the token endpoint.
Widget now shows only confirmed outdated/current containers, with
unverified and unsupported-registry images summarised as a footer count.
2026-05-16 18:00:15 +02:00
8 changed files with 120 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="audiobookshelf_svg__a" x1="255.5" x2="255.5" y1="991.4" y2="496.6" gradientTransform="matrix(1 0 0 -1 0 1000)" gradientUnits="userSpaceOnUse"><stop offset=".32" style="stop-color:#cd9d49"/><stop offset=".99" style="stop-color:#875d27"/></linearGradient><circle cx="255.5" cy="256" r="247.4" style="fill:url(#audiobookshelf_svg__a)"/><path d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256 256-114.6 256-256S397.4 0 256 0m-.5 503.4C118.9 503.4 8.1 392.6 8.1 256S118.9 8.6 255.5 8.6 502.9 119.4 502.9 256 392.1 503.4 255.5 503.4m160.7-265.8c-2-1.7-5.1-4.1-9.4-7v-32.8c0-83.6-67.7-151.3-151.3-151.3s-151.3 67.7-151.3 151.3v32.8c-4.2 2.9-7.3 5.4-9.4 7-1.7 1.4-2.7 3.5-2.7 5.8v39.3c0 2.2 1 4.3 2.7 5.8 4.7 3.9 15.4 12 32.1 20.4v3.8c0 10.3 6.6 18.6 14.8 18.6s14.8-8.4 14.8-18.6v-94.2c0-10.3-6.6-18.6-14.8-18.6-7.9 0-14.3 7.7-14.8 17.3v-19.4c0-71 57.6-128.6 128.6-128.6 71.1 0 128.6 57.6 128.6 128.6v19.4c-.5-9.7-7-17.3-14.8-17.3-8.2 0-14.8 8.4-14.8 18.6v94.2c0 10.3 6.6 18.6 14.8 18.6s14.8-8.4 14.8-18.6v-3.8c16.7-8.4 27.4-16.5 32.1-20.4 1.7-1.4 2.7-3.6 2.7-5.8v-39.3c-.1-2.3-1-4.4-2.7-5.8M202.7 401.3c9.9 0 17.9-8 17.9-17.9V182.8c0-9.9-8-17.9-17.9-17.9h-18.5c-9.9 0-17.9 8-17.9 17.9v200.6c0 9.9 8 17.9 17.9 17.9zM173.1 213h40.8v4.3h-40.8zm91.6 188.3c9.9 0 17.9-8 17.9-17.9V182.8c0-9.9-8-17.9-17.9-17.9h-18.5c-9.9 0-17.9 8-17.9 17.9v200.6c0 9.9 8 17.9 17.9 17.9zM235.1 213h40.8v4.3h-40.8zm91.7 188.3c9.9 0 17.9-8 17.9-17.9V182.8c0-9.9-8-17.9-17.9-17.9h-18.5c-9.9 0-17.9 8-17.9 17.9v200.6c0 9.9 8 17.9 17.9 17.9zM297.1 213h40.8v4.3h-40.8zM135.4 407.5h240.2c7.4 0 13.5 6 13.5 13.5 0 7.4-6 13.5-13.5 13.5H135.4c-7.4 0-13.5-6-13.5-13.5s6-13.5 13.5-13.5" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="readarr_svg__Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.readarr_svg__st0{fill:#eee}</style><circle id="readarr_svg__svg_2_00000098218664590018320060000008938703571635696276_" cx="256" cy="256" r="255.6" class="readarr_svg__st0"/><path d="M256 512c-34.6 0-68.1-6.8-99.6-20.1C125.9 479 98.5 460.5 75 437s-42-50.9-54.9-81.4C6.8 324.1 0 290.6 0 256s6.8-68.1 20.1-99.6C33 125.9 51.5 98.5 75 75s50.9-42 81.4-54.9C187.9 6.8 221.4 0 256 0s68.1 6.8 99.6 20.1C386.1 33 413.5 51.5 437 75s42 50.9 54.9 81.4C505.2 188 512 221.5 512 256s-6.8 68.1-20.1 99.6C479 386.1 460.5 413.5 437 437s-50.9 42-81.4 54.9c-31.5 13.3-65 20.1-99.6 20.1M256 .8C115.3.8.8 115.3.8 256S115.3 511.2 256 511.2 511.2 396.7 511.2 256 396.7.8 256 .8M459.6 170c-11.1-26.3-27.1-49.9-47.3-70.2s-44-36.2-70.3-47.4C314.8 40.9 285.8 35 256 35s-58.8 5.8-86 17.4c-26.3 11.1-49.9 27.1-70.2 47.3s-36.2 44-47.4 70.3C40.9 197.2 35 226.2 35 256s5.8 58.8 17.4 86c11.1 26.3 27.1 49.9 47.3 70.2s43.9 36.2 70.2 47.3c27.2 11.5 56.2 17.4 86 17.4s58.8-5.8 86-17.4c26.3-11.1 49.9-27.1 70.2-47.3s36.2-43.9 47.3-70.2c11.5-27.2 17.4-56.2 17.4-86 .1-29.8-5.7-58.8-17.2-86" style="fill:#443c3c"/><path d="M425.9 172.1c.1-6.3.2-9.9.3-10-.3-26.7-8.8-24-15.3-24.8-2.4.1-4.7.3-6.9.4-34.8-43.4-88.1-71.2-148-71.2s-113.4 27.8-148.1 71.3c-2.6-.2-5.3-.4-8.1-.5-6.5.8-14.9-1.9-15.3 24.8.1.1.2 4.7.3 12.5C73 199.3 66.4 226.9 66.4 256c0 31.5 7.7 61.2 21.3 87.3.3 1.2.9 2.6 1.9 3.6 32.2 58.7 94.6 98.6 166.3 98.6 104.6 0 189.5-84.8 189.5-189.5.1-30.1-7-58.6-19.5-83.9M255.3 389.9c-1.8 0-3.6 0-5.4-.1-.2-42.5-1.6-199.7-1.9-200 .3.3-3.5-13.1-10.2-15.6-1.5-.8-18.6-14.9-60.3-25.6 22.5-16.3 49.5-25.1 77.8-25.1s55.3 8.8 77.8 25c-41.6 10.7-58.7 24.9-60.2 25.6l-2.8 2c-5.9 3.5-6.6 8.1-6.2 7.8-.3.3-2.6 161.7-3 205.8-1.9.1-3.7.2-5.6.2" class="readarr_svg__st0"/><path d="M450 256c0-32-7.7-62.2-21.5-88.8 0-1.9.1-3.3.1-4.1l.1-.1v-1c-.3-24.2-7.2-26.6-15.5-27.1-.7 0-1.4-.1-2-.2h-.4c-1.1.1-2.2.1-3.2.2C372 90.5 317.3 62 256 62c-61.5 0-116.4 28.7-151.9 73.4-1.3-.1-2.7-.2-4-.2h-.4c-.6.1-1.3.1-2 .2-8.3.5-15.2 2.9-15.5 27.1v1l.1.1c0 1.1.1 3.1.1 6C69.4 195.5 62 224.9 62 256c0 32.8 8.2 63.7 22.5 90.8.1 8.1.2 15.8.3 22.7 0 2.9 1.5 8.5 7.2 8.9.3.1.8.2 1.6.3 4 .7 8.9 1.7 14.5 2.9C143.8 423.5 196.8 450 256 450s112.2-26.5 147.8-68.3c5.1-1.1 9.6-2 13.3-2.6.8-.1 1.3-.2 1.6-.3 5.7-.4 7.2-6 7.2-8.9.1-6.3.2-13.3.3-20.6 15.1-27.7 23.8-59.5 23.8-93.3m-383.1 0c0-26.8 5.6-52.2 15.7-75.3v.5q-.45 4.2-.6 9.6v.5c.3 6.4 1.5 84 2.4 144-11.2-24.1-17.5-51-17.5-79.3m185.6 172.9v-9c0-3.6 0-8.2-.1-13.7.3-1.5.4-3.4.1-5.6v-9c0-5.6-.1-13.6-.1-23.9-.1-19.1-.3-44.7-.6-72.3-.2-27.5-.5-53.2-.7-72.3-.1-10.3-.2-18.3-.3-23.9 0-2.9-.1-5.2-.1-6.7 0-.8 0-1.4-.1-1.9v-.5c0-.1 0-.2-.1-.4 0-.2-.1-.4-.1-.5-.8-3.3-4.5-14.1-11.4-16.9-.2-.1-.5-.3-.8-.5-16.2-11.3-54.1-30.4-128.2-35.9C144.7 93.8 197.2 67 255.9 67c58.4 0 110.7 26.6 145.4 68.4-74.6 5.4-112.6 24.7-128.9 36.1-.3.2-.6.4-.8.5-6.9 2.8-10.6 13.6-11.4 16.9-.1.1-.1.3-.1.5s-.1.3-.1.4v.4c0 .5 0 1.1-.1 2 0 1.6-.1 3.8-.1 6.7-.1 5.6-.2 13.6-.3 23.9-.2 19.1-.5 44.9-.7 72.5s-.5 53.3-.6 72.4c-.1 10.3-.1 18.3-.1 23.9v9c-.3 2-.2 3.7 0 5.2 0 5.8-.1 10.7-.1 14.4v9c-1 6.9 1.6 9.8 4 10.9 1 .5 2.3.8 3.4.8.8 0 1.4-.1 1.8-.4 42-32.5 94.6-49.1 128.2-57-34.6 37.8-84.3 61.6-139.5 61.6S151 421.4 116.4 383.6c33.6 8 85.4 24.6 126.9 56.6.4.3 1 .4 1.8.4 1.1 0 2.3-.3 3.4-.8 2.4-1.2 5-4 4-10.9m176.2-237.7v-.6c0-3.4-.2-6.4-.5-9 0-1.2 0-2.3.1-3.4 10.8 23.8 16.8 50.1 16.8 77.9 0 29.4-6.7 57.3-18.8 82.1.9-60.6 2.1-140.6 2.4-147m-17.9-51.5c.7.1 1.4.1 2.1.2 5.4.3 10.5.7 10.8 22.1 0 .5-.1 1.1-.1 2 0 1-.1 2.4-.1 4.1-2.8-3-6.2-3.2-9.2-3.4-.7 0-1.4-.1-2.1-.2h-.2c-81.7 4.4-122.7 24.7-139.8 36.4-.4.3-.8.5-.9.6-2.2 1.9-4.4 4.7-6.3 7.5.1-11.3.3-18.6.3-19.5.7-2.6 4.1-11.5 8.5-13.1l.2-.1c.3-.2.7-.4 1.4-.9 16.5-11.4 56.1-31.3 135.4-35.7m-312.8.6c.7 0 1.4-.1 2.1-.2 79.4 4.4 118.9 24.2 135.4 35.7.7.5 1.1.7 1.4.9l.2.1c4.6 1.7 8.1 11.4 8.5 13.4l.1 1.3c.1 2.5.1 8.1.2 16.1-1.7-2.6-3.9-4.9-6.4-5.9-.2-.1-.5-.4-.9-.6-17.1-11.7-58.1-31.9-139.8-36.3h-.2c-.7.1-1.4.1-2.1.2-3 .2-6.4.4-9.2 3.3 0-1.6-.1-2.9-.1-3.8s0-1.6-.1-2c.3-21.5 5.4-21.8 10.9-22.2" style="fill:#8e2222"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+7 -3
View File
@@ -17,11 +17,15 @@ async function getCookie(): Promise<string> {
const res = await axios.post(
`${host}/api/v2/auth/login`,
new URLSearchParams({ username: user, password: pass }),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': host, 'Origin': host } }
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': host, 'Origin': host },
validateStatus: s => s < 400,
}
)
const cookie = (res.headers['set-cookie'] ?? []).find((c: string) => c.startsWith('SID='))
if (!cookie || res.data === 'Fails.') throw new Error('qBittorrent login failed')
// qBittorrent 5.x renamed the cookie from SID to QBT_SID_{port}
const cookie = (res.headers['set-cookie'] ?? []).find((c: string) => /^(QBT_)?SID[_=]/.test(c))
if (!cookie) throw new Error(`qBittorrent login failed (${res.status}): "${String(res.data).trim()}"`)
sid = cookie.split(';')[0]
sidExpiry = Date.now() + 55 * 60 * 1000
+11 -5
View File
@@ -119,18 +119,23 @@ async function getGhcrLatestDigest(image: string, tag: string): Promise<string |
}
async function getLscrLatestDigest(image: string, tag: string): Promise<string | null> {
return getGenericRegistryDigest('lscr.io', image, tag)
// lscr.io is backed by ghcr.io and requires the same GitHub token auth
return getGenericRegistryDigest('lscr.io', image, tag, process.env.GITHUB_TOKEN)
}
router.get('/docker', async (_req, res) => {
router.get('/docker', async (req, res) => {
const host = process.env.PORTAINER_HOST
if (!host) {
res.status(503).json({ error: 'PORTAINER_HOST not configured' })
return
}
const cached = fromCache<{ containers: ContainerInfo[] }>('docker:full', HUB_TTL)
if (cached) { res.json(cached); return }
if (!req.query.refresh) {
const cached = fromCache<{ containers: ContainerInfo[] }>('docker:full', HUB_TTL)
if (cached) { res.json(cached); return }
} else {
delete cache['docker:full']
}
try {
const headers = portainerHeaders()
@@ -187,7 +192,8 @@ router.get('/docker', async (_req, res) => {
const latest = await getGhcrLatestDigest(name, tag)
if (latest) upToDate = latest === repoDigest
} else if (isLscr && repoDigest) {
const latest = await getLscrLatestDigest(name, tag)
// lscr.io is a mirror of ghcr.io — same manifests, same digests, same auth
const latest = await getGhcrLatestDigest(name, tag)
if (latest) upToDate = latest === repoDigest
}
+7 -5
View File
@@ -52,12 +52,14 @@ export function Header() {
<div className="logo-sub">homelab dashboard</div>
</div>
</div>
<nav className="nav-tabs">
<NavLink to="/home" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Home</NavLink>
<NavLink to="/dashboard" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Dashboard</NavLink>
<NavLink to="/apps" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps</NavLink>
</nav>
<div className="topbar-right">
<nav className="nav-tabs">
<NavLink to="/home" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Home</NavLink>
<NavLink to="/dashboard" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Dashboard</NavLink>
<NavLink to="/apps" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps</NavLink>
</nav>
{weather && (
<div className="weather-inline">
<span className="weather-emoji">{weatherEmoji(weather.code)}</span>
+50 -35
View File
@@ -9,35 +9,59 @@ interface ContainerInfo {
endpoint: string
}
const KNOWN = ['docker.io', 'ghcr.io', 'lscr.io']
export function DockerUpdatesWidget() {
const [containers, setContainers] = useState<ContainerInfo[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
useEffect(() => {
fetch('/api/updates/docker')
const load = (bust = false) => {
if (bust) setRefreshing(true)
else setLoading(true)
setError(null)
fetch(`/api/updates/docker${bust ? '?refresh=1' : ''}`)
.then(r => r.json())
.then(d => {
if (d.error) setError(d.error)
else setContainers(d.containers)
})
.then(d => { if (d.error) setError(d.error); else setContainers(d.containers) })
.catch(() => setError('Failed to connect'))
.finally(() => setLoading(false))
}, [])
.finally(() => { setLoading(false); setRefreshing(false) })
}
const outdated = containers.filter(c => c.upToDate === false).length
const knownRegistries = ['docker.io', 'ghcr.io', 'lscr.io']
const unknown = containers.filter(c => c.upToDate === null && !knownRegistries.includes(c.registry)).length
useEffect(() => { load() }, [])
const outdated = containers.filter(c => c.upToDate === false)
const upToDate = containers.filter(c => c.upToDate === true)
const unverified = containers.filter(c => c.upToDate === null && KNOWN.includes(c.registry))
const external = containers.filter(c => !KNOWN.includes(c.registry))
// Show: outdated first, then up to date. Unverified and ext only as a footer count.
const visible = [...outdated, ...upToDate]
return (
<div className="card">
<div className="widget-header">
<div className="widget-title"><span className="dot" />Docker</div>
{!loading && !error && (
<span className="widget-badge" style={outdated > 0 ? { background: 'rgba(248,113,113,0.15)', color: 'var(--red)' } : {}}>
{outdated > 0 ? `${outdated} outdated` : 'all current'}
</span>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{!loading && !error && (
<span className="widget-badge" style={outdated.length > 0 ? { background: 'rgba(248,113,113,0.15)', color: 'var(--red)' } : {}}>
{outdated.length > 0 ? `${outdated.length} outdated` : 'all current'}
</span>
)}
<button
onClick={() => load(true)}
disabled={refreshing || loading}
title="Re-check all images"
style={{
background: 'none', border: '1px solid var(--border)', borderRadius: 6,
color: 'var(--muted)', cursor: 'pointer', padding: '2px 6px', fontSize: 11,
lineHeight: 1, transition: 'color 0.15s',
opacity: refreshing || loading ? 0.4 : 1,
}}
>
{refreshing ? '…' : '↺'}
</button>
</div>
</div>
{loading && <div className="widget-loading">Checking images</div>}
@@ -45,13 +69,7 @@ export function DockerUpdatesWidget() {
{!loading && !error && (
<div className="progress-group" style={{ maxHeight: '260px', overflowY: 'auto', scrollbarWidth: 'thin', paddingRight: '4px' }}>
{[...containers]
.sort((a, b) => {
const rank = (c: ContainerInfo) =>
c.upToDate === false ? 0 : c.upToDate === null ? 1 : 2
return rank(a) - rank(b)
})
.map(c => (
{visible.map(c => (
<div key={c.name} className="list-item">
<div className="list-item-left" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
<span>{c.name}</span>
@@ -59,20 +77,17 @@ export function DockerUpdatesWidget() {
{c.image}:{c.tag.startsWith('sha256:') ? c.tag.slice(0, 15) + '…' : c.tag} · {c.endpoint}
</span>
</div>
{c.upToDate === null && !knownRegistries.includes(c.registry) ? (
<span className="pill pill-blue">ext</span>
) : c.upToDate === true ? (
<span className="pill pill-green"></span>
) : c.upToDate === false ? (
<span className="pill pill-red"> update</span>
) : (
<span className="pill" style={{ background: 'var(--surface2)', color: 'var(--muted)' }}>?</span>
)}
{c.upToDate === true
? <span className="pill pill-green"></span>
: <span className="pill pill-red"> update</span>
}
</div>
))}
{unknown > 0 && (
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>
{unknown} non-Docker Hub image{unknown > 1 ? 's' : ''} not checked
{(unverified.length > 0 || external.length > 0) && (
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)', marginTop: 8, lineHeight: 1.6 }}>
{unverified.length > 0 && <div>{unverified.length} image{unverified.length > 1 ? 's' : ''} could not be verified</div>}
{external.length > 0 && <div>{external.length} image{external.length > 1 ? 's' : ''} on unsupported registry</div>}
</div>
)}
</div>
+2
View File
@@ -36,10 +36,12 @@ export const appGroups: AppGroup[] = [
name: 'Media',
apps: [
{ name: 'Jellyfin', url: 'https://jellyfin.syco.me', icon: `${ICONS}/jellyfin.svg` },
{ name: 'Audiobookshelf', url: 'https://audiobooks.syco.me', icon: `${ICONS}/audiobookshelf.svg` },
{ name: 'Navidrome', url: 'https://music.syco.me', icon: `${ICONS}/navidrome.svg` },
{ name: 'Overseerr', url: 'https://overseerr.syco.me', icon: `${ICONS}/overseerr.svg` },
{ name: 'Radarr', url: 'https://radarr.syco.me', icon: `${ICONS}/radarr.svg` },
{ name: 'Sonarr', url: 'https://sonarr.syco.me', icon: `${ICONS}/sonarr.svg` },
{ name: 'Readarr', url: 'https://readarr.syco.me', icon: `${ICONS}/readarr.svg` },
{ name: 'Lidarr', url: 'https://lidarr.syco.me', icon: `${ICONS}/lidarr.svg` },
{ name: 'Prowlarr', url: 'https://prowlarr.syco.me', icon: `${ICONS}/prowlarr.svg` },
{ name: 'Bazarr', url: 'https://bazarr.syco.me', icon: `${ICONS}/bazarr.svg` },
+41 -3
View File
@@ -53,12 +53,17 @@ body::before {
/* ── Header ── */
header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 0 18px;
background: var(--bg);
border-bottom: 1px solid var(--border);
margin-bottom: 28px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.logo { display: flex; align-items: center; gap: 10px; }
.logo-mark {
@@ -244,11 +249,44 @@ header {
}
/* ── Responsive ── */
@media (max-width: 540px) {
@media (max-width: 640px) {
.shell { padding: 0 14px 32px; }
.stat-value { font-size: 18px; }
.clock-time { font-size: 16px; }
.topbar-right { gap: 12px; }
/* Two-row header: [logo · clock] then [nav full-width] */
header {
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 0 10px;
margin-bottom: 16px;
}
/* Logo shrinks */
.logo-sub { display: none; }
.logo-text { font-size: 15px; }
/* Clock: time only, no date */
.clock-time { font-size: 15px; letter-spacing: 0.5px; }
.clock-date { display: none; }
/* Weather hidden — full card already on /home */
.weather-inline { display: none !important; }
/* topbar-right (clock) pushed to far right on row 1 */
.topbar-right { margin-left: auto; gap: 0; }
/* Nav drops to row 2, spans full width */
.nav-tabs {
order: 10;
flex-basis: 100%;
justify-content: stretch;
}
.nav-tab {
flex: 1;
justify-content: center;
text-align: center;
}
}
/* ── Nav tabs ── */