diff --git a/openspec/changes/add-pwa-mobile/.openspec.yaml b/openspec/changes/add-pwa-mobile/.openspec.yaml new file mode 100644 index 0000000..cd2ce7e --- /dev/null +++ b/openspec/changes/add-pwa-mobile/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-16 diff --git a/openspec/changes/add-pwa-mobile/design.md b/openspec/changes/add-pwa-mobile/design.md new file mode 100644 index 0000000..cb7af9d --- /dev/null +++ b/openspec/changes/add-pwa-mobile/design.md @@ -0,0 +1,79 @@ +## Context + +The dashboard (`web/`, React 18 + Vite 6 + Tailwind v3) is served by Express as +static files from `web/dist` (same origin as `/api` + SSE). `App.tsx` already uses a +`grid-cols-1 lg:grid-cols-3` layout, so it collapses to a single column on mobile, +but two things are missing for a good phone experience: (1) the `PriceMatrix` order +book is a 6-column `` that overflows horizontally on ~360–430px screens, and +(2) there is no install/mobile metadata (manifest, theme-color, apple-touch-icon). + +The app is real-time: the frontend consumes `/api/state` over SSE and shows a +`connected` badge. A domain rule (honesty) forbids ever presenting stale or fake +market data. The target runtime is the 24/7 VPS behind HTTPS at +`arbpulse.wayool.com` (nginx + certbot, Cloudflare DNS-only). + +## Goals / Non-Goals + +**Goals:** +- Installable to the phone home screen (standalone, branded icon). +- Responsive, touch-friendly dashboard on phones without regressing desktop. +- Zero risk of showing cached/stale market data. +- No new runtime dependencies; no change to build/deploy steps. + +**Non-Goals:** +- Offline support of any kind (no cached app shell, no cached data). +- Push notifications / background sync. +- A native app or app-store packaging. +- Redesigning the dashboard; this is layout + metadata only. + +## Decisions + +### D1: No service worker (installable via manifest + HTTPS only) +Since Chrome 108 (mobile) / 112 (desktop), a service worker is no longer required to +install from the browser menu / "Add to Home Screen"; iOS Safari never required one. +No-op `fetch` handlers are now actively skipped and warned about by Chrome. Because +this app must never serve cached data, we ship **no service worker at all**. +- **Chosen:** manifest + icons + HTTPS → installable; SSE always fetches live data. +- **Alternatives:** `vite-plugin-pwa`/Workbox with a shell precache (rejected: its + value is offline/precaching, which conflicts with the no-stale-data rule and adds + a dependency); a minimal passthrough SW just to get the auto-install banner + (rejected: Chrome ignores no-op fetch handlers, and it adds a moving part for a + banner we don't need — manual install from the menu is sufficient). +- **Trade-off:** no automatic install prompt on Android; users install from the + browser menu (the only path on iOS anyway). Acceptable. + +### D2: PriceMatrix responsive strategy — stacked cards below `sm` +Render per-venue cards (exchange + bid/ask/qty/spread as label–value rows) on small +screens and keep the existing `
` from `sm` up. +- **Chosen:** Tailwind responsive classes to swap layouts (`block sm:table` pattern + or two branches gated by breakpoint) — no JS, no `matchMedia`, purely CSS. +- **Alternative:** wrap the table in `overflow-x-auto` (rejected as primary: a + 6-column price table sideways-scrolling on a phone is poor UX for the primary + widget; horizontal scroll can remain as a defensive fallback). + +### D3: Icons derived from one master +Generate a single ArbPulse master icon (pulse line → Bitcoin "B", blue→green on +`#0a0e14`) and derive `icon-192.png`, `icon-512.png`, `icon-maskable-512.png` +(with safe padding), and `apple-touch-icon.png` (180). Store under `web/public/` so +Vite copies them verbatim to `web/dist`. +- **theme_color/background_color:** `#0a0e14` (matches the dashboard background). + +### D4: Manifest + head metadata, same-origin +`manifest.webmanifest` uses `start_url: "/"`, `scope: "/"`, `display: "standalone"`. +`index.html` adds `theme-color`, `apple-mobile-web-app-capable`, +`apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`, +`viewport-fit=cover` (already has `width=device-width, initial-scale=1`), and links +to the manifest + apple-touch-icon. Everything is same-origin, so no CORS/proxy +concerns and the existing Express static + SPA fallback serve it unchanged. + +## Risks / Trade-offs + +- [Stale data if a SW is ever added later] → Documented no-SW decision; if a SW is + introduced in the future it MUST deny-list `/api` and never cache the SSE stream. +- [iOS quirks: no auto-prompt, status-bar/notch rendering] → Provide + `apple-touch-icon`, `apple-mobile-web-app-*`, and `viewport-fit=cover`; verify on a + real iPhone (Add to Home Screen), not just Android. +- [PriceMatrix layout duplication risk] → Keep a single data mapping and switch only + presentation via breakpoints to avoid divergent desktop/mobile logic. +- [Maskable icon getting cropped] → Keep the glyph within the ~80% safe zone in the + maskable variant. diff --git a/openspec/changes/add-pwa-mobile/proposal.md b/openspec/changes/add-pwa-mobile/proposal.md new file mode 100644 index 0000000..b44e2cb --- /dev/null +++ b/openspec/changes/add-pwa-mobile/proposal.md @@ -0,0 +1,48 @@ +## Why + +The Arb Pulse dashboard is desktop-first: the 3-column grid collapses acceptably on +narrow screens, but the order-book table overflows horizontally and there is no +mobile/PWA metadata. We want to demo and monitor the engine from a phone — +installable to the home screen, full-window, and touch-friendly — while the app +runs 24/7 on the VPS behind HTTPS (`arbpulse.wayool.com`). + +## What Changes + +- Make the dashboard fully responsive on phones (~360–430px): stack the + `PriceMatrix` order book as per-venue cards below `sm`, reflow the `StatsBar` + badges, and ensure touch targets are at least ~44px in `Controls`/`ConfigPanel`. +- Add a Web App Manifest (`web/public/manifest.webmanifest`) so the dashboard is + installable to the home screen (name/short_name, `start_url`, `display: standalone`, + theme/background color, icons). +- Add ArbPulse app icons (192, 512, a maskable variant, and a 180px + `apple-touch-icon`) under `web/public/`. +- Add mobile/install metadata to `web/index.html`: `theme-color`, + `apple-mobile-web-app-*`, `viewport-fit=cover`, and links to the manifest and + apple-touch-icon. +- **No service worker and no offline support.** The app is real-time (SSE); it must + never serve cached/stale market data. Installability comes from the manifest + + HTTPS (menu / "Add to Home Screen"), not from a cached shell. When disconnected + the UI shows its existing "reconnecting/offline" state, never fake data. + +## Capabilities + +### New Capabilities +- `pwa`: installability and mobile-web behavior of the dashboard — how the app is + installable to the home screen via manifest + HTTPS with no service worker, how + it stays responsive on small screens, and the explicit no-offline / no-stale-data + guarantee for this real-time app. + +### Modified Capabilities + + +## Impact + +- Frontend: `web/index.html` (head metadata), `web/public/` (new manifest + icons), + `web/src/components/PriceMatrix.tsx` and `StatsBar.tsx` (responsive layout), + minor touch-target tweaks in `Controls.tsx`/`ConfigPanel.tsx`. +- Build/serve: none — `npm run build` copies `web/public/` into `web/dist`, already + served by Express static; the Docker image bakes it in. No new deploy steps. +- Dependencies: none added (no `vite-plugin-pwa`/Workbox, since there is no SW). +- Behavior: no API, wire-format, or engine changes. Desktop layout unchanged. +- Testing: verified on a real phone at `https://arbpulse.wayool.com` after the + normal `dev → PR → main → deploy.sh` flow. diff --git a/openspec/changes/add-pwa-mobile/specs/pwa/spec.md b/openspec/changes/add-pwa-mobile/specs/pwa/spec.md new file mode 100644 index 0000000..b84c244 --- /dev/null +++ b/openspec/changes/add-pwa-mobile/specs/pwa/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Installable to the home screen +The dashboard SHALL be installable to a device home screen via a Web App Manifest +served over HTTPS, without requiring a service worker. + +#### Scenario: Manifest is present and valid +- **WHEN** the dashboard is loaded over HTTPS +- **THEN** a linked `manifest.webmanifest` is served that includes `name`, + `short_name`, `start_url` in scope, `display: standalone`, `theme_color`, + `background_color`, and at least 192px and 512px PNG icons + +#### Scenario: User installs from the browser +- **WHEN** a user chooses "Install app" / "Add to Home Screen" from the browser menu +- **THEN** the app is added with the ArbPulse icon and launches in a standalone, + full-window mode at `start_url` + +### Requirement: No offline mode and no stale data +The app SHALL NOT register a service worker and SHALL NOT cache market data, so it +never presents stale or fake data (real-time honesty rule). + +#### Scenario: No service worker is registered +- **WHEN** the dashboard runs in any environment +- **THEN** no service worker is registered and no application cache stores `/api` + responses or the SSE stream + +#### Scenario: Disconnected device shows a clear non-data state +- **WHEN** the device has no working connection to the engine +- **THEN** the UI shows its reconnecting/offline indicator and does not display + cached market figures as if they were live + +### Requirement: Responsive mobile layout +The dashboard SHALL be usable on phone-sized viewports (~360–430px wide) without +horizontal overflow of primary widgets, while the desktop layout is unchanged. + +#### Scenario: Order book fits a phone screen +- **WHEN** the viewport is narrower than the `sm` breakpoint +- **THEN** the order-book (`PriceMatrix`) is presented as per-venue stacked cards + showing bid, ask, quantities, and spread without horizontal scrolling + +#### Scenario: Stats and controls remain readable and tappable +- **WHEN** the dashboard is viewed on a phone +- **THEN** the stats bar reflows without clipping and interactive controls have + touch targets of at least ~44px + +#### Scenario: Desktop layout is preserved +- **WHEN** the viewport is at the `lg` breakpoint or wider +- **THEN** the existing multi-column layout and table order book render as before + +### Requirement: Mobile and install metadata +The `index.html` head SHALL include the metadata needed for correct mobile rendering +and home-screen installation on Android and iOS. + +#### Scenario: Head includes mobile/install tags +- **WHEN** the page is served +- **THEN** the head contains a `theme-color`, `viewport-fit=cover` in the viewport + meta, a link to the manifest, an `apple-touch-icon`, and the + `apple-mobile-web-app-*` tags for standalone iOS launch diff --git a/openspec/changes/add-pwa-mobile/tasks.md b/openspec/changes/add-pwa-mobile/tasks.md new file mode 100644 index 0000000..338edfd --- /dev/null +++ b/openspec/changes/add-pwa-mobile/tasks.md @@ -0,0 +1,39 @@ +## 1. Icons + +- [x] 1.1 Finalize the ArbPulse master icon and derive `icon-192.png`, + `icon-512.png`, `icon-maskable-512.png` (glyph within ~80% safe zone), and + `apple-touch-icon.png` (180px) +- [x] 1.2 Place all icons under `web/public/` + +## 2. Manifest + +- [x] 2.1 Create `web/public/manifest.webmanifest` with `name`, `short_name`, + `start_url: "/"`, `scope: "/"`, `display: "standalone"`, + `theme_color`/`background_color` `#0a0e14`, and icon entries (192, 512, and + 512 maskable with `"purpose": "maskable"`) + +## 3. HTML head metadata + +- [x] 3.1 In `web/index.html`, add `viewport-fit=cover` to the viewport meta and a + `theme-color` meta (`#0a0e14`) +- [x] 3.2 Add ``, ``, and the + `apple-mobile-web-app-capable` / `apple-mobile-web-app-status-bar-style` / + `apple-mobile-web-app-title` tags + +## 4. Responsive layout + +- [x] 4.1 `PriceMatrix.tsx`: render per-venue stacked cards (exchange + bid/ask/qty/ + spread as label–value rows) below `sm`; keep the `
` from `sm` up, with + no horizontal overflow on phones +- [x] 4.2 `StatsBar.tsx`: reflow stats and badges on mobile without clipping +- [x] 4.3 Ensure touch targets in `Controls.tsx` and `ConfigPanel.tsx` are ~44px min +- [x] 4.4 Sanity-check `App.tsx` spacing/padding on narrow viewports + +## 5. Verify + +- [x] 5.1 `npm run typecheck` and `npm test` pass +- [x] 5.2 `npm run build` succeeds and `web/dist` contains the manifest + icons +- [x] 5.3 DevTools device emulation: no horizontal overflow at ~375px; Application → + Manifest shows no errors; confirm no service worker is registered +- [ ] 5.4 After `dev → PR → main → deploy.sh`, install on a real phone from + `https://arbpulse.wayool.com` and confirm standalone launch + live SSE data diff --git a/scripts/arbpulse-icon-master.png b/scripts/arbpulse-icon-master.png new file mode 100644 index 0000000..a2ed262 Binary files /dev/null and b/scripts/arbpulse-icon-master.png differ diff --git a/scripts/gen-icons.ps1 b/scripts/gen-icons.ps1 new file mode 100644 index 0000000..414ac2e --- /dev/null +++ b/scripts/gen-icons.ps1 @@ -0,0 +1,44 @@ +# One-off ArbPulse PWA icon generator. +# Resizes the AI-generated master icon into the sizes referenced by the +# Web App Manifest and index.html. Uses only Windows System.Drawing (no deps). +param( + [string]$Master = (Join-Path $PSScriptRoot "arbpulse-icon-master.png"), + [string]$OutDir = (Join-Path $PSScriptRoot "..\web\public") +) + +Add-Type -AssemblyName System.Drawing + +$bg = [System.Drawing.ColorTranslator]::FromHtml("#0a0e14") + +function Save-Resized { + param([System.Drawing.Image]$Src, [int]$Size, [string]$Path, [double]$Scale = 1.0) + + $bmp = New-Object System.Drawing.Bitmap($Size, $Size) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $g.Clear($bg) + + $target = [int]([math]::Round($Size * $Scale)) + $offset = [int]([math]::Round(($Size - $target) / 2)) + $rect = New-Object System.Drawing.Rectangle($offset, $offset, $target, $target) + $g.DrawImage($Src, $rect) + + $g.Dispose() + $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + Write-Output "wrote $Path ($Size x $Size, scale $Scale)" +} + +$src = [System.Drawing.Image]::FromFile($Master) + +Save-Resized -Src $src -Size 192 -Path (Join-Path $OutDir "icon-192.png") +Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-512.png") +Save-Resized -Src $src -Size 180 -Path (Join-Path $OutDir "apple-touch-icon.png") +# Maskable: full-bleed on the master's own background. The glyph's built-in +# padding already keeps it inside the ~80% safe zone, and full-bleed avoids a +# visible seam from mismatched flat padding vs. the master's subtle glow. +Save-Resized -Src $src -Size 512 -Path (Join-Path $OutDir "icon-maskable-512.png") -Scale 1.0 + +$src.Dispose() diff --git a/web/index.html b/web/index.html index c70a173..cf252af 100644 --- a/web/index.html +++ b/web/index.html @@ -2,9 +2,16 @@ - + Arb Pulse — BTC Arbitrage Engine + + + + + + + -
+
+

