Add PWA support and mobile responsive layout

- PWA manifest, service worker (stale-while-revalidate for static assets,
  network-only for API), and app icons for installability
- Root-scoped /manifest.json and /sw.js routes in FastAPI
- New mobile.css with responsive breakpoints at 768/600/400px:
  fixed bottom tab bar on phones, single-column cards, full-screen modals,
  compact header toolbar, touch-friendly targets
- Fix modal-content-wide min-width overflow on small screens
- Update README with Camera, OpenRGB, and PWA features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:20:21 +03:00
parent 8fe9c6489b
commit 9ee6dcf94a
11 changed files with 715 additions and 10 deletions

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
@@ -248,6 +248,26 @@ app.add_middleware(
# Include API routes
app.include_router(router)
# PWA: serve manifest and service worker from root scope
_static_root = Path(__file__).parent / "static"
@app.get("/manifest.json", include_in_schema=False)
async def pwa_manifest():
"""Serve PWA manifest from root scope."""
return FileResponse(_static_root / "manifest.json", media_type="application/manifest+json")
@app.get("/sw.js", include_in_schema=False)
async def pwa_service_worker():
"""Serve service worker from root scope (controls all pages)."""
return FileResponse(
_static_root / "sw.js",
media_type="application/javascript",
headers={"Cache-Control": "no-cache"},
)
# Mount static files
static_path = Path(__file__).parent / "static"
if static_path.exists():

View File

@@ -0,0 +1,550 @@
/* ── Mobile & Tablet Responsive Overrides ──────────────────────
Loaded last — overrides desktop-first styles from other CSS files.
Breakpoints: 768px (tablets), 600px (phones), 400px (small phones)
─────────────────────────────────────────────────────────────── */
/* ================================================================
TABLET (≤ 768px)
================================================================ */
@media (max-width: 768px) {
/* Header — keep single row, scroll toolbar if needed */
header {
flex-direction: column;
gap: 4px;
padding: 4px 0 6px;
text-align: center;
}
.header-toolbar {
justify-content: center;
}
/* Container */
.container {
padding: 10px;
}
/* Cards grid — allow narrower cards on tablets */
.displays-grid,
.devices-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
/* Modals — near full width */
.modal-content {
width: 95%;
max-width: none;
margin: 10px;
}
.modal-content-wide {
min-width: 0;
width: 95%;
max-width: none;
}
/* Modal padding reduction */
.modal-header {
padding: 16px 16px 12px;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 12px 16px 16px;
}
/* Section headings */
h2 {
font-size: 1.25rem;
margin-bottom: 14px;
}
/* Segment range fields — allow wrapping */
.segment-range-fields {
flex-wrap: wrap;
}
.segment-range-fields input[type="number"] {
width: 60px;
}
/* Composite layer editor */
.composite-layer-blend {
width: 80px;
}
/* Display picker */
.display-picker-content {
width: 95%;
}
}
/* ================================================================
PHONE (≤ 600px)
================================================================ */
@media (max-width: 600px) {
/* Prevent horizontal scroll */
html, body {
overflow-x: hidden;
}
/* ── Header ── */
header {
padding: 4px 0 6px;
}
.header-title {
gap: 6px;
}
.header-toolbar {
gap: 1px;
padding: 2px 3px;
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.header-toolbar::-webkit-scrollbar {
display: none;
}
.header-toolbar-sep {
display: none;
}
.header-link {
display: none;
}
.header-btn {
min-width: 32px;
min-height: 32px;
padding: 4px 6px;
flex-shrink: 0;
}
.header-locale {
flex-shrink: 0;
width: auto;
max-width: 48px;
}
h1 {
font-size: 1.1rem;
}
#server-version {
display: none;
}
/* ── Bottom Tab Bar ── */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
background: var(--card-bg);
border-bottom: none;
border-top: 1px solid var(--border-color);
margin-bottom: 0;
display: flex;
flex-wrap: nowrap;
justify-content: space-around;
padding: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-shadow: 0 -2px 8px var(--shadow-color);
gap: 0;
}
.tab-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px 6px;
font-size: 0.65rem;
border-bottom: none;
border-top: 2px solid transparent;
margin-bottom: 0;
position: relative;
}
.tab-btn.active {
border-bottom-color: transparent;
border-top-color: var(--primary-color);
}
.tab-btn .icon {
width: 20px;
height: 20px;
display: block;
}
.tab-btn > span[data-i18n] {
font-size: 0.6rem;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Tab badge repositioned to top-right of icon */
.tab-badge {
position: absolute;
top: 2px;
right: calc(50% - 18px);
font-size: 0.55rem;
padding: 0 4px;
min-width: 14px;
line-height: 1.2;
margin-left: 0;
}
/* Body padding for fixed bottom bar */
body {
padding-bottom: 64px;
}
/* ── Container ── */
.container {
padding: 8px;
}
/* ── Cards — single column ── */
.displays-grid,
.devices-grid,
.templates-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.card {
padding: 10px 14px 14px;
}
.card-title {
font-size: 1.05rem;
}
.card-header {
padding-right: 24px;
}
.add-device-card {
min-height: 100px;
}
.add-device-icon {
font-size: 2rem;
}
/* ── Modals — full screen ── */
.modal-content {
width: 100%;
max-width: 100%;
max-height: 100%;
border-radius: 0;
margin: 0;
}
.modal-content-wide {
min-width: 0;
width: 100%;
max-width: 100%;
border-radius: 0;
}
.modal-header {
padding: 12px 14px 10px;
}
.modal-header h2 {
font-size: 1.15rem;
}
.modal-body {
padding: 14px;
}
.modal-footer {
padding: 10px 14px 14px;
}
/* Inline fields stack vertically */
.inline-fields {
flex-direction: column;
gap: 8px;
}
/* Segment rows — stack vertically */
.segment-row-fields {
flex-direction: column;
align-items: stretch;
}
.segment-range-fields {
flex-wrap: wrap;
}
.segment-range-fields input[type="number"] {
width: 100%;
flex: 1;
}
/* Buttons */
.btn {
min-width: 0;
}
.modal-footer .btn-icon {
min-width: 50px;
padding: 8px 16px;
}
/* Form groups */
.form-group {
margin-bottom: 12px;
}
/* Gradient stop rows — tighter */
.gradient-stop-row {
gap: 4px;
padding: 4px 6px;
}
.gradient-stop-pos {
width: 60px;
max-width: 60px;
}
/* Composite layers */
.composite-layer-row {
flex-wrap: wrap;
}
.composite-layer-blend {
width: 100%;
}
/* Metrics grid — single column */
.metrics-grid {
grid-template-columns: 1fr;
}
/* Timing legend */
.timing-legend {
gap: 4px;
font-size: 0.7rem;
}
/* Audio test stats */
.audio-test-stats,
.vs-test-stats {
flex-wrap: wrap;
gap: 10px;
}
/* Section */
section {
margin-bottom: 24px;
}
h2 {
font-size: 1.15rem;
margin-bottom: 10px;
}
/* Section tip */
.section-tip {
font-size: 0.78rem;
padding: 6px 10px;
}
/* Card subtitle gap */
.card-subtitle {
gap: 8px;
margin-bottom: 10px;
}
/* Footer */
.app-footer {
margin-bottom: 50px;
}
/* Command palette */
#command-palette {
padding-top: 5vh;
}
.cp-dialog {
width: 95vw;
}
/* Stream sub-tabs */
.stream-tab-bar {
flex-wrap: wrap;
gap: 2px;
margin-bottom: 10px;
}
.stream-tab-btn {
padding: 6px 8px;
font-size: 0.8rem;
}
.stream-tab-count {
font-size: 0.6rem;
padding: 0 4px;
}
.cs-expand-collapse-group {
gap: 1px;
}
.btn-expand-collapse {
width: 26px;
height: 26px;
font-size: 0.75rem;
}
/* Display picker */
.display-picker-content {
width: 98%;
}
.display-picker-canvas {
padding: 12px;
}
.display-picker-title {
font-size: 1.1rem;
margin-bottom: 12px;
}
/* Lightbox */
.lightbox-stats {
font-size: 0.7rem;
gap: 10px;
padding: 6px 10px;
}
}
/* ================================================================
SMALL PHONE (≤ 400px)
================================================================ */
@media (max-width: 400px) {
/* Tighter header */
h1 {
font-size: 1rem;
}
/* Cards */
.card {
padding: 8px 10px 12px;
}
.card-title {
font-size: 0.95rem;
}
.card-top-actions {
gap: 1px;
}
.card-remove-btn,
.card-power-btn,
.card-autostart-btn {
width: 32px;
height: 32px;
}
/* Tab buttons even tighter */
.tab-btn {
padding: 6px 2px 4px;
}
.tab-btn > span[data-i18n] {
font-size: 0.55rem;
}
/* Modal body */
.modal-body {
padding: 10px;
}
.modal-header {
padding: 10px 12px 8px;
}
}
/* ================================================================
TOUCH DEVICE ENHANCEMENTS
================================================================ */
@media (hover: none) and (pointer: coarse) {
/* Larger touch targets */
.card-remove-btn,
.card-power-btn,
.card-autostart-btn {
width: 34px;
height: 34px;
}
.modal-close-btn {
width: 38px;
height: 38px;
}
.hint-toggle {
width: 24px;
height: 24px;
}
/* Always show drag handles on touch */
.card > .card-drag-handle,
.template-card > .card-drag-handle {
opacity: 0.4;
}
/* Disable hover transform on cards (causes janky scrolling) */
.card:hover {
transform: none;
}
.add-device-card:hover {
transform: none;
}
/* Color picker dots — larger for fingers */
.color-picker-dot {
width: 38px;
height: 38px;
}
}
/* ================================================================
STANDALONE PWA MODE
================================================================ */
@media (display-mode: standalone) {
/* In standalone/PWA mode the browser chrome is gone,
so we can use full viewport safely */
header {
padding-top: env(safe-area-inset-top, 4px);
}
}

View File

@@ -545,13 +545,18 @@
.modal-content-wide {
width: fit-content;
min-width: 500px;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
}
@media (min-width: 769px) {
.modal-content-wide {
min-width: 500px;
}
}
.modal-content-wide .modal-body {
overflow-y: auto;
scrollbar-gutter: stable;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,28 @@
{
"name": "LED Grab",
"short_name": "LED Grab",
"description": "WLED ambient lighting controller based on screen content",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#4CAF50",
"orientation": "any",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/static/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -0,0 +1,90 @@
/**
* Service Worker for LED Grab PWA.
*
* Strategy:
* - Static assets (/static/): stale-while-revalidate
* - API / config requests: network-only (device control must be live)
* - Navigation: network-first with offline fallback
*/
const CACHE_NAME = 'ledgrab-v1';
const PRECACHE_URLS = [
'/',
'/static/css/base.css',
'/static/css/layout.css',
'/static/css/components.css',
'/static/css/cards.css',
'/static/css/modal.css',
'/static/css/calibration.css',
'/static/css/dashboard.css',
'/static/css/streams.css',
'/static/css/patterns.css',
'/static/css/automations.css',
'/static/css/tutorials.css',
'/static/css/mobile.css',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png',
];
// Install: pre-cache core shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
// Fetch handler
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API and config: always network (device control must be live)
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/config/')) {
return; // fall through to default network fetch
}
// Static assets: stale-while-revalidate
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) =>
cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
if (response.ok) {
cache.put(event.request, response.clone());
}
return response;
}).catch(() => cached);
return cached || fetchPromise;
})
)
);
return;
}
// Navigation: network-first
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match('/') || new Response('Offline', {
status: 503,
headers: { 'Content-Type': 'text/plain' },
})
)
);
return;
}
});

View File

@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Grab</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
<!-- PWA -->
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="LED Grab">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/layout.css">
<link rel="stylesheet" href="/static/css/components.css">
@@ -16,6 +23,7 @@
<link rel="stylesheet" href="/static/css/patterns.css">
<link rel="stylesheet" href="/static/css/automations.css">
<link rel="stylesheet" href="/static/css/tutorials.css">
<link rel="stylesheet" href="/static/css/mobile.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head>
<body style="visibility: hidden;">
@@ -409,5 +417,6 @@
startAutoRefresh();
}
</script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</body>
</html>