- What this is: A mobile-first PWA for controlling FloRo neon signs over Web Bluetooth. Users connect once, then adjust brightness, color, animation modes, and power as if holding a dedicated hardware remote.
- Who it's for: FloRo sign owners on Chrome/Edge (desktop or Android) who want fast, tactile control without a generic developer console feel.
- Space/industry: Smart lighting / IoT controller apps (peers: Philips Hue, Govee Home, Apple Home).
- Project type: Single-screen utility PWA with installable home-screen behavior.
Status: This section supersedes Mint Remote visual direction below for the next implementation pass. BLE behavior, scene persistence, sheets, and PWA contracts are unchanged unless noted here.
Calm control, fast thumbs. Material 3 surfaces, no glow or arc hero. Users flip colors and modes often : one-tap access to recents on the main surface; full palette in a sheet.
The current color is always obvious : one large squircle shows what the sign is showing; recents are one tap away.
- Direction: Material 3 minimal, compact (not airy). Icons carry meaning so rows stay short.
- Decoration: None on canvas : tonal
surface-containercards only - Accent: Restrained teal primary
#4DB6ACon controls. No global UI retint to selected neon; color feedback is on squircles only - Typography: Roboto Flex UI, Roboto Mono activity log
- Icons: Material Symbols Outlined 24dp default, 20dp inline in dense rows
Previous mock had oversized cards and label-heavy blocks. Target Google Home / Pixel Settings density: 48dp list rows, 12px card padding, 8px vertical gaps.
| Token | Value | Use |
|---|---|---|
--space-section |
8px | Gap between cards / strip |
--space-card |
12px | Card internal padding |
--row-height |
48px | Icon + label + control rows |
--strip-padding |
12px 16px | Global color strip |
--squircle-hero |
48px | Hero (was 56 : tighter) |
--squircle-quick |
40px | Recents in strip |
Layout shift: Replace tall "readout card + separate fine-tune card" with icon-led control rows (one row per parameter). Segmented adjust context stays, but the active parameter row highlights; inactive rows stay visible at 48dp with icon, name, value, ±.
Icons reduce text, fill horizontal space intentionally, and survive localization. Load once in index.html:
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet">| Surface | Icon | Symbol name |
|---|---|---|
| Settings | ⋮ | more_vert |
| Connect banner | Bluetooth | bluetooth_searching |
| Static tab | Solid | palette |
| Dynamic tab | Animation | animation |
| Color sheet / hero | Color | palette |
| Custom picker | Tune | tune |
| More colors | Add | add |
| Brightness | Sun | brightness_6 |
| Speed | Motion | speed |
| Mode | Effects | auto_awesome |
| Browse modes | Search | search |
| Save favorite | Bookmark | bookmark_add |
| Save color preset | Label | label |
| Power on | Power | power_settings_new |
| Decrease / increase | − / + | remove / add (icon buttons, not text) |
Rules:
- Leading icon in every control row (24dp,
on-surface-variant) - Tab segments may show icon + short label (
Static,Dynamic) or icon-only under 360px width - Do not add decorative icons : every icon maps to a control
- Icon buttons: 40×40dp touch target,
state-layeron press
Users must tell neon, saved custom, and unsaved custom apart at a glance. Color alone is not enough when two customs are similar hues.
- Saved preset (
floro_color_presets) → userlabel - Neon catalog (fixed 8 hex → name map in
js/colors.js) → e.g.Ruby Red - Fallback → uppercase hex
#FF00FF
| Type | Class | Visual | Label under swatch |
|---|---|---|---|
| Neon | .swatch--neon |
Solid fill only | Curated name (max 8 chars) |
| Saved custom | .swatch--saved |
Fill + solid 1.5px outline-variant ring |
User preset name |
| Unsaved custom | .swatch--custom |
Fill + dashed 1.5px outline-variant ring + small tune icon badge (12dp, bottom-right) |
Hex or "Custom" |
| Selected (any) | .is-selected |
2px on-surface ring + gap |
: |
Hero row shows: [48px squircle] [name] [type subtitle] : subtitle is Neon | Saved | #AABBCC for unsaved custom.
Each recent is a column: squircle + 8-character label underneath (not floating whitespace).
[hero] [○] [○] [○] [+]
Ruby Red Violet #E91E63 Green More
neon saved custom neon
Tap target includes label (whole column). Type styling follows rules above.
| Step | User action | UI result |
|---|---|---|
| 1 | Drag custom picker | Live preview; hero shows hex + swatch--custom dashed ring; subtitle "Custom" |
| 2 | Pause / commit | Added to recents with hex label until named |
| 3 | Tap Save preset (label icon) | Modal: name required (max 32 chars), default suggestion from hex |
| 4 | Confirm save | floro_color_presets entry; swatch becomes swatch--saved; label replaces hex in strip, recents, sheet |
| 5 | Re-pick same hex later | resolveColorLabel returns saved name automatically |
Rename / delete: long-press saved swatch in sheet → menu: Rename, Delete. Rename updates all UI surfaces showing that hex.
Neon never shows dashed ring : catalog colors are first-class, not "custom."
Top app bar: FloRo · Settings (more_vert)
Connect banner (bluetooth_searching + Connect) : compact 40dp
[ palette Static | animation Dynamic ] ← Dynamic default
┌ Global color strip ──────────────────────┐
│ palette [hero] Ruby Red · Neon │
│ [○][○][○][○][+] label under each │
└────────────────────────────────────────┘
── Static ──
brightness_6 Brightness 8 − +
Power (power_settings_new, pinned)
── Dynamic ──
[ Mode | Brightness | Speed ] ← compact segmented
auto_awesome Mode 32 Counter − + ← active row highlighted
brightness_6 Bright 8 − +
speed Speed 50% − +
search Browse modes >
bookmark Favorites ····· scroll
Power (pinned)
No arc gauge, no quick-action chip row, no vertical mode rail, no 4×2 neon grid on main.
| Situation | Tab shown | Adjust context | Mode / color |
|---|---|---|---|
First install (no floro_scene_state) |
Dynamic | Mode | lastAnimationMode 32, color #ff0000 |
| Return visit | Restored displayView from localStorage |
Mode if Dynamic | Restored scene snapshot |
| After connect / power-on | Dynamic (restore) | Mode | alignUIForPowerOnRestore() : unchanged |
| User picks solid color | Switches to Static | n/a | activeMode → 1 |
Code: js/state.js → SCENE_DEFAULTS.displayView: 'animation'. HTML default tab: Dynamic selected, #anim-controls visible.
Sits below Static/Dynamic tabs, above tab panel content. Visible on both tabs so frequent color flippers on Dynamic do not switch tabs for recents.
┌──────────────────────────────────────────┐
│ palette [hero] Ruby Red · Neon │
│ Ruby Violet #E91E Green More │ ← 8 char labels, typed swatches
│ [○] [○] [○] [○] [+] │
└──────────────────────────────────────────┘
| Target | Tap behavior |
|---|---|
| Hero squircle or name row | Open Color sheet |
| Recent squircle (1–4) | Apply color immediately (see flows below) |
+ squircle |
Open Color sheet scrolled to Neon / Custom |
Static tab has brightness only in its panel. Dynamic has no color controls in its panel; strip handles all color.
- Load
floro_scene_state+floro_recent_colors - Render global color strip from
activeColor+ top 4 recents - If no saved state → Dynamic tab, Mode context, mode 32
- If saved state → restore tab, brightness, speed, color, mode
tryAutoReconnect()if supported (unchanged)- Connect banner if offline
Applies to strip on Static or Dynamic tab.
- Set
activeColorto recent hex resolveColorLabel(hex)→ update hero name + subtitle (Neon / Saved / hex)- Apply correct swatch class (
neon|saved|custom) on hero + tapped recent - Ring selected column in strip
- If sign was in animation (
activeMode !== 1):prepareSolidModeForColorPick()→ UI Static tab, mode 1 local, flag mode send - Debounced BLE:
C=…thenM1if needed (existingsendColorToSignLive) pushRecent(hex): move to front, dedupe- Re-render strip (max 4 recents + hero)
persistScene(); light haptic
Not connected: steps 1–3 + 7 still run; BLE steps no-op; activity log on failed write if user connects later scene restores.
- User opened sheet via hero,
+, or (future) overflow - Tap neon squircle
- Same as recent tap (steps 1–8 above)
- Close sheet
- If user was on Dynamic before sheet → ends on Static tab (solid mode)
- Tap saved preset squircle
- Same apply pipeline as neon (steps 2–8 in recent flow)
- Close sheet
- Preset does not duplicate into recents unless
pushRecentruns on commit (it should : same as any pick)
Live adjust (drag 2D plane, hue strip, sliders, hex field):
| Phase | UI | BLE | Tab |
|---|---|---|---|
| Pointer down / drag | Hero preview in sheet updates; optional live preview on strip hero after 150ms idle | sendColorToSignLive debounced 120ms |
If activeMode !== 1: switch to Static immediately (prepareSolidModeForColorPick) |
Pause 150ms (scheduleCommit) |
pushRecent → onColorCommit |
Immediate color + mode 1 if needed | Static tab |
| Tap Done | Close sheet only; color already committed on last pause | : | Stay on Static if color forced solid |
After custom commit:
activeColor= picked hex- Hero + strip update; custom hex in recents (front)
applyColorSelection(): if already mode 1,syncColorToSign()only- No auto-open Save preset modal; user taps Save preset in sheet if they want a named slot
- Light haptic on commit
Edge cases:
- Drag then dismiss sheet without pause: last live value stands if debounce already sent; if user swipes sheet closed mid-drag, treat close as commit (flush
scheduleCommiton sheet close) - Invalid hex: field shakes, revert to
currentHex; no BLE send - Same as last color: still refresh recents order (move to front)
- Power off: picker works for UI state; BLE blocked until power on
- White (#FFFFFF): squircle gets
outline-variantborder for visibility
- User has custom (or any) color active in sheet
- Tap Save preset →
#color-preset-modal - Enter name → confirm
- Append to
floro_color_presets(cap enforced : evict oldest with log) - Re-render Your colors in sheet; do not add second row on main
- Modal closes; sheet stays open; user taps Done when finished
| From → To | UI | BLE |
|---|---|---|
| Dynamic → Static | Show brightness card; strip unchanged | If activeMode !== 1: setMode(1), send solid |
| Static → Dynamic | Show adjust context + readout | If was mode 1: setMode(lastAnimationMode) |
Color strip never hides on tab switch.
| Context | ± action | Wrap | BLE |
|---|---|---|---|
| Mode | ±1 mode | 2↔200, skip mode 1 | M{n} |
| Brightness | ±1 step | 1↔8 | B=n |
| Speed | ±5% (or 10%) | 0↔100 | S=… inverted wire |
Favorite chip tap → set context Mode, apply mode, highlight chip.
Browse all → mode picker sheet → on select, close sheet, update readout.
- Connect + power on:
restoreSceneToSign()→ Dynamic tab, saved animation mode, strip shows saved color (sign may animate; color state preserved for when user goes solid) - Power off: strip visible but dimmed; taps update UI only or no-op BLE
- Power on:
setPowerState(true)→alignUIForPowerOnRestore()→ send full scene
Power on → animation 32 (typical first-run behavior):
- User taps Power On while connected
alignUIForPowerOnRestore()setsdisplayView = 'animation',activeMode=lastAnimationMode(default 32 if never changed)- UI lands on Dynamic tab; readout shows 32 · Counter Spin (or saved mode name)
syncSceneToSign()sends brightness, speed, color, thenM32(or saved mode) last- Physical sign boots into that animation : matches firmware tendency to show last mode on power-up
If the user had been on mode 47 before power off, restore sends 47, not 32. Mode 32 is the default from SCENE_DEFAULTS.lastAnimationMode and seed favorites, not a hard override every power cycle.
Connect without power toggle: same restore path when isPoweredOn is true on connect (onConnected → restoreSceneToSign()).
- Horizontal Tactile Brightness Card (
.brightness-card--horizontal): Full-width M3 card at the top of the#solid-controlslayout (flex-direction: column). Features a horizontal slider (1to8), large step (−and+) buttons (48×48dptouch targets), and tabular numerical readout (1to8). Replaces legacy vertical side-by-side-90degrotated slider layout for superior touch-drag ergonomics on mobile browsers. - Pinned Bottom Action Dock (
.bottom-action-dock): The#power-btn(.power-cta) is permanently extracted outside of scrolling.remote-dockpanels into a dedicated fixed/sticky bottom footer inside.app-shell(z-index: 60, accounting forsafe-area-inset-bottom). Whether browsing Static colors or scrolling through 200 animation modes, the Power CTA remains 100% thumb-reachable without scrolling.
Sections top to bottom:
- Neon : 8 squircles, horizontal scroll
- Your colors : saved presets; squircle + delete
- Recent : full list (up to 8) from
floro_recent_colors - Custom : HSL picker (
js/color-picker.js) - Save preset : opens
#color-preset-modal
Sheet title: Color. On squircle pick: apply, close sheet, update global strip. On sheet close mid-custom-drag: flush pending commit.
All color touch targets use squircles (superellipse). One shape token for neon, custom, saved, and recent colors.
| Token | Value | Use |
|---|---|---|
--shape-squircle |
28% border-radius on 1:1 box |
CSS squircle approximation |
--squircle-hero |
48×48px | Current color in global strip |
--squircle-quick |
40×40px | Recents in global strip |
--squircle-grid |
44×44px | Neon + saved grids inside sheet |
--swatch-label-size |
10px / 500 | Caption under recent columns |
Selected state: 2px on-surface ring + 2px gap (outline), no glow shadow.
.swatch-squircle {
width: var(--squircle-size, 44px);
height: var(--squircle-size, 44px);
border-radius: var(--shape-squircle, 28%);
border: 2px solid transparent;
padding: 0;
}
.swatch-squircle.is-selected {
border-color: var(--md-sys-color-on-surface);
box-shadow: 0 0 0 2px var(--md-sys-color-surface-container);
}
.swatch-squircle.swatch--saved {
border: 1.5px solid var(--md-sys-color-outline-variant);
}
.swatch-squircle.swatch--custom {
border: 1.5px dashed var(--md-sys-color-outline-variant);
}
.swatch-squircle.swatch--custom::after {
/* 12dp tune badge, bottom-right : Material symbol or SVG */
}.control-row {
display: flex;
align-items: center;
min-height: 48px;
gap: 12px;
padding: 0 12px;
}
.control-row .material-symbols-outlined {
font-size: 24px;
color: var(--md-sys-color-on-surface-variant);
}| Token | Hex | Use |
|---|---|---|
--md-sys-color-primary |
#4DB6AC |
Filled buttons, slider active track |
--md-sys-color-surface |
#101414 |
Page background |
--md-sys-color-surface-container |
#1B211F |
Cards |
--md-sys-color-surface-container-high |
#252B29 |
Readout card, recents tray |
--md-sys-color-on-surface |
#E0E3E2 |
Primary text |
--md-sys-color-on-surface-variant |
#BEC9C6 |
Labels |
--md-sys-color-outline-variant |
#3F4947 |
Dividers |
All v2.1.0 BLE behavior survives: connect/reconnect, power gating, Static/Dynamic logic, brightness/speed/mode, 200-mode picker, animation favorites, color presets, HSL picker, scene persistence, activity log, PWA install, wake lock, haptics. Removed: global updateNeonThemeColor() UI retint (squircle selection only).
| File | Change |
|---|---|
index.html |
Global #color-strip; icon-led 48dp rows; Material Symbols font link |
js/colors.js |
New: neon hex→name map + resolveColorLabel(hex, presets) |
js/state.js |
SCENE_DEFAULTS.displayView: 'animation' |
css/styles.css |
Compact spacing; swatch type variants; .control-row |
js/app.js |
Strip columns with labels; swatch class from color type; rename preset |
js/ui.js |
Squircle ring on selected color only (no global retint) |
#palette-sheet |
Neon / Your colors / Recent / Custom; squatch + label grid |
Premium hardware remote energy : not a BLE debug panel.
The app should feel like a polished physical remote: compact, confident, mode-driven, with infrastructure (connection, logs, install) tucked away until needed. Every pixel on the main surface earns its place by controlling the sign.
The UI glows the color on the sign. Default chrome accent is mint (#41E9BD, reference palette). When the user picks a solid neon color, updateNeonThemeColor() retints --accent, arc stroke, slider fills, swatch rings, and control-card glows to match. The remote feels physically linked to the light it controls.
Mint Remote aesthetic from attached AC remote mockups: Sofia Sans, deep black-green canvas, charcoal cards, circular arc hero gauge, vertical mode rail, full-width mint power CTA. Implementation plan: UI_OVERHAUL_PLAN.md.
Hero + dock layout (Mint Remote). Arc gauge and mode rail are supplementary readout in .app-main; control cards live inside #remote-dock (primary thumb zone). Power CTA is the same #power-btn styled as a mint pill at the dock bottom. Infrastructure stays in sheets.
┌─────────────────────────────────────┐
│ Header: FloRo · status · menu │ ~52px, radial glow behind
├─────────────────────────────────────┤
│ HERO ZONE (readout, compacts ≤740) │
│ [Solid|Anim rail] ( ARC GAUGE ) │
│ [ quick action chips x4 ] │
├─────────────────────────────────────┤
│ CONNECT STRIP (offline, dock-adj) │
├─────────────────────────────────────┤
│ #remote-dock (PRIMARY thumb zone) │
│ [Solid|Anim] [power mirror] toolbar│
│ Brightness · Color/Mode cards │
│ preset row (h-scroll or pills) │
│ [ mint Power On/Off #power-btn ] │
└─────────────────────────────────────┘
(fixed overlay, outside `.app-shell`, z-index 70)
Install banner (#install-banner)
settings / sheets → z-index 100
Full markup and phase breakdown: UI_OVERHAUL_PLAN.md.
Visual overhaul only. All v2.1.0 behavior must survive unchanged: BLE connect/reconnect/auto-reconnect/disconnect, power gating, Solid/Animation mode logic, brightness/speed/color/mode controls, 200-mode picker with search, animation favorites and color presets, HSL custom picker with recents, scene persistence, activity log, PWA install flows, wake lock, haptics, and dynamic accent from selected neon color.
Authoritative checklist: UI_OVERHAUL_PLAN.md → Feature Parity Matrix + Behavioral invariants + Existing UX Review. Devex review and QA sign off against those sections.
Additional non-negotiables from shipped UX review:
- Install banner: Fixed bottom overlay (
#install-banner.install-prompt), outside.app-shell. Do not regress z-index or 7-day dismiss TTL. - Thumb-zone dock:
#remote-dockremains the primary control surface on phones; hero gauge is supplementary readout. - Preset layouts: Animation favorites = horizontal scroll row; color presets = flex-wrap pill row.
- Custom color: Bottom sheet (
#palette-sheet) only; no inline palette on the main surface. - Power-on restore:
alignUIForPowerOnRestore()Animation tab behavior is non-negotiable.
| Element | Role |
|---|---|
| Neon Attack logo + "FloRo" | Compact brand mark (keep in mint layout) |
| Status chip | Connection state; offline copy "Tap to connect"; tap opens settings |
| Menu button (vertical dots) | #btn-menu; opens settings sheet |
| Element | Role |
|---|---|
| Segmented control | Solid | Animation |
| Power button | IEC power symbol; global on/off |
| Brightness slider | Always visible in dock |
| Solid panel | 4×2 color grid filling thumb zone, color preset chips, custom color + save actions |
| Animation panel | Speed slider, mode picker row (+/−), horizontal quick presets |
Mode list and search live in #mode-picker-sheet (bottom sheet), not on the main scroll surface.
Preview swatch in upper area (hidden below 740px height). All controls in remote dock.
Mode hero is a tappable row opening the mode picker sheet. Quick presets scroll horizontally in the dock.
Infrastructure only: connection, activity log, install, about.
| Region | CSS / ID | Behavior |
|---|---|---|
| Header | .app-header |
Sticky top: logo + FloRo, status chip, vertical-dots menu. 52px min height + safe-area top; radial glow behind. |
| Hero zone | .hero-zone |
Mode rail (left) + arc gauge (center) + quick actions. Readout only; compacts on short viewports, never removed. |
| Connect strip | #connect-prompt |
Dock-adjacent offline CTA; hidden when connected/connecting/unsupported. |
| Remote dock | #remote-dock |
Primary thumb zone: sticky toolbar (Solid | Animation + power mirror), scrollable control cards inside. |
| Power CTA | #power-btn.power-cta |
Single #power-btn node; mint full-width pill at dock bottom (not a cloned button). |
| Install banner | #install-banner.install-prompt |
Fixed overlay outside .app-shell, z-index: 70. Never flex child of shell. 7-day dismiss TTL. |
| Sheets | .sheet-overlay + .modal-sheet |
Bottom sheets; z-index: 100. One overlay at a time. |
Main content (.app-main) is max-width 500px, centered. #remote-dock sits below connect strip; cards scroll inside dock while toolbar stays thumb-reachable.
- Direction: Mint Remote : dark smart-home remote with neon mint accent and arc gauge hero (reference: attached AC remote mockups)
- Decoration level: Intentional : radial teal glow at top of canvas, mint outer glow on active controls and arc stroke
- Mood: Premium night-mode remote. Matte charcoal surfaces, one luminous mint thread (retinted to sign color when picking neon).
- Reference patterns:
- Attached mockups: Arc gauge, vertical mode rail, 24px card radius, bottom power pill
- Philips Hue / Govee: Mode-first IA, preset grids, brightness always visible
- Prior FloRo IA: Settings in sheets, no BLE debug on main surface
- Family: Sofia Sans : Regular 400, Medium 500, Semi Bold 600, Bold 700
- Loading: Google Fonts CDN in
index.html; fallbacksystem-ui, sans-serif - Data/Console:
ui-monospace, 'SF Mono', Menlo, monospaceat 11px for activity log (unchanged)
| Token | Size | Weight | Use |
|---|---|---|---|
| Gauge hero | 42–48px | 700 | Arc center value (brightness / mode #) |
| Title | 20px | 700 | Sheet titles, header |
| Body | 17px | 400 | Default text, buttons |
| Label | 15px | 600 | Card labels, slider values |
| Eyebrow | 13px | 600 | Section headers, uppercase |
| Caption | 11–12px | 600 | Status, console |
- Approach: Restrained : mint default accent + dynamic retint to selected neon color
| Token | Value | Use |
|---|---|---|
--bg-deep / --bg-primary |
#020D0A |
Page background |
--bg-surface / --bg-grouped |
#1D1F21 |
Cards, rails, header buttons |
--bg-elevated |
#252829 |
Inactive chips, inputs |
--text-primary / --label-primary |
#DBE6E4 |
Primary text |
--text-secondary / --label-secondary |
rgba(219,230,228,0.62) |
Labels |
--text-muted / --label-tertiary |
rgba(219,230,228,0.38) |
Meta, placeholders |
--accent / --neon-glow |
#41E9BD |
Default mint; updates with selected color |
--accent-dim / --accent-muted |
rgba(65,233,189,0.18) |
Active chip fill, glow base |
--accent-glow / --accent-rgba |
rgba(65,233,189,0.45) |
Shadow, arc, rings |
--danger / --system-red |
#FF6B6B |
Offline, destructive |
--success |
#41E9BD |
Connected (same as accent) |
--warning / --system-orange |
#FFB020 |
Connecting pulse |
--system-blue |
#5B9BD5 |
Info log entries |
Top third: radial-gradient(ellipse 120% 50% at 50% -10%, rgba(8,44,38,0.85), transparent 55%) over --bg-deep.
When a solid color is selected, updateNeonThemeColor() in js/ui.js propagates the chosen hex to --accent, arc stroke, slider fill, swatch rings, and card glow. Default rest state uses mint.
Dark-only. No light theme.
- Base unit: 4px
- Density: Compact : one-handed phone use
- Max content width: 500px centered
- Key spacing:
| Context | Value |
|---|---|
| Header horizontal padding | 16px |
| Hero zone gap (rail / gauge) | 12px |
| Card grid gap | 10px |
| Card internal padding | 14px |
| Preset grid gap | 8px |
| Power CTA margin top | 12px + safe-area |
- Approach: Grid-disciplined : hero row + 2-col control cards inside dock + mode-specific preset rows
- Sticky chrome: Header fixed; hero may scroll on very short viewports
- Border radius scale:
| Token | Value | Use |
|---|---|---|
--radius-control |
16px | Swatches, inputs, quick actions |
--radius-card |
24px | Control cards, sheets |
--radius-pill |
999px | Mode rail, power CTA, status chip |
--gauge-size |
min(72vw, 280px) |
Arc diameter |
- Approach: Intentional : arc and glow communicate state
- Easing:
--spring: 0.25s cubic-bezier(0.25, 0.1, 0.25, 1) - Key animations:
| Interaction | Duration | Behavior |
|---|---|---|
| Arc value change | 280ms | stroke-dashoffset transition |
| View switch (Solid ↔ Animation) | 280ms | Gauge label crossfade |
| Sheet open | 350ms | Slide up (sheet-up) |
| Status connecting | 1s loop | Warning dot pulse |
| Button / card press | instant | scale(0.98) |
| Reduced motion | : | Collapse to 0.01ms via prefers-reduced-motion |
- States:
chip-offline,chip-connecting,chip-connected,chip-error - Truncate device names beyond 14 characters
- Tapping opens settings sheet (same as menu button)
- Never show raw BLE UUIDs or debug strings
- Vertical pills: Solid | Animation (restyled from horizontal seg control)
- Use
role="tablist"/role="tab"/role="tabpanel"for accessibility - Active pill: solid
--accentfill,--bg-deeptext, mint glow shadow
- SVG arc (~270°) with mint stroke (or dynamic accent)
- Center: large value (brightness
8or mode32) + sublabel aria-labelreflects current scene state- Updated via
updateArcGauge()injs/ui.js
- Charcoal
--bg-surface, 24px radius, optional icon badge - Disabled:
disabled-control→ 45% opacity - Connected: subtle dynamic glow on border from active color
- Live value in card header in
--accent - Thin track with mint fill; thumb with soft glow
- 4-column grid, minimum 48px touch target
- Selected: white border + accent glow ring
- Custom palette hidden behind disclosure : never compete with presets on first glance
- Collapsed by default (
palette-body.is-collapsed) - Chevron rotates 180° when expanded;
aria-expandedtoggles with state - Opens custom HSL picker (2D saturation/lightness, hue strip, HSL sliders, hex input, recent colors)
- Light haptic on toggle
- Pill chips in a scrollable grid (max-height 180px) under "Quick Presets"
- Each chip shows mode number (accent pill) + label
- Tap label to apply mode; tap × to remove
- Active chip:
--accent-mutedfill + accent ring when mode matches current selection - "Add" uses
btn-text; opens save-favorite sheet with prefilled name frommode-names.js - Persisted in
localStorage(floro_favorites)
- Bottom sheet on mobile; centered card on ≥600px
- Grabber bar at top
- Sections use uppercase 13px titles above grouped cards
- "Done" button dismisses sheet
- Lives in settings only : never on main surface
- Color-coded left border: info (blue), success (green), error (red), write (accent)
| Variant | Use |
|---|---|
btn-primary |
Scan & Connect, Save, Install (mint fill) |
btn-secondary |
Reconnect, Done, Cancel (surface fill) |
btn-destructive |
Disconnect |
btn-text |
Add favorite (accent text) |
btn-step |
± stepper (44×44px) |
power-cta |
Full-width bottom Power On/Off pill |
haptic() in js/ui.js uses navigator.vibrate when available:
| Trigger | Intensity |
|---|---|
| Swatch tap, mode change, sheet open, connect success, favorite save | Light (15ms) |
| Power off | Heavy (50ms) |
No haptic spam on slider drag; values update visually in real time.
On load, tryAutoReconnect() in js/app.js runs if Web Bluetooth is supported, ble.canReconnect() is true, and a last device exists. Status chip shows chip-connecting / "Connecting…"; success runs onConnected(), failure resets to offline. Settings sheet exposes manual Reconnect to {name} when auto-connect is unavailable or failed.
When the user is in Animation mode and picks a swatch or commits a custom color, applyColorSelection():
- Switches the view to Solid (animated panel transition)
- Sets BLE mode to 1 (solid color) without a redundant mode write
- Sends the full scene (brightness, speed, color, mode)
Tapping Solid in the toolbar also forces mode 1 if an animation was active. Tapping Animation restores lastAnimationMode if currently on solid (mode 1).
Solid ↔ Animation uses 280ms opacity + horizontal translate. Only one panel receives pointer events at a time (is-active / is-exiting classes).
When power is off, all control cards get disabled-control (45% opacity, no interaction). Brightness labels read "OFF". Power strip in toolbar is enabled only when connected.
On connect and when the user turns power on, restoreSceneToSign() sends brightness, speed, color, and the saved animation mode to the sign. The UI switches to the Animation tab with that mode pre-selected in the picker so the remote matches sign output (the sign firmware tends to show the last animation on power-on even when the saved UI tab was Solid).
Do not introduce these : they break the premium-remote feel:
| Anti-pattern | Why it's wrong |
|---|---|
| BLE debug console on main screen | Feels like a developer tool, not a remote |
| Showing Solid + Animation controls simultaneously | Violates mode-first IA; creates scroll fatigue |
| Large "Connect" hero on launch | Connection is secondary; status chip + auto-reconnect handle it |
Native <input type="color"> |
Awkward on mobile; use HSL picker in collapsed disclosure |
| Purple/violet gradient accents | Generic AI slop; use mint #41E9BD as default chrome |
| iOS system-font-only UI | Use Sofia Sans per reference mockups |
| Multi-column marketing layout | This is a utility remote, not a landing page |
| Centered-everything with uniform spacing | Use left-aligned grouped cards like iOS Settings |
| Exposed UUID / GATT service names in UI | Belongs in dev docs, not user-facing chrome |
| Modal stacks (sheet on sheet on sheet) | One overlay at a time; use sequential sheets |
| Light theme | Breaks neon-on-dark premium aesthetic |
| URL | Role |
|---|---|
https://rawshn.com/sign-controller/ |
Public production URL (canonical) |
Subdirectory routing: This repo is a git submodule at rawshn-portfolio/public/sign-controller/. The portfolio build serves those static files at /sign-controller/ on rawshn.com (embedded static assets, not an external rewrite like /coach). Deploy via bash scripts/deploy.sh or bump the submodule in rawshn-portfolio and run vercel deploy --prod there.
Canonical contract: 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:
standalonewithdisplay_override: ["standalone"] - Identity:
id=./(resolves tohttps://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 vianode scripts/generate-icons.mjs - Service worker: register
./sw.jsimmediately withupdateViaCache: "none"; bumpCACHE_NAMEon asset changes - Install prompt:
#install-banner; native Install only whenbeforeinstallpromptfired, otherwise a clearly labeled help action - Required header:
Permissions-Policy: bluetooth=(self)plusCache-Control: no-cacheonsw.js(portfoliovercel.json)
| File | Design responsibility |
|---|---|
index.html |
IA structure, ARIA roles, component markup |
css/styles.css |
All visual tokens, mint remote layout, gauge, motion |
js/ui.js |
View transitions, sheets, theme color |
js/pwa-install.js |
Native install prompt (Health Hub contract) |
UI_OVERHAUL_PLAN.md |
Phased implementation plan for mint remote build |
js/state.js |
Scene persistence (localStorage) |
js/app.js |
Mode logic, auto-reconnect, remote dock, color presets |
js/mode-names.js |
Curated friendly names for modes 1–200 |
js/color-picker.js |
Custom HSL palette widget |
js/errors.js |
User-facing BLE error messages for activity log |
manifest.webmanifest |
PWA identity (./ scope, canonical id) |
icons/ |
Install / home-screen icons (from Neon Attack logo.png) |
sw.js |
Offline cache |
logo.png |
Neon Attack official mark (header + icon source) |
scripts/generate-icons.mjs |
Regenerate icons from logo.png |
Locked layout decisions for the from-scratch HTML/CSS rebuild. Tokens and north star unchanged; this section supersedes conflicting notes in older IA diagrams above.
- Visible: Neon Attack logo + "FloRo" brand, vertical-dots menu (
#btn-menu) only. - Hidden:
#status-chipissr-only(screen reader + ID contract). Connection state surfaces on the Connect quick-action chip (#quick-settings), green when connected viaupdateConnectQuickAction().
- Arc gauge (
#scene-gauge) + vertical Static / Dynamic mode rail (display labels; internal IDs remainmode-seg-solid/mode-seg-animation). - Quick actions row: Color, Modes, Add, Connect.
- Scrollable panel with
flex: 1; min-height: 0; overflow-y: autoon.remote-panel. Power CTA pinned at dock bottom (flex-shrink: 0). No toolbar power mirror. - Static tab:
.solid-layoutflex row - compact brightness stepper ~20% left (min 68px), neon color card ~80% right (side by side, never stacked).- Brightness: vertical remote rail (up/down steps, sun readout, 8-segment tap track + accent fill) + hidden range input for BLE sync.
- Neon color: 4×2 swatch grid, Custom color + Save preset actions below (grid, not overlapping swatches).
- Color presets: 3-column
.preset-row--solidgrid; labels truncated to 6 chars viatruncatePresetLabel().
- Dynamic tab: brightness + speed slider cards, mode picker card, horizontal-scroll
.preset-row--animfor favorites. - Power: single
#power-btn.power-ctamint pill at dock bottom only.
.app-shell:height: 100dvh; overflow: hiddencolumn flex.- Hero compacts via
max-height+ smaller--gauge-sizeat ≤915px and ≤740px height (OnePlus 12 / short Android). - Connect strip (
#connect-prompt) stays mint-accented; not retinted to selected neon. - Install banner: fixed overlay outside
.app-shell,z-index: 70.
--accent-on(computed inaccentOnColor()) for readable text on accent-filled controls, chips, and mode rail active state.
- All 65 IDs in
scripts/check-ids.sh. updateConnectQuickAction(),updateNeonThemeColor(),truncatePresetLabel()behavior preserved.- Service worker cache bumped on static asset change (
floro-controller-v57as of 2026-09-05).
| Date | Decision | Rationale |
|---|---|---|
| 2026-06-23 | Ground-up IA: mode-specific views + settings sheet | User chose full IA redesign over reskin; matches Hue/Home remote patterns |
| 2026-06-23 | Premium hardware remote as north star | Differentiates from generic BLE panels; drives compact main + hidden infra |
| 2026-06-23 | iOS 16 dark grouped aesthetic | Native feel on primary target (Chrome Android + iOS PWA install) |
| 2026-06-23 | Accent #ff007f with dynamic color sync |
FloRo brand pink default; UI glows selected neon color |
| 2026-06-23 | Collapsible custom palette | Keeps solid view scannable; power users expand HSL picker |
| 2026-06-23 | Auto-reconnect on load | Remote should "just work" when sign is in range |
| 2026-06-23 | Activity log in settings only | Debug visibility without polluting main remote surface |
| 2026-06-23 | Deploy at /sign-controller/ via portfolio submodule |
Keeps rawshn.com namespace clean; single Vercel project (portfolio) |
| 2026-06-29 | Android install banner fallback + 7-day dismiss TTL | beforeinstallprompt is unreliable on some Android builds; fallback banner + timed dismiss prevents permanent hide after accidental tap |
| 2026-06-29 | Fixed install overlay outside shell | Shipped #install-banner at z-70; do not move into .app-shell flex (UX review 2026-06-29) |
| 2026-06-29 | Standalone PWA paths (absolute manifest + scoped SW) | start_url/scope/id = /sign-controller/ like Health Hub /coach/; SW uses __FLORO_PWA_BASE |
| 2026-06-29 | Neon Attack logo in header + PWA icons | Official logo.png branding with FloRo title and #ff007f accent; icons regenerated from same source |
| 2026-06-29 | Animation picker IA redesign | Mode hero + searchable scrollable list with number + name; dropdown hidden (a11y fallback only) |
| 2026-06-29 | js/mode-names.js curated placeholders |
200 memorable names (mode 1 = Solid Color); APK strings not in repo, replace when available |
| 2026-06-29 | IEC power icon in toolbar | Universal power symbol (stem + arc) instead of toggle metaphor |
| 2026-06-30 | Mobile install dock always shows Install | Compact bar after Not now keeps Install one tap away; Android 2.5s fallback matches Health Hub |
| 2026-06-30 | Bottom-first remote dock layout | Power, tabs, colors, and mode controls in thumb zone; no primary scroll on phone |
| 2026-06-30 | Scene state persistence (floro_scene_state) |
Restore tab, brightness, speed, color, mode on reconnect and power-on |
| 2026-06-30 | Power-on opens Animation tab | UI aligns with sign output on restore; avoids solid tab while sign animates |
| 2026-06-30 | Quick presets: 4 defaults, empty array respected | Deleted presets never respawn; only fresh install seeds defaults |
| 2026-06-30 | Official Neon Attack icon from APK playstore.png | Header + PWA icons regenerated from APK asset |
| 2026-06-30 | Mode picker + custom color as bottom sheets | Keeps main remote surface compact; lists scroll inside sheets only |
| 2026-06-30 | Save color preset (floro_color_presets) |
User-named custom colors on Solid tab |
| 2026-06-29 | Mint Remote visual overhaul (planned) | Reference mockups: Sofia Sans, #41E9BD, arc gauge, card grid; see UI_OVERHAUL_PLAN.md |
| 2026-06-29 | Default accent mint, dynamic retint kept | North star unchanged: UI glows selected neon color |
| 2026-06-29 | Feature Parity Matrix in UI_OVERHAUL_PLAN.md | Every v2.1.0 feature mapped before mint build; devex/QA acceptance contract |
| 2026-06-29 | UI Rebuild v2 from scratch | Clean HTML/CSS; sr-only status chip; Connect quick-action shows connection; 20/80 static layout; scrollable dock; no power mirror; v38 cache |
| 2026-07-09 | Material 3 minimal redesign | User rejected Mint Remote visual noise; M3 surfaces, Roboto Flex, no arc/glow |
| 2026-07-09 | Color: hero squircle + 4 recents on main | Frequent color flippers; neon grid and saved row move to Color sheet only |
| 2026-07-09 | Squircle shape system for all color targets | Unified 44/48/56dp squircles; ring selection, no global neon retint |
| 2026-07-09 | Dynamic adjust context (Mode/Brightness/Speed) | One readout ± control; fine-tune sliders secondary; mode ± skips solid mode 1 |
| 2026-07-09 | Global color strip below tabs | Recents reachable from Dynamic default without tab switch |
| 2026-07-09 | Default launch tab: Dynamic | SCENE_DEFAULTS.displayView: 'animation'; first open matches animation-first use |
| 2026-07-09 | Custom color: commit on pause + sheet close flush | Live BLE while dragging; Static tab when leaving animation; Done only closes |
| 2026-07-09 | Material Symbols + 48dp control rows | Cut whitespace; icons replace verbose labels; compact density |
| 2026-07-09 | Color swatch types: neon / saved / custom | Dashed ring + tune badge for unsaved custom; labels under recents; resolveColorLabel |
| 2026-09-05 | PWA install matches Health Hub | Native prompt() only; relative manifest; no <base>; see specs/pwa.md |