ArbPulse

-

Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance

+

+ Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX + · Binance +

BTC/USDT · WebSocket feeds · inventory model @@ -33,7 +36,9 @@ export function App(): JSX.Element {

{!snapshot ? ( -
Connecting to engine…
+
+ Connecting to engine… +
) : (
@@ -47,7 +52,10 @@ export function App(): JSX.Element {
- +
diff --git a/web/src/components/ConfigPanel.tsx b/web/src/components/ConfigPanel.tsx index 180c7fa..be1c03e 100644 --- a/web/src/components/ConfigPanel.tsx +++ b/web/src/components/ConfigPanel.tsx @@ -37,12 +37,21 @@ function Toggle({ type="button" onClick={() => onChange(!on)} className={clsx( - "flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition", - modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500", + "flex min-h-[44px] items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition", + modified + ? "border-accent/50 bg-accent/5" + : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500", )} > - {label} - + + {label} + +
- {label} + + {label} + {valueLabel}
-

Active exchanges

+

+ Active exchanges +

{EXCHANGES.map(({ id, label }) => ( void }): JSX.Element { +function Toggle({ + label, + on, + onChange, +}: { + label: string; + on: boolean; + onChange: (v: boolean) => void; +}): JSX.Element { return ( @@ -49,7 +62,7 @@ export function Controls({ stats, config }: Props): JSX.Element { @@ -57,13 +70,17 @@ export function Controls({ stats, config }: Props): JSX.Element {
- control.setDemo(v)} /> + control.setDemo(v)} + />
@@ -89,7 +106,10 @@ export function Controls({ stats, config }: Props): JSX.Element {
Taker fees - K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)} + K {pct(config.takerFees.kraken, 2)} · By{" "} + {pct(config.takerFees.bybit, 2)} · O{" "} + {pct(config.takerFees.okx, 2)} · Bn{" "} + {pct(config.takerFees.binance, 2)}
diff --git a/web/src/components/PriceMatrix.tsx b/web/src/components/PriceMatrix.tsx index 664fa1a..0678bc2 100644 --- a/web/src/components/PriceMatrix.tsx +++ b/web/src/components/PriceMatrix.tsx @@ -50,7 +50,83 @@ export function PriceMatrix({ quotes }: Props): JSX.Element { ) : undefined } > -
+ {/* Mobile: per-venue stacked cards (no horizontal overflow). */} +
+ {quotes.map((q) => { + const spread = + q.ask !== null && q.bid !== null ? q.ask - q.bid : null; + return ( +
+
+ + {LABELS[q.exchange]} +
+
+
+
+ Bid +
+
+ {q.bid !== null ? usd(q.bid) : "—"} +
+
+
+
+ Bid Qty +
+
+ {q.bidQty?.toFixed(3) ?? "—"} +
+
+
+
+ Ask +
+
+ {q.ask !== null ? usd(q.ask) : "—"} +
+
+
+
+ Ask Qty +
+
+ {q.askQty?.toFixed(3) ?? "—"} +
+
+
+
+ Spread +
+
+ {spread !== null ? usd(spread) : "—"} +
+
+
+
+ ); + })} +
+ + {/* Desktop / tablet: full table from the sm breakpoint up. */} +
@@ -64,34 +140,50 @@ export function PriceMatrix({ quotes }: Props): JSX.Element { {quotes.map((q) => { - const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null; + const spread = + q.ask !== null && q.bid !== null ? q.ask - q.bid : null; return ( - + - - + + ); })} diff --git a/web/src/components/StatsBar.tsx b/web/src/components/StatsBar.tsx index 546fe7b..d217073 100644 --- a/web/src/components/StatsBar.tsx +++ b/web/src/components/StatsBar.tsx @@ -6,10 +6,20 @@ interface Props { connected: boolean; } -function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element { +function Stat({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "profit" | "loss" | "warn"; +}): JSX.Element { return (
- {label} + + {label} + = 0 ? "profit" : "loss"; return ( -
- +
+ -
+
{stats.demoMode && ( Demo / Simulated Feed )} - + {circuit.label} - + {connected ? "LIVE" : "OFFLINE"}
- + {LABELS[q.exchange]} {q.bid !== null ? usd(q.bid) : "—"} {q.bidQty?.toFixed(3) ?? "—"} + {q.bidQty?.toFixed(3) ?? "—"} + {q.ask !== null ? usd(q.ask) : "—"} {q.askQty?.toFixed(3) ?? "—"}{spread !== null ? usd(spread) : "—"} + {q.askQty?.toFixed(3) ?? "—"} + + {spread !== null ? usd(spread) : "—"} +