Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Check PWA install contracts
run: node scripts/check-pwa.mjs

- name: Regenerate PWA icons
- name: Verify PWA icon generation
run: node scripts/generate-icons.mjs

- name: Check icon outputs exist
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to FloRo Sign Controller are documented here. Version numbers match `APP_VERSION` in `js/ui.js`. Bump `CACHE_NAME` in `sw.js` when static assets change so installed PWAs pick up updates.

## [2.2.1] - 2026-09-05

### Fixed
- Register the service worker immediately instead of waiting for every font and image to load, and bypass stale CDN caches during worker updates.
- Add an honest manual-install path when Chrome withholds `beforeinstallprompt`, plus installed-app detection and app-drawer instructions for Android.
- Use the searchable Android app name **FloRo Sign** while preserving the same production manifest identity.
- Serve cached navigations immediately so installed launches do not wait on a poor network connection.
- Show Android install guidance without the old 2.5-second layout shift that delayed Largest Contentful Paint.
- Render the default disconnected state in the initial shell and align first-run Dynamic mode with the specified mode 32, avoiding startup layout and text shifts.
- Stop programmatic color restoration from firing a user-change callback that forced first-run and restored Dynamic scenes into Static mode 1.
- Force service-worker precache requests to bypass Chrome's HTTP cache, preventing a new cache version from silently reinstalling stale JavaScript.

### Performance
- Replace the 3.7 MB Material Symbols font with a 6 KB subset containing only the 16 icons used by FloRo.
- Remove render-blocking third-party Google Font requests; Android continues to use its native Roboto font.
- Reuse the 192 px app icon in the install banner instead of downloading and precaching a duplicate 53 KB logo.

## [2.2.0] - 2026-09-05

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,11 +745,11 @@ Do **not** introduce these — they break the premium-remote feel:
Canonical contract: [`specs/pwa.md`](specs/pwa.md). Health Hub is the reference for the install button (native `prompt()` only).
- **Asset paths:** Relative `./` in the page, SW, and manifest. No `<base>` tag. Works at localhost `/` and production `/sign-controller/`.
- **Display:** `standalone` with `display_override: ["standalone"]`
- **Identity:** `id` = `https://rawshn.com/sign-controller/`
- **Identity:** `id` = `./` (resolves to `https://rawshn.com/sign-controller/` in production)
- **Meta:** `mobile-web-app-capable` + `apple-mobile-web-app-capable`; `apple-mobile-web-app-title` = FloRo
- **Icons:** Neon Attack `logo.png`; regenerate via `node scripts/generate-icons.mjs`
- **Service worker:** `./sw.js` on `load`; bump `CACHE_NAME` on asset changes
- **Install prompt:** `#install-banner`; **Install** visible only when `beforeinstallprompt` fired
- **Service worker:** register `./sw.js` immediately with `updateViaCache: "none"`; bump `CACHE_NAME` on asset changes
- **Install prompt:** `#install-banner`; native **Install** only when `beforeinstallprompt` fired, otherwise a clearly labeled help action
- **Required header:** `Permissions-Policy: bluetooth=(self)` plus `Cache-Control: no-cache` on `sw.js` (portfolio `vercel.json`)

## File Map
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ See [`specs/pwa.md`](specs/pwa.md). Short version: **Install** calls the browser

| Platform | Banner | Install button |
|----------|--------|----------------|
| **Android (Chrome/Edge)** | After `beforeinstallprompt`, or a 2.5s fallback with honest copy | Only when the native prompt exists |
| **Android (Chrome/Edge)** | Native prompt when available; otherwise immediate honest guidance | Native **Install**, or clearly labeled **Install help** |
| **iOS (Safari)** | On first visit (Share → Add to Home Screen copy) | Hidden (Apple has no auto-install) |
| **Desktop** | Hidden until `beforeinstallprompt` | Native **Install** when Chrome/Edge offers it |

Dismiss **Not now** stores `floro_install_dismissed_v5` for **7 days**. Settings > **Install App** still works (native prompt, or a help sheet). After install (standalone), the banner hides.
Dismiss **Not now** stores `floro_install_dismissed_v6` for **7 days**. Settings > **Install App** still works (native prompt, or a help sheet). On Android, an installed app appears in the app drawer as **FloRo Sign**; the launcher may not automatically pin it to the Home screen.

## Assets

Expand Down
8 changes: 2 additions & 6 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@
--radius-pill: 999px;
--radius-control: 12px;

--font-display: 'Roboto Flex', system-ui, sans-serif;
--font-mono: 'Roboto Mono', ui-monospace, monospace;
--font-display: 'Roboto', system-ui, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, monospace;

--header-height: 56px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
Expand Down Expand Up @@ -140,10 +140,6 @@ body.sheet-open { overflow: hidden; }
background: var(--bg-deep);
}

.app-shell.install-banner-visible {
padding-bottom: calc(88px + var(--safe-bottom));
}

/* ── Material Symbols (self-hosted for offline PWA) ── */

@font-face {
Expand Down
Binary file modified fonts/MaterialSymbolsOutlined.woff2
Binary file not shown.
24 changes: 12 additions & 12 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>FloRo Sign Controller</title>
<script>
window.addEventListener('load', function () {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('./sw.js').then(
function (reg) { console.log('ServiceWorker registered:', reg.scope); },
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js', { updateViaCache: 'none' }).then(
function (reg) {
console.log('ServiceWorker registered:', reg.scope);
reg.update().catch(function () {});
},
function (err) { console.warn('ServiceWorker registration failed:', err); }
);
});
}
</script>
<meta name="theme-color" content="#020D0A">
<meta name="mobile-web-app-capable" content="yes">
<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="FloRo">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,400;8..144,500;8..144,700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<meta name="apple-mobile-web-app-title" content="FloRo Sign">
<link rel="preload" href="./fonts/MaterialSymbolsOutlined.woff2" as="font" type="font/woff2" crossorigin>
<link rel="icon" type="image/png" sizes="192x192" href="./icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="./icons/icon-512.png">
Expand All @@ -55,7 +54,7 @@
<main class="app-main">
<div id="compat-banner" class="compat-banner hidden"></div>

<div id="connect-prompt" class="connect-banner hidden" role="region" aria-label="Connect to sign">
<div id="connect-prompt" class="connect-banner" role="region" aria-label="Connect to sign">
<div class="connect-banner__text">
<span class="material-symbols-outlined connect-banner__icon" aria-hidden="true">bluetooth_searching</span>
<span><strong>Not connected</strong> Pair your sign</span>
Expand Down Expand Up @@ -244,12 +243,13 @@

<div id="install-banner" class="install-prompt hidden" role="region" aria-label="Install FloRo app" aria-hidden="true">
<div class="install-banner-card">
<img src="./logo.png" alt="" class="install-banner-icon" width="48" height="48">
<img src="./icons/icon-192.png" alt="" class="install-banner-icon" width="48" height="48">
<div class="install-banner-content">
<p id="install-banner-title" class="install-banner-title">Install FloRo</p>
<p id="install-banner-subtitle" class="install-banner-subtitle">Install FloRo so it opens full screen, not as a browser tab.</p>
<div class="install-banner-actions">
<button id="btn-install" class="btn btn-primary install-banner-action install-banner-action-primary hidden" type="button">Install</button>
<button id="btn-install-help" class="btn btn-primary install-banner-action install-banner-action-primary hidden" type="button">Install help</button>
<button id="btn-dismiss-install" class="btn btn-secondary install-banner-action install-banner-action-dismiss" type="button">Not now</button>
</div>
</div>
Expand Down Expand Up @@ -298,7 +298,7 @@ <h3 class="settings-section-title">Activity Log</h3>

<section class="settings-section settings-about">
<p class="about-line">FloRo Sign Controller</p>
<p class="about-version" id="app-version">v2.2.0</p>
<p class="about-version" id="app-version">v2.2.1</p>
</section>

<button id="btn-close-settings" class="btn btn-secondary btn-full" type="button">Done</button>
Expand Down
1 change: 1 addition & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,7 @@ setupPwaInstall({
bannerTitle: document.getElementById('install-banner-title'),
bannerSubtitle: document.getElementById('install-banner-subtitle'),
btnInstall: document.getElementById('btn-install'),
btnInstallHelp: document.getElementById('btn-install-help'),
btnDismiss: document.getElementById('btn-dismiss-install'),
settingsBtn: document.getElementById('btn-settings-install'),
modal: document.getElementById('install-modal'),
Expand Down
2 changes: 1 addition & 1 deletion js/color-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,8 @@ export function createColorPicker({ root, initialHex = '#ff0000', onColorChange,
suppressEvents = true;
setHsl(hexToHsl(n), { commit: false });
suppressEvents = false;
onColorChange?.(currentHex);
if (commit) {
onColorChange?.(currentHex);
pushRecent(currentHex);
recent = loadRecent();
renderRecent();
Expand Down
108 changes: 74 additions & 34 deletions js/pwa-install.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/** Health Hub-style PWA install: native prompt() only when Chromium offers it. */
/** Native PWA install with honest manual and already-installed fallbacks. */

export const DISMISS_KEY = 'floro_install_dismissed_v5';
export const DISMISS_KEY = 'floro_install_dismissed_v6';
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const ANDROID_FALLBACK_DELAY_MS = 2500;

export function isStandalone() {
return (
Expand Down Expand Up @@ -44,12 +43,27 @@ function shouldSuppressBanner() {
return isStandalone() || isDismissed();
}

function installHelpText(hasNativePrompt) {
async function detectInstalledPwa() {
if (isStandalone()) return true;
if (!('getInstalledRelatedApps' in navigator)) return false;

try {
const apps = await navigator.getInstalledRelatedApps();
return apps.some((app) => app.platform === 'webapp');
} catch {
return false;
}
}

function installHelpText(hasNativePrompt, installedPwa) {
if (installedPwa) {
return 'FloRo Sign is installed. Find it in your Android app drawer; Chrome may not add a Home screen icon.';
}
if (isIOS() && !hasNativePrompt) {
return 'Open this page in Safari, tap Share, then Add to Home Screen.';
}
if (isAndroid() && !hasNativePrompt) {
return 'Stay on this page in Chrome. When Chrome offers install, use that. A bookmark keeps the browser bar.';
return 'Chrome has not offered one-tap install yet. You can still install from the Chrome menu.';
}
return 'Install FloRo so it opens full screen, not as a browser tab.';
}
Expand All @@ -59,6 +73,7 @@ export function setupPwaInstall({
bannerTitle,
bannerSubtitle,
btnInstall,
btnInstallHelp,
btnDismiss,
settingsBtn,
modal,
Expand All @@ -70,40 +85,42 @@ export function setupPwaInstall({
log,
}) {
let deferredPrompt = window.__floroDeferredInstall || null;
let androidFallbackTimer = null;
let installedPwa = isStandalone();

function showBanner() {
if (!bannerEl || shouldSuppressBanner()) return;
bannerEl.classList.remove('hidden');
bannerEl.setAttribute('aria-hidden', 'false');
document.querySelector('.app-shell')?.classList.add('install-banner-visible');
syncBanner();
}

function hideBanner() {
bannerEl?.classList.add('hidden');
bannerEl?.setAttribute('aria-hidden', 'true');
document.querySelector('.app-shell')?.classList.remove('install-banner-visible');
}

function syncBanner() {
const hasNativePrompt = Boolean(deferredPrompt);
if (bannerTitle) bannerTitle.textContent = 'Install FloRo';
if (bannerSubtitle) bannerSubtitle.textContent = installHelpText(hasNativePrompt);
if (bannerTitle) {
bannerTitle.textContent = installedPwa ? 'FloRo Sign is installed' : 'Install FloRo Sign';
}
if (bannerSubtitle) {
bannerSubtitle.textContent = installHelpText(hasNativePrompt, installedPwa);
}
if (btnInstall) {
btnInstall.textContent = 'Install';
btnInstall.classList.toggle('hidden', !hasNativePrompt);
btnInstall.classList.toggle('hidden', !hasNativePrompt || installedPwa);
}
if (btnInstallHelp) {
btnInstallHelp.textContent = installedPwa ? 'Find app' : 'Install help';
btnInstallHelp.classList.toggle(
'hidden',
hasNativePrompt || (!installedPwa && !isAndroid() && !isIOS())
);
}
btnDismiss?.classList.remove('hidden');
}

function scheduleAndroidFallback() {
if (shouldSuppressBanner() || !isAndroid() || deferredPrompt) return;
androidFallbackTimer = window.setTimeout(() => {
if (!deferredPrompt && !shouldSuppressBanner()) showBanner();
}, ANDROID_FALLBACK_DELAY_MS);
}

function renderModalSteps(items) {
if (!modalSteps) return;
modalSteps.innerHTML = '';
Expand All @@ -120,6 +137,18 @@ export function setupPwaInstall({
modalSteps?.classList.remove('hidden');
modalAction?.classList.add('hidden');

if (installedPwa) {
modalTitle.textContent = 'Find FloRo Sign';
modalDesc.textContent =
'Android installs the app in your app drawer, but your launcher may not add it to the Home screen.';
renderModalSteps([
'Swipe up from your Home screen to open the app drawer',
'Search for FloRo Sign (older installs may appear as FloRo)',
'Long-press the app icon and drag it to your Home screen',
]);
return;
}

if (isIOS()) {
modalTitle.textContent = 'Add to Home Screen';
modalDesc.textContent = 'iPhone and iPad cannot auto-install. In Safari:';
Expand All @@ -131,13 +160,14 @@ export function setupPwaInstall({
return;
}

modalTitle.textContent = 'Install FloRo';
modalTitle.textContent = 'Install FloRo Sign';
modalDesc.textContent =
'Chrome and Edge only show a one-tap install when this page qualifies as an app. Until then:';
'Chrome can delay or temporarily suppress its one-tap prompt. Manual installation is always available:';
renderModalSteps([
'Open https://rawshn.com/sign-controller/ in Chrome or Edge (not a WebView or in-app browser)',
'Wait until the address bar or browser menu offers Install / Install app',
'If you only see Add to Home screen, that is a bookmark, not the standalone app. Reload and try again after the service worker is active',
'Open this page directly in Chrome or Edge, not an in-app browser',
'Tap the three-dot Chrome menu',
'Tap Add to Home screen (or Install and create shortcut), then choose Install app',
'After installation, swipe up and search your app drawer for FloRo Sign',
]);
}

Expand All @@ -147,31 +177,32 @@ export function setupPwaInstall({

async function triggerNativeInstall() {
if (!deferredPrompt) return false;
let accepted = false;
try {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
log?.('User installed FloRo Controller app.', 'success');
accepted = true;
installedPwa = true;
log?.('User installed FloRo Sign.', 'success');
}
} catch (err) {
log?.(`Install prompt failed: ${err?.message || err}`, 'error');
return false;
}
hideBanner();
deferredPrompt = null;
window.__floroDeferredInstall = null;
closeHelpSheet();
if (accepted) showBanner();
else hideBanner();
return true;
}

function capturePrompt(event) {
event.preventDefault();
deferredPrompt = event;
installedPwa = false;
window.__floroDeferredInstall = event;
if (androidFallbackTimer) {
window.clearTimeout(androidFallbackTimer);
androidFallbackTimer = null;
}
showBanner();
}

Expand All @@ -181,25 +212,26 @@ export function setupPwaInstall({
hideBanner();
} else if (deferredPrompt) {
showBanner();
} else if (isIOS()) {
} else if (isIOS() || isAndroid()) {
showBanner();
} else {
hideBanner();
scheduleAndroidFallback();
}

window.addEventListener('appinstalled', () => {
log?.('FloRo Controller installed.', 'success');
hideBanner();
installedPwa = true;
log?.('FloRo Sign installed.', 'success');
deferredPrompt = null;
window.__floroDeferredInstall = null;
settingsBtn?.closest('.settings-section')?.classList.add('hidden');
showBanner();
});

btnInstall?.addEventListener('click', async () => {
await triggerNativeInstall();
});

btnInstallHelp?.addEventListener('click', openHelpSheet);

btnDismiss?.addEventListener('click', () => {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
Expand Down Expand Up @@ -227,4 +259,12 @@ export function setupPwaInstall({
if (settingsBtn && isStandalone()) {
settingsBtn.closest('.settings-section')?.classList.add('hidden');
}

detectInstalledPwa().then((installed) => {
if (!installed || isStandalone()) return;
installedPwa = true;
deferredPrompt = null;
window.__floroDeferredInstall = null;
showBanner();
});
}
Loading
Loading