diff --git a/.agents/notes/implemented/2026-10-06-three-boards-install-on-a-phone.md b/.agents/notes/implemented/2026-10-06-three-boards-install-on-a-phone.md
index de27fadf..1684a09b 100644
--- a/.agents/notes/implemented/2026-10-06-three-boards-install-on-a-phone.md
+++ b/.agents/notes/implemented/2026-10-06-three-boards-install-on-a-phone.md
@@ -178,7 +178,10 @@ new tokens valued per theme, because no pair of the existing ones keeps the
selected segment lighter than its track in both. Rejected: two rows of six for
the console's eleven pages (every page pays for a launcher grid at the top) and
a tab bar fixed at the bottom (a fixed bar fights the composer and the
-keyboard, and the operator's other installed board took its own out).
+keyboard, and the operator's other installed board took its own out). The
+second was reversed on 2026-11-12
+(`2026-11-12-the-boards-are-apps-on-a-phone.md`): the bar steps aside while a
+field is typed in, and that other board has a bottom tab bar again.
Measured headless at 375 and 414 in both languages, both themes: no cell off
the screen, nothing overflowing, no name cut, one row on the pipe's and the
diff --git a/.agents/notes/implemented/2026-11-12-the-boards-are-apps-on-a-phone.md b/.agents/notes/implemented/2026-11-12-the-boards-are-apps-on-a-phone.md
new file mode 100644
index 00000000..abfac30a
--- /dev/null
+++ b/.agents/notes/implemented/2026-11-12-the-boards-are-apps-on-a-phone.md
@@ -0,0 +1,100 @@
+---
+title: On a phone the three boards are laid out as apps, with a tab bar at the bottom
+status: implemented
+date: 2026-11-12
+scope: stack
+---
+
+## Decision
+
+On a phone (640px and narrower) the three boards take an app's frame; on a
+desk the layout is what it was, refined.
+
+- **The tab strip is a tab bar** pinned to the bottom edge, frosted, one row of
+ equal cells with the icon over the name. The page on screen is marked by a
+ soft pill behind its icon; a count that needs somebody (`.count.warn`,
+ `.count.bad`) is a filled badge on the icon, any other count a quiet one. The
+ console's two groups stay one cell each, and a group's own pages are a
+ segmented control under the large title.
+- **The bar steps aside while a field is typed in**, and comes back on the blur.
+- **A large title** names the page on screen; once it has scrolled under the
+ header, the header shows the name in small, centred, and draws its bottom line.
+- **The header keeps what a thumb needs:** the name, a search button that opens
+ the search field under it, the gear, and the live dot, with the word shown
+ only when the board is not live. Language, light or dark and ↻ are left to the
+ settings drawer, where all three boards already offered them.
+- **A dialog is a sheet** rising from the bottom edge, its answers full width
+ and 46px tall; **a toast is a capsule** dropping from under the status bar.
+- **An installed board refreshes when pulled** down from the top. It presses the
+ same ↻ a desk does, and it is wired only where nothing else answers a pull: an
+ installed board, on a touch screen, with no drawer or dialog open.
+- **Depth, glass and motion are tokens** valued per mode: `--elev`/`--elev-up`
+ for cards, `--glass`/`--hairline` for the bars, `--ease`/`--spring` for what
+ slides, and `--num` (the system's rounded face, figures of one width) for
+ numbers a person counts. The overview's one sentence glows from its card's
+ corner in the colour of what it says, and its badge sends out a slow ring when
+ something needs somebody. A shown page fades and rises 8px into place. Under
+ `prefers-reduced-motion` all of it stands still.
+- **The home-screen icons** are drawn on a surface lit from above with a faint
+ glow of the accent behind the glyph, and iOS is handed the full-bleed icon.
+
+The new script is one pinned block, `hookstack app shell`, the twelfth in
+`scripts/assert_design.py`.
+
+## Why
+
+The operator called the boards crude as apps and asked for taste ("这个 ui
+太简陋了 … 做的有 taste 一点,给人惊喜"). Rendered at 414px before the change,
+the header held seven controls in two rows (the name, the language, the gear,
+light or dark, ↻, the live pill, and the search on its own row), and the tabs
+sat under it, out of a thumb's reach, in a strip that read as a web page's.
+
+**The tab bar was turned down once**, on 2026-10-06
+(`2026-10-06-three-boards-install-on-a-phone.md`), for two reasons, and both
+were checked before this reversed it:
+
+- *A fixed bar fights the composer and the keyboard.* It did, the way any bar
+ does: the keyboard lifts a fixed bottom bar over the field it opened for. So
+ the bar now leaves while a text field, a textarea or a select has focus, the
+ way the phone's own apps hide theirs, and the console's session panes end above
+ the bar (`--tabbar-h`), so the composer is never under it. Measured headless:
+ the bar is `display: none` with the session composer focused and back on blur.
+- *The operator's other installed board took its own out.* It has one again: its
+ design language (read 2026-11-12) lays a phone out as a fixed header and a
+ bottom tab bar for the places an on-call person opens most.
+
+That other board also paid for a lesson this keeps: the View Transitions API,
+used there for a change of page, flashed its fixed tab bar and swallowed the taps
+of a whole transition, by design. A change of page here is a plain CSS animation
+on the view being shown, which captures nothing and blocks nothing, and a live
+refetch, which redraws inside a view without showing it again, never replays it.
+
+Everything else follows from the bar: once the strip is at the bottom, the name
+of the page is no longer on screen, which is what the large title is for; the
+second row of a group's pages had nowhere to go but under that title; and with
+the tabs gone from the top, the header could shed what a phone reaches through
+the gear anyway. A browser tab keeps its own pull to refresh, so the board's
+pull is wired only in an installed board, which has none.
+
+## Consequences
+
+- Measured headless on the stack smoke's data, every page of the three boards
+ (126 renders: 375px and 414px, both languages, light, and dark at 375px): no
+ page wider than the screen, no tab name cut, one row in the bar on all three
+ boards, every field at 16px; the pull arms at 64px of travel
+ and presses ↻ once; the header's small title follows the scroll both ways; the
+ group's segmented control presses the strip's own tabs, so the page's router
+ is still the only one, and it follows a language switch.
+- On a desk the layout and every control are where they were; the cards gained a
+ shadow on white and a top edge in the dark, and numbers moved from `--mono` to
+ `--num`.
+- The same sweep found the console's Approvals squeezing a report's title into
+ a column one word wide beside its buttons, on a phone; its two tables are one
+ block per row there now, like the flight recorder. And the desk's margin under
+ the strip had lifted the tab bar 20px off the bottom edge; the phone zeroes it.
+- A home-screen icon on a phone keeps the image it was added with. The new icons
+ reach a phone only when the icon is added again, like the status-bar style.
+- The tab shell's phone rules and the app shell together decide what a phone
+ shows; a fourth board gets both by copying the blocks.
+- Not done: swipe-back from the edge to close a drawer, and a count-up of the
+ overview's numbers. Each is a gesture or a timer the boards do not have yet.
diff --git a/OVERVIEW.md b/OVERVIEW.md
index 123dcf0e..d3cfa918 100644
--- a/OVERVIEW.md
+++ b/OVERVIEW.md
@@ -206,16 +206,18 @@ investigated, approved by a named person, the condition ending, the fix held.
## On a phone
-
+
The same three boards at a phone's width, from a run of the rehearsal above.
-Each installs as an app and runs under the status bar in its own colour; its
-tabs fit one row, and the investigator's two groups each take one cell, the open
-one unfolding its pages beneath as a segmented control. Wide tables become one
-block per row, and every field types at a size the phone does not zoom into. A
-press from a phone, on a card or in the console, approves the version of the
-procedure that was on the screen; one that changed since is refused and the
-console shows it again.
+Each installs as an app and is laid out as one: a frosted tab bar along the
+bottom edge, the page's name as a large title that the header takes over once it
+scrolls away, the overview's one sentence glowing in the colour of its answer.
+The investigator's two groups each take one cell in the bar, the open one's pages
+a segmented control under the title. A dialog rises as a sheet, an installed
+board refreshes when pulled down, wide tables become one block per row, and
+every field types at a size the phone does not zoom into. A press from a phone,
+on a card or in the console, approves the version of the procedure that was on
+the screen; one that changed since is refused and the console shows it again.
## Running it locally
diff --git a/README.md b/README.md
index 04e7f524..19140ed8 100644
--- a/README.md
+++ b/README.md
@@ -36,9 +36,9 @@ The investigator is the product. The pipe and the judge are the smallest alertin
- **Half a day, for real:** [STACK.md](STACK.md) runs all three from source; [docs/containment.md](docs/containment.md) is the boundary map you read *before* pointing it at a cluster.
- **What it does, in full:** [docs/what-you-get.md](docs/what-you-get.md).
-
+
-The three boards read in Chinese or English and follow the system's light or dark; [the full tour](OVERVIEW.md) has every screenshot, from local runs and not mockups.
+The three boards install on a phone as apps, read in Chinese or English and follow the system's light or dark; [the full tour](OVERVIEW.md) has every screenshot, from local runs and not mockups.
## How it fits together
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 4c06ecef..1b0eb677 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -30,9 +30,9 @@
- **半天,真上:**[STACK.md](STACK.md) 从源码一步步跑起三个服务;[docs/containment.md](docs/containment.md) 是指向真实集群**之前**要读的边界地图。
- **完整功能清单:**[docs/what-you-get.md](docs/what-you-get.md)(英文)。
-
+
-三块看板都能切换中英文,并跟随系统的浅色或深色;[完整介绍](OVERVIEW.md)(英文)里有全部截图,取自本地真实运行,不是效果图。
+三块看板都能装到手机上当 App 用,能切换中英文,并跟随系统的浅色或深色;[完整介绍](OVERVIEW.md)(英文)里有全部截图,取自本地真实运行,不是效果图。
## 怎么串起来
diff --git a/docs/design-language.md b/docs/design-language.md
index bfed86dc..deedad6d 100644
--- a/docs/design-language.md
+++ b/docs/design-language.md
@@ -14,20 +14,22 @@ in `scripts/stack-smoke.sh`. A red design check is the contract talking.
## The shared blocks
-Ten delimited regions, identical in all three files:
+Twelve delimited regions, identical in all three files:
| Block | Delimiters | What it holds |
| --- | --- | --- |
-| Design tokens | `── hookstack design tokens …` / `── end design tokens ──` | Both sets of colours, the mono and UI font stacks, the header controls' own styling |
+| Design tokens | `── hookstack design tokens …` / `── end design tokens ──` | Both sets of colours, depth, glass and the two motion curves, the mono, UI and number font stacks, the header controls' own styling |
| Live control markup | `` … the first `` | The light/dark button, ↻, and the connection indicator |
| Live control script | `── live control …` / `── end live control ──` | One streaming connection, capped reconnect backoff, refetch on wake |
| Token wiring | `── hookstack token wiring …` / `── end token wiring ──` | The one way a page asks for a token and keeps it |
-| Tab shell | `── hookstack tab shell …` / `── end tab shell ──` | The tab strip every page is navigated by |
+| Tab shell | `── hookstack tab shell …` / `── end tab shell ──` | The tab strip every page is navigated by, which is the tab bar on a phone |
| Theme first paint | `── hookstack theme …` / `── end theme ──` | The snippet in `
` that picks light or dark before anything is drawn |
| Theme wiring | `── theme wiring …` / `── end theme wiring ──` | The button, the reader's pick, and a system that changes its mind |
| Components | `── hookstack components …` / `── end components ──` | Everything a board is built from: the top bar, buttons and fields, the status pill, the headline and its numbers, the list row, the drawer, the dialog, the toast |
| Dialogs markup | `── hookstack dialogs …` / `── end dialogs ──` | The one dialog and the toast stack every page asks with |
| Kit script | `── hookstack kit …` / `── end kit ──` | The helpers the components are drawn by: the words in two languages, dates and durations, icons, the dialog, the toast, the clipboard |
+| Install script | `── install on a phone …` / `── end install ──` | The manifest, the icons, the service worker, the browser's bar colour and the safe-area insets |
+| App shell script | `── hookstack app shell …` / `── end app shell ──` | What a phone gets besides: the large title and the header's small one, a group's pages under the title, the search behind its button, the pull that refreshes an installed board |
Copy a block wholesale when changing it, in all three files, in one commit.
@@ -51,7 +53,22 @@ Two rules that keep a board readable: **colour is earned by state**, so a
healthy page is quiet and grey; and `--mono` carries anything a machine wrote
(ids, keys, commands, payloads) while `--ui` carries prose. `"PingFang SC"`
stays in the UI stack on purpose — alert titles arrive in whatever language
-the monitoring system speaks and are rendered here verbatim.
+the monitoring system speaks and are rendered here verbatim. A number a person
+counts (alerts, verdicts, dollars, the tab counts) is set in `--num`, the
+system's rounded face where it has one, with figures of one width so a count
+that changes does not shuffle what is beside it; an id stays in `--mono`.
+
+Since 2026-11-12 the tokens also carry depth and motion, valued per mode like
+the colours. `--elev` lifts a card off the page (a light top edge in the dark, a
+soft shadow on white) and `--elev-up` is the same card under a pointer.
+`--glass` and `--hairline` are the bars a phone keeps on screen — the header and
+the tab bar — frosted, so what scrolls under them stays faintly there.
+`--ease` is the phone's own sheet curve (fast out, long settle) for everything
+that slides, and `--spring` a small overshoot for the tab bar's pill. Colour is
+still earned by state, and the overview's sentence spends it as light: its card
+glows from the corner in the colour of what it says, green when nothing needs
+anyone, amber or red when something does — and then its badge sends out a slow
+ring. Whoever asked the system for less motion gets all of it standing still.
hookprobe's console additionally aliases its older token names
(`--panel`, `--line`, `--dim`, `--green`, `--amber`, `--red`) onto the shared
@@ -64,10 +81,11 @@ reader picks the other one. The tokens carry both sets of values under one set
of names, and `data-theme` on `` says which applies. A snippet in
`` sets it before anything is drawn — the reader's pick if there is one,
otherwise what `prefers-color-scheme` asks for — so a phone in light mode never
-flashes a dark page. The button beside ↻ switches; switching back to what the
-system uses forgets the pick, so the same button is the way back to following
-the system. The pipe's settings drawer offers the three choices by name. The
-pick is kept in the browser under one key, per board, like the tokens.
+flashes a dark page. On a desk the button beside ↻ switches; switching back to
+what the system uses forgets the pick, so the same button is the way back to
+following the system. Every board's settings drawer offers the three choices by
+name, and on a phone it is the way, since the header there leaves the button
+out. The pick is kept in the browser under one key, per board, like the tokens.
Every text colour in the light set clears 4.5:1 on white and on its own 10%
tint, which is how a pill is drawn. The dark set is unchanged.
@@ -108,17 +126,37 @@ console opens what it holds in the tab itself instead of a drawer — a runbook,
a role, a session — because those are worked in, not glanced at: a crumb leads
back, and the sessions keep their list beside the open conversation, which on
a phone becomes one pane at a time, like a mail app. Its only drawer is the
-settings. On a phone the strip itself never slides sideways: it is one row of
-equal cells, the icon over the name, and the console's two groups are one
-cell each, opening the group's first page, while the group of the page on
-screen unfolds its pages as a segmented control under the row. Tables on a
-phone let their headers and status pills wrap, and a table with more columns
-than a phone holds becomes one small block per row: the pipe's channels and
-dead letters, the investigator's flight recorder.
+settings. Tables on a phone let their headers and status pills wrap, and a
+table with more columns than a phone holds becomes one small block per row: the
+pipe's channels and dead letters, the investigator's flight recorder and its
+approvals.
+
+On a phone a board is laid out as an app (since 2026-11-12). The strip is the
+tab bar, pinned to the bottom edge where a thumb already rests, frosted, and it
+never slides sideways: one row of equal cells, the icon over the name, the page
+on screen marked by a soft pill behind its icon, a count that needs somebody a
+filled badge on the icon. The console's two groups are one cell each, opening
+the group's first page; the group's own pages are a segmented control under
+the large title. The bar steps aside while a field is typed in, so it never
+rides the keyboard over a composer. The page opens with its name as a large
+title, and once that has scrolled under the header the header says it in small,
+centred, and draws its bottom line. The header keeps only what a thumb needs:
+the board's name, a search button that opens the search field under it, the
+gear, and the live dot (the word appears when the board is not live) — the
+language, light or dark and ↻ are in the settings drawer, and the console's
+budget chip sits beside them. A dialog is a sheet that rises from the bottom
+edge with answers full width and tall enough for a thumb; a toast drops from
+under the status bar as a capsule. An installed board refreshes when pulled
+down from the top, through the same ↻ a desk presses — a browser tab has its
+own pull and keeps it. A page that is shown fades and rises into place; a live
+refetch redraws inside it and moves nothing. On a desk none of this changes the
+layout: one strip under the header, the controls in the header.
All three boards install on a phone (the pipe's since 2026-10-05, the judge's
and the investigator's since 2026-10-06): a web manifest, icons drawn from
-each favicon's own geometry (`scripts/make_app_icons.py`), and one service
+each favicon's own geometry on a surface lit from above
+(`scripts/make_app_icons.py`; iOS gets the full-bleed one, since it rounds the
+corners itself and fills what is transparent with black), and one service
worker — the same file in all three services, pinned like the blocks above —
that keeps the shell and never the data, so offline a board says its service
is out of reach rather than showing a stale one as current. The settings
@@ -172,8 +210,10 @@ both deployments here — which is why they are settings and not discovered.
The boards do not keep a clock. What they show changes when a service writes,
and the service says so: each page holds one streaming connection (`/live`,
`/v1/live` on the investigator) and refetches *what it is currently looking at*
-when a `changed` arrives. The header keeps ↻ for a manual refetch and shows
-whether the connection is up.
+when a `changed` arrives. The header keeps ↻ for a manual refetch — an
+installed board on a phone is pulled down from the top instead — and shows
+whether the connection is up: a dot that breathes while it is, and stands still,
+amber and named, while it is not.
The signal carries no rows, deliberately. Every board has filters, a window, a
cursor of its own, so "look again" is smaller than pushing rows the viewer may
diff --git a/docs/img/hookstack-phones.png b/docs/img/hookstack-phones.png
index 7be53c1f..10a4568c 100644
Binary files a/docs/img/hookstack-phones.png and b/docs/img/hookstack-phones.png differ
diff --git a/docs/img/zh/hookjudge-status.png b/docs/img/zh/hookjudge-status.png
deleted file mode 100644
index 666ac6da..00000000
Binary files a/docs/img/zh/hookjudge-status.png and /dev/null differ
diff --git a/docs/img/zh/hookprobe-audit.png b/docs/img/zh/hookprobe-audit.png
deleted file mode 100644
index 7847dcaf..00000000
Binary files a/docs/img/zh/hookprobe-audit.png and /dev/null differ
diff --git a/docs/img/zh/hookprobe-sessions.png b/docs/img/zh/hookprobe-sessions.png
deleted file mode 100644
index 4d4a534b..00000000
Binary files a/docs/img/zh/hookprobe-sessions.png and /dev/null differ
diff --git a/docs/img/zh/hookprobe-skills.png b/docs/img/zh/hookprobe-skills.png
deleted file mode 100644
index b04101f0..00000000
Binary files a/docs/img/zh/hookprobe-skills.png and /dev/null differ
diff --git a/docs/img/zh/hookrelay-ledger.png b/docs/img/zh/hookrelay-ledger.png
deleted file mode 100644
index e88cd8c8..00000000
Binary files a/docs/img/zh/hookrelay-ledger.png and /dev/null differ
diff --git a/docs/img/zh/hookrelay-timeline.png b/docs/img/zh/hookrelay-timeline.png
deleted file mode 100644
index bd720e7f..00000000
Binary files a/docs/img/zh/hookrelay-timeline.png and /dev/null differ
diff --git a/docs/img/zh/hookstack-phones.png b/docs/img/zh/hookstack-phones.png
index 046717ec..63f15b9d 100644
Binary files a/docs/img/zh/hookstack-phones.png and b/docs/img/zh/hookstack-phones.png differ
diff --git a/docs/index.md b/docs/index.md
index d4e28150..6f8c829a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -35,9 +35,9 @@ The investigator is the product. The pipe and the judge are the smallest alertin
- **Half a day, for real:** [STACK.md](https://github.com/itswl/hookstack/blob/main/STACK.md) runs all three from source; [docs/containment.md](containment.md) is the boundary map you read *before* pointing it at a cluster.
- **What it does, in full:** [docs/what-you-get.md](what-you-get.md).
-
+
-The three boards read in Chinese or English and follow the system's light or dark; [the full tour](https://github.com/itswl/hookstack/blob/main/OVERVIEW.md) has every screenshot, from local runs and not mockups.
+The three boards install on a phone as apps, read in Chinese or English and follow the system's light or dark; [the full tour](https://github.com/itswl/hookstack/blob/main/OVERVIEW.md) has every screenshot, from local runs and not mockups.
## How it fits together
diff --git a/docs/zh/index.md b/docs/zh/index.md
index e61f7a65..effa23e1 100644
--- a/docs/zh/index.md
+++ b/docs/zh/index.md
@@ -33,9 +33,9 @@ description: 让 agent 值班,但不把钥匙交出去 —— 管道给每条
- **半天,真上:**[STACK.md](https://github.com/itswl/hookstack/blob/main/STACK.md) 从源码一步步跑起三个服务;[docs/containment.md](../containment.md) 是指向真实集群**之前**要读的边界地图。
- **完整功能清单:**[docs/what-you-get.md](../what-you-get.md)(英文)。
-
+
-三块看板都能切换中英文,并跟随系统的浅色或深色;[完整介绍](https://github.com/itswl/hookstack/blob/main/OVERVIEW.md)(英文)里有全部截图,取自本地真实运行,不是效果图。
+三块看板都能装到手机上当 App 用,能切换中英文,并跟随系统的浅色或深色;[完整介绍](https://github.com/itswl/hookstack/blob/main/OVERVIEW.md)(英文)里有全部截图,取自本地真实运行,不是效果图。
## 怎么串起来
diff --git a/hookjudge/hookjudge/static/icon-192.png b/hookjudge/hookjudge/static/icon-192.png
index f3221aa3..4300be66 100644
Binary files a/hookjudge/hookjudge/static/icon-192.png and b/hookjudge/hookjudge/static/icon-192.png differ
diff --git a/hookjudge/hookjudge/static/icon-512.png b/hookjudge/hookjudge/static/icon-512.png
index c3abf39c..8ce45387 100644
Binary files a/hookjudge/hookjudge/static/icon-512.png and b/hookjudge/hookjudge/static/icon-512.png differ
diff --git a/hookjudge/hookjudge/static/icon-maskable-512.png b/hookjudge/hookjudge/static/icon-maskable-512.png
index ffcf781b..aa6c8f72 100644
Binary files a/hookjudge/hookjudge/static/icon-maskable-512.png and b/hookjudge/hookjudge/static/icon-maskable-512.png differ
diff --git a/hookjudge/hookjudge/static/icon.svg b/hookjudge/hookjudge/static/icon.svg
index fdfb35ef..b7182990 100644
--- a/hookjudge/hookjudge/static/icon.svg
+++ b/hookjudge/hookjudge/static/icon.svg
@@ -1 +1 @@
-
+
diff --git a/hookjudge/hookjudge/status.html b/hookjudge/hookjudge/status.html
index cc7c8ff0..04240faf 100644
--- a/hookjudge/hookjudge/status.html
+++ b/hookjudge/hookjudge/status.html
@@ -36,17 +36,31 @@
--text: #d7dde6; --muted: #8b94a3; --accent: #4c8dff;
--ok: #3dd68c; --warn: #f5a524; --bad: #e5484d;
/* The surfaces between those, and the depth under a drawer or a dialog. */
- --surface-2: #161b25; --hover: #19202c; --code: #080b10; --radius: 12px;
+ --surface-2: #161b25; --hover: #19202c; --code: #080b10; --radius: 14px;
--seg-track: #161b25; --seg-pill: #2c3544;
--border-strong: #2c3544; --ring: #3a4352; --well: #0e1219; --code-text: #c3cad6;
--topbar: rgba(11, 14, 20, .9); --scrim: rgba(3, 5, 9, .55); --scrim-strong: rgba(3, 5, 9, .62);
--sheen: rgba(255, 255, 255, .04);
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, .45); --shadow-card: 0 20px 60px rgba(0, 0, 0, .5);
--shadow-toast: 0 10px 30px rgba(0, 0, 0, .4);
+ /* Depth, glass and motion (2026-11-12, when the boards became apps rather
+ than pages): a card sits on the page by a light edge in the dark and a
+ soft shadow in the light; the bars a phone keeps on screen are frosted, so
+ what scrolls under them stays faintly there; and everything that moves
+ eases the way the phone's own sheets do — fast out, long settle. */
+ --elev: inset 0 1px 0 rgba(255, 255, 255, .035);
+ --elev-up: inset 0 1px 0 rgba(255, 255, 255, .05), 0 10px 28px rgba(0, 0, 0, .35);
+ --glass: rgba(13, 17, 24, .82); --hairline: rgba(255, 255, 255, .07);
+ --ease: cubic-bezier(.32, .72, 0, 1); --spring: cubic-bezier(.34, 1.4, .64, 1);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* "PingFang SC" stays in the stack: alert titles arrive in whatever language
the monitoring system speaks, and they are rendered here verbatim. */
--ui: 14px/1.55 -apple-system, "PingFang SC", "Segoe UI", sans-serif;
+ /* A number a person counts (alerts, verdicts, dollars) is set in the
+ system's rounded face where it has one, with figures of one width so a
+ changing count does not shuffle its neighbours; an id or a key a machine
+ wrote stays in --mono. */
+ --num: ui-rounded, "SF Pro Rounded", -apple-system, "PingFang SC", "Segoe UI", sans-serif;
color-scheme: dark;
}
/* The same roles valued for a white page. Which set applies is data-theme on
@@ -64,64 +78,96 @@
--sheen: rgba(15, 23, 42, .05);
--shadow-drawer: -18px 0 48px rgba(15, 23, 42, .14); --shadow-card: 0 18px 50px rgba(15, 23, 42, .18);
--shadow-toast: 0 8px 24px rgba(15, 23, 42, .14);
+ --elev: 0 1px 2px rgba(15, 23, 42, .04), 0 2px 10px rgba(15, 23, 42, .035);
+ --elev-up: 0 2px 4px rgba(15, 23, 42, .05), 0 12px 28px rgba(15, 23, 42, .08);
+ --glass: rgba(250, 251, 253, .84); --hairline: rgba(15, 23, 42, .09);
color-scheme: light;
}
- .rc { display: flex; gap: 6px; align-items: center; }
- .rc button, .rc select { background: var(--surface); border: 1px solid var(--border);
- color: var(--text); border-radius: 6px; padding: 3px 8px; font: inherit; font-size: 12px;
- cursor: pointer; }
+ /* The header's own switches: light or dark, refresh, and whether the board is
+ live — a dot that breathes while the service is streaming to it and stands
+ still, amber and named, when it is not. */
+ .rc { display: flex; gap: 2px; align-items: center; }
+ .rc button, .rc select { background: transparent; border: 0; color: var(--muted); border-radius: 8px;
+ min-width: 30px; height: 30px; padding: 0 7px; font: inherit; font-size: 14px; cursor: pointer;
+ display: inline-grid; place-items: center; transition: background-color .15s, color .15s; }
.rc button svg { display: block; }
- .rc button:hover, .rc select:hover { border-color: var(--accent); }
- .rc .live { font-size: 11px; color: var(--muted); border: 1px solid var(--border);
- border-radius: 10px; padding: 2px 8px; white-space: nowrap; }
- .rc .live.on { color: var(--ok); border-color: var(--ok); }
- .rc .live.off { color: var(--warn); border-color: var(--warn); }
+ .rc button:hover, .rc select:hover { background: var(--surface-2); color: var(--text); }
+ .rc .live { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--muted);
+ padding: 0 6px 0 8px; white-space: nowrap; letter-spacing: .01em; }
+ .rc .live::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
+ .rc .live.on { color: var(--ok); }
+ .rc .live.on::before { animation: live-breathe 2.8s ease-out infinite; }
+ .rc .live.off { color: var(--warn); }
+ @keyframes live-breathe {
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent); }
+ 70%, 100% { box-shadow: 0 0 0 7px color-mix(in srgb, currentColor 0%, transparent); }
+ }
/* ── end design tokens ────────────────────────────────────────────────────── */
/* ── hookstack tab shell · keep this block identical in all three pages ── */
.tabs { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
.tab { background: none; border: 0; color: var(--muted); padding: 8px 12px; cursor: pointer; font: inherit;
- font-size: 13px; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
+ font-size: 13px; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap;
+ transition: color .15s, border-color .2s; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-group { display: flex; align-items: center; gap: 2px; margin-left: 10px; padding-left: 10px; border-left: 1px solid var(--border); }
.tab-group-label { color: var(--muted); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; padding-right: 6px; }
- .tab .count { color: var(--muted); font-family: var(--mono); font-size: 11px; margin-left: 4px; }
+ .tab .count { color: var(--muted); font-family: var(--num); font-variant-numeric: tabular-nums; font-size: 11.5px; margin-left: 4px; }
.tab.active .count { color: var(--accent); }
.tab-group-label [data-icon] { display: none; }
.tab-group-label[data-view] { cursor: pointer; }
- /* On a phone the strip is one row of equal cells, the icon over the name: no
- row sliding sideways under a thumb, which the operator found ugly
- (2026-10-08). A page with groups shows each group as one cell that opens
- the group's first page, and the group of the page on screen unfolds its
- pages as a second row, a segmented control under the first. The line under
- the first row is a pseudo-element so it stays under the first row. */
+ /* The large title and the group's pages, which the app shell draws on a phone
+ (below); a desk has the strip, and neither. */
+ .ltitle, .subseg, .top-title { display: none; }
+ /* On a phone the strip is the app's tab bar (2026-11-12): pinned to the
+ bottom edge where a thumb already rests, frosted, one row of equal cells,
+ the icon over the name, never sliding sideways (the operator found a
+ sliding row ugly, 2026-10-08). The page on screen is marked by a soft pill
+ behind its icon; a count that needs somebody is a filled badge on the icon,
+ any other count a quiet one. A page with groups shows each group as one
+ cell that opens the group's first page; the group's own pages are a
+ segmented control the app shell puts under the large title, where a phone
+ looks for it, rather than a second bar at the bottom. */
@media (max-width: 640px) {
- .wrap > .tabs { flex-wrap: wrap; gap: 0; border-bottom: 0; align-items: stretch; }
- .wrap > .tabs::after { content: ""; order: 1; flex: 0 0 100%; border-bottom: 1px solid var(--border); }
+ .wrap > .tabs { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; margin: 0; flex-wrap: nowrap; gap: 0;
+ align-items: stretch; border: 0; border-top: 1px solid var(--hairline);
+ padding: 5px max(6px, var(--safe-right)) calc(4px + var(--safe-bottom)) max(6px, var(--safe-left));
+ background: var(--glass); -webkit-backdrop-filter: saturate(1.8) blur(22px); backdrop-filter: saturate(1.8) blur(22px); }
.wrap > .tabs > .tab, .wrap > .tabs .tab-group-label {
- display: flex; flex: 1 1 0; min-width: 0; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
- position: relative; margin: 0 0 -1px; padding: 9px 1px 7px; color: var(--muted);
- font-size: clamp(10.5px, 2.8vw, 11.5px); line-height: 1.2; letter-spacing: 0; text-transform: capitalize;
- border-bottom: 2px solid transparent; }
+ display: flex; flex: 1 1 0; min-width: 0; flex-direction: column; align-items: center; justify-content: flex-start; gap: 3px;
+ position: relative; margin: 0; padding: 4px 1px 3px; color: var(--muted); border: 0;
+ font-size: clamp(10px, 2.7vw, 11px); font-weight: 500; line-height: 1.2; letter-spacing: 0; text-transform: capitalize; }
.wrap > .tabs > .tab > [data-t], .wrap > .tabs .tab-group-label > [data-t] {
display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .wrap > .tabs > .tab svg.i, .wrap > .tabs .tab-group-label svg.i { width: 19px; height: 19px; }
- .wrap > .tabs > .tab [data-icon], .wrap > .tabs .tab-group-label [data-icon] { display: inline-flex; }
- .wrap > .tabs > .tab .count { position: absolute; top: 5px; left: calc(50% + 9px); margin: 0; font-size: 10px; line-height: 1; }
- .wrap > .tabs > .tab.active, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label { color: var(--text); border-bottom-color: var(--accent); }
- .wrap > .tabs > .tab.active svg.i, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label svg.i { color: var(--accent); }
+ .wrap > .tabs > .tab [data-icon], .wrap > .tabs .tab-group-label [data-icon] {
+ display: grid; place-items: center; width: 54px; height: 30px; border-radius: 999px; position: relative;
+ transition: background-color .3s var(--ease), transform .35s var(--spring); }
+ .wrap > .tabs > .tab svg.i, .wrap > .tabs .tab-group-label svg.i { width: 22px; height: 22px; stroke-width: 1.7; }
+ .wrap > .tabs > .tab.active, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label { color: var(--text); font-weight: 600; }
+ .wrap > .tabs > .tab.active [data-icon], .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label [data-icon] {
+ background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
+ .wrap > .tabs > .tab.active svg.i, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label svg.i { stroke-width: 2; }
+ .wrap > .tabs > .tab:active [data-icon], .wrap > .tabs .tab-group-label:active [data-icon] { transform: scale(.88); }
+ .wrap > .tabs > .tab .count { position: absolute; top: 1px; left: calc(50% + 7px); margin: 0; min-width: 17px; height: 17px;
+ padding: 0 5px; border-radius: 999px; font-size: 10.5px; font-weight: 650; line-height: 17px; text-align: center;
+ background: var(--surface-2); color: var(--muted); box-shadow: 0 0 0 2px var(--bg); }
+ .wrap > .tabs > .tab .count:empty { display: none; }
+ .wrap > .tabs > .tab .count.warn { background: var(--warn); color: var(--bg); }
+ .wrap > .tabs > .tab .count.bad { background: var(--bad); color: var(--bg); }
+ /* A bar fixed to the bottom edge is in the way of a keyboard: the phone
+ lifts it over the composer the keyboard opened for (why a fixed bar was
+ turned down on 2026-10-06). While a field is typed in, the bar steps
+ aside, as the phone's own apps' bars do, and comes back on the blur. */
+ body:has(:is(input:not([type=checkbox]):not([type=radio]), textarea, select):focus) .wrap > .tabs { display: none; }
.wrap > .tabs .tab-group { display: contents; }
.wrap > .tabs .tab-group > .tab { display: none; }
- .wrap > .tabs .tab-group:has(> .tab.active) > .tab {
- display: flex; order: 2; flex: 1 1 0; min-width: 0; justify-content: center; position: relative;
- margin: 10px 0 0; padding: 7px 4px; border: 0; border-radius: 0;
- background: var(--seg-track); color: var(--muted); font-size: 12.5px; line-height: 1.2; }
- .wrap > .tabs .tab-group > .tab:first-of-type { border-radius: 9px 0 0 9px; }
- .wrap > .tabs .tab-group > .tab:last-of-type { border-radius: 0 9px 9px 0; }
- .wrap > .tabs .tab-group > .tab [data-icon], .wrap > .tabs .tab-group > .tab .count { display: none; }
- .wrap > .tabs .tab-group > .tab > [data-t] { position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .wrap > .tabs .tab-group > .tab.active { color: var(--text); font-weight: 600; }
- .wrap > .tabs .tab-group > .tab.active::before { content: ""; position: absolute; inset: 2px; border-radius: 7px; background: var(--seg-pill); }
+ .view-head h2.echo { display: none; }
+ .ltitle { display: block; margin: 4px 2px 14px; font-size: 31px; font-weight: 750; letter-spacing: -.022em; line-height: 1.12; }
+ .subseg:not(:empty) { display: flex; gap: 2px; margin: -2px 0 16px; padding: 2px; border-radius: 10px; background: var(--seg-track); }
+ .subseg button { flex: 1 1 0; min-width: 0; border: 0; background: transparent; color: var(--muted); padding: 7px 4px;
+ border-radius: 8px; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;
+ transition: background-color .25s var(--ease), color .2s; }
+ .subseg button.on { background: var(--seg-pill); color: var(--text); font-weight: 600; box-shadow: var(--elev); }
}
/* ── end tab shell ──────────────────────────────────────────────────────── */
/* ── hookstack components · keep this block identical in all three pages ── */
@@ -161,11 +207,11 @@
:root { --safe-top: env(safe-area-inset-top); --safe-right: env(safe-area-inset-right); --safe-bottom: env(safe-area-inset-bottom); --safe-left: env(safe-area-inset-left); }
/* Read by the install block: env() is a CSS value, and a padding is the way to a number in script. */
.safe-probe { position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
- .top { position: sticky; top: 0; z-index: 20; padding-top: var(--safe-top); background: var(--topbar); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); }
+ .top { position: sticky; top: 0; z-index: 20; padding-top: var(--safe-top); background: var(--glass); -webkit-backdrop-filter: saturate(1.8) blur(22px); backdrop-filter: saturate(1.8) blur(22px); border-bottom: 1px solid var(--hairline); transition: border-color .25s; }
.top-in { max-width: 1280px; margin: 0 auto; padding: 10px max(24px, var(--safe-right)) 10px max(24px, var(--safe-left)); display: flex; align-items: center; gap: 18px; }
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); white-space: nowrap; }
.brand:hover { text-decoration: none; }
- .brand b { font-size: 16px; letter-spacing: .01em; }
+ .brand b { font-size: 16px; letter-spacing: -.005em; transition: opacity .2s; }
.brand-sub { color: var(--muted); font-size: 12px; }
.logo { width: 24px; height: 24px; flex: 0 0 auto; }
.search { flex: 1 1 auto; max-width: 560px; margin: 0 auto; position: relative; display: flex; align-items: center; }
@@ -177,6 +223,9 @@
kbd { font-family: var(--mono); font-size: 11px; padding: 1px 6px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; color: var(--muted); background: var(--bg); }
.top-right { display: flex; align-items: center; gap: 8px; }
.stamp { color: var(--muted); font-size: 12px; font-family: var(--mono); white-space: nowrap; }
+ /* The phone's search button and the small title the header takes once the
+ large one has scrolled under it: both the app shell's, both phone-only. */
+ .btn.top-search { display: none; }
.wrap { max-width: 1280px; margin: 0 auto; padding: 18px max(24px, var(--safe-right)) 80px max(24px, var(--safe-left)); }
.wrap > .tabs { margin: 0 0 20px; }
@@ -184,13 +233,18 @@
.tab .count.warn { color: var(--warn); }
.tab .count.bad { color: var(--bad); }
.view-head { margin: 0 0 16px; }
- .view-head h2 { margin: 0 0 4px; font-size: 18px; font-weight: 650; }
+ .view-head h2 { margin: 0 0 4px; font-size: 18px; font-weight: 680; letter-spacing: -.012em; }
.view-head p { margin: 0; color: var(--muted); font-size: 13px; }
+ /* A page shown arrives: it rises a few pixels into place as it fades in. Only
+ when a view is SHOWN — a live refetch redraws inside it and moves nothing. */
+ .view:not([hidden]) { animation: view-in .38s var(--ease) both; }
+ @keyframes view-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ── controls ── */
- .btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-size: 13px; white-space: nowrap; text-decoration: none; }
+ .btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; text-decoration: none; transition: background-color .15s, border-color .15s, transform .25s var(--ease); }
+ .btn:active:not([disabled]) { transform: scale(.96); }
.btn:hover { border-color: var(--border-strong); background: var(--surface-2); text-decoration: none; }
- .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 35%, transparent), inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent); }
.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }
.btn.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
@@ -203,21 +257,21 @@
textarea.inp { height: auto; }
.check { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; cursor: pointer; user-select: none; }
.spacer { flex: 1 1 auto; }
- .seg { display: inline-flex; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 3px; gap: 2px; flex-wrap: wrap; }
- .seg button { border: 0; background: transparent; color: var(--muted); padding: 5px 11px; border-radius: 7px; cursor: pointer; font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
+ .seg { display: inline-flex; background: var(--seg-track); border: 0; border-radius: 10px; padding: 3px; gap: 2px; flex-wrap: wrap; }
+ .seg button { border: 0; background: transparent; color: var(--muted); padding: 5px 11px; border-radius: 7px; cursor: pointer; font-size: 13px; display: inline-flex; gap: 6px; align-items: center; transition: background-color .25s var(--ease), color .15s; }
.seg button:hover { color: var(--text); }
- .seg button.on { background: var(--surface-2); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
- .seg .n { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
+ .seg button.on { background: var(--seg-pill); color: var(--text); font-weight: 600; box-shadow: var(--elev), inset 0 0 0 1px var(--hairline); }
+ .seg .n { font-family: var(--num); font-variant-numeric: tabular-nums; font-size: 12px; font-weight: 500; color: var(--muted); }
.seg .n.warn { color: var(--warn); } .seg .n.bad { color: var(--bad); } .seg .n.info { color: var(--accent); } .seg .n.ok { color: var(--ok); }
.chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); font-size: 12.5px; cursor: pointer; }
/* ── status: a word, earned colour ── */
- .pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px 0 8px; border-radius: 999px; font-size: 12px; white-space: nowrap; border: 1px solid var(--border); color: var(--muted); }
+ .pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px 0 8px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; border: 1px solid var(--border); color: var(--muted); }
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
- .pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 10%, transparent); }
- .pill.info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
- .pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 9%, transparent); }
- .pill.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 50%, transparent); background: color-mix(in srgb, var(--bad) 10%, transparent); }
+ .pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 26%, transparent); background: color-mix(in srgb, var(--warn) 10%, transparent); }
+ .pill.info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 26%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
+ .pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 26%, transparent); background: color-mix(in srgb, var(--ok) 9%, transparent); }
+ .pill.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 30%, transparent); background: color-mix(in srgb, var(--bad) 10%, transparent); }
.src { font-family: var(--mono); font-size: 11.5px; padding: 1px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--muted); white-space: nowrap; }
.kind { font-size: 10.5px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--border); color: var(--muted); font-family: var(--mono); }
/* A chip that means something keeps its colour: .kind comes after the text
@@ -226,21 +280,39 @@
/* ── the answer first: one sentence, a chart, a few numbers ── */
.hero { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 14px; margin-bottom: 14px; }
- .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 18px 20px; min-height: 124px; }
- .headline { display: flex; align-items: center; gap: 12px; font-size: 21px; font-weight: 650; letter-spacing: .01em; }
- .headline .badge { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; flex: 0 0 auto; }
+ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 18px 20px; min-height: 124px; box-shadow: var(--elev); }
+ /* The card holding the one sentence glows from its corner in the colour of
+ what it says — colour earned by state, as everywhere, only spent as light
+ rather than ink: calm green when nothing needs anyone, amber or red when
+ something does, and then the badge sends out a slow ring, the way a phone
+ says a call is waiting. */
+ .panel:has(> .headline) { position: relative; isolation: isolate; overflow: hidden; }
+ .panel:has(> .headline)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
+ background: radial-gradient(110% 130% at 0% 0%, color-mix(in srgb, var(--glow, var(--accent)) 15%, transparent), transparent 62%),
+ radial-gradient(60% 90% at 100% 100%, color-mix(in srgb, var(--glow, var(--accent)) 6%, transparent), transparent 70%); }
+ .panel:has(> .headline.ok) { --glow: var(--ok); } .panel:has(> .headline.warn) { --glow: var(--warn); }
+ .panel:has(> .headline.bad) { --glow: var(--bad); } .panel:has(> .headline.info) { --glow: var(--accent); }
+ .panel:has(> .headline.warn), .panel:has(> .headline.bad) { border-color: color-mix(in srgb, var(--glow) 28%, var(--border)); }
+ .headline { display: flex; align-items: center; gap: 13px; font-size: 22px; font-weight: 720; letter-spacing: -.018em; line-height: 1.3; }
+ .headline .badge { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; flex: 0 0 auto; position: relative; }
+ .headline.warn .badge::after, .headline.bad .badge::after { content: ""; position: absolute; inset: 0; border-radius: inherit; animation: beacon 2.8s var(--ease) infinite; }
+ @keyframes beacon {
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 32%, transparent); }
+ 75%, 100% { box-shadow: 0 0 0 13px color-mix(in srgb, currentColor 0%, transparent); }
+ }
.headline .badge svg.i { width: 20px; height: 20px; }
.headline.ok .badge { background: color-mix(in srgb, var(--ok) 15%, transparent); color: var(--ok); }
.headline.warn .badge { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
.headline.bad .badge { background: color-mix(in srgb, var(--bad) 16%, transparent); color: var(--bad); }
.headline.info .badge { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
- .headline-sub { color: var(--muted); margin: 10px 0 0 50px; font-size: 13px; }
- .hero-actions { margin: 12px 0 0 50px; display: flex; gap: 8px; flex-wrap: wrap; }
+ .headline-sub { color: var(--muted); margin: 10px 0 0 53px; font-size: 13px; }
+ .hero-actions { margin: 14px 0 0 53px; display: flex; gap: 8px; flex-wrap: wrap; }
.chart-head { display: flex; justify-content: space-between; align-items: baseline; color: var(--muted); font-size: 12px; }
.chart-head b { color: var(--text); font-weight: 600; font-size: 13px; }
.bars { display: flex; align-items: stretch; gap: 3px; height: 70px; margin-top: 12px; }
.bar { flex: 1 1 0; display: flex; flex-direction: column-reverse; gap: 1px; min-width: 0; border-radius: 2px; }
- .bar i { display: block; border-radius: 2px; flex: 0 0 auto; }
+ .bar i { display: block; border-radius: 3px; flex: 0 0 auto; transform-origin: bottom; animation: grow .6s var(--ease) both; }
+ @keyframes grow { from { transform: scaleY(0); } }
.bar i.bad { background: var(--bad); } .bar i.warn { background: var(--warn); }
.bar i.rest { background: var(--accent); opacity: .8; }
.bar i.quiet { background: var(--muted); opacity: .25; }
@@ -252,19 +324,20 @@
.legend .lb::before { background: var(--bad); } .legend .lw::before { background: var(--warn); }
.legend .la::before { background: var(--accent); } .legend .lq::before { background: var(--muted); opacity: .4; }
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
- .tile { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; cursor: pointer; }
- .tile:hover { border-color: var(--border-strong); background: var(--surface-2); }
+ .tile { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; cursor: pointer; box-shadow: var(--elev); transition: box-shadow .25s var(--ease), transform .25s var(--ease), border-color .2s, background-color .2s; }
+ .tile:active { transform: scale(.975); }
+ @media (hover: hover) { .tile:hover { border-color: var(--border-strong); box-shadow: var(--elev-up); transform: translateY(-1px); } }
.tile .lbl { color: var(--muted); font-size: 12.5px; }
- .tile .val { font-size: 28px; font-weight: 650; font-family: var(--mono); line-height: 1.2; margin-top: 4px; }
+ .tile .val { font-size: 30px; font-weight: 680; font-family: var(--num); font-variant-numeric: tabular-nums; letter-spacing: -.02em; line-height: 1.15; margin-top: 4px; }
.tile .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
- .tile.warn.on { border-color: color-mix(in srgb, var(--warn) 55%, transparent); } .tile.warn.on .val { color: var(--warn); }
+ .tile.warn.on { border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: linear-gradient(160deg, color-mix(in srgb, var(--warn) 7%, var(--surface)), var(--surface) 70%); } .tile.warn.on .val { color: var(--warn); }
.tile.info.on .val { color: var(--accent); }
- .tile.bad.on { border-color: color-mix(in srgb, var(--bad) 55%, transparent); } .tile.bad.on .val { color: var(--bad); }
+ .tile.bad.on { border-color: color-mix(in srgb, var(--bad) 45%, transparent); background: linear-gradient(160deg, color-mix(in srgb, var(--bad) 7%, var(--surface)), var(--surface) 70%); } .tile.bad.on .val { color: var(--bad); }
.tile.ok.on .val { color: var(--ok); }
.tile.active { box-shadow: inset 0 0 0 1px var(--accent); }
.banners { display: grid; gap: 8px; margin-bottom: 14px; }
.banners:empty { display: none; }
- .banner { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); font-size: 13px; }
+ .banner { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--border); background: var(--surface); font-size: 13px; }
.banner .grow { flex: 1 1 auto; min-width: 0; }
.banner.warn { border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: color-mix(in srgb, var(--warn) 7%, var(--surface)); } .banner.warn svg.i { color: var(--warn); }
.banner.bad { border-color: color-mix(in srgb, var(--bad) 45%, transparent); background: color-mix(in srgb, var(--bad) 8%, var(--surface)); } .banner.bad svg.i { color: var(--bad); }
@@ -272,9 +345,10 @@
/* ── lists: one row per item, two lines, grouped by day ── */
.filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 12px; }
- .list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); }
+ .list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); box-shadow: var(--elev); }
.day { padding: 7px 16px; font-size: 12px; color: var(--muted); background: var(--well); border-bottom: 1px solid var(--border-soft); display: flex; justify-content: space-between; }
- .row { display: grid; grid-template-columns: 136px minmax(0, 1fr) auto; grid-template-areas: "st ttl m1" "st sub m2"; gap: 3px 14px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--border-soft); cursor: pointer; position: relative; }
+ .row { display: grid; grid-template-columns: 136px minmax(0, 1fr) auto; grid-template-areas: "st ttl m1" "st sub m2"; gap: 3px 14px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--border-soft); cursor: pointer; position: relative; transition: background-color .15s; }
+ .row:active { background: var(--hover); }
.row:last-child { border-bottom: 0; }
.row:hover { background: var(--hover); }
.row.cur { background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
@@ -286,7 +360,7 @@
.row .m1 { grid-area: m1; display: flex; gap: 10px; align-items: center; justify-content: flex-end; color: var(--muted); font-size: 12px; }
.row .m2 { grid-area: m2; display: flex; gap: 10px; align-items: center; justify-content: flex-end; }
.row .when { font-family: var(--mono); font-size: 12px; }
- .row .cost { color: var(--ok); font-family: var(--mono); font-size: 12px; }
+ .row .cost { color: var(--ok); font-family: var(--num); font-variant-numeric: tabular-nums; font-size: 12.5px; font-weight: 500; }
.row.quiet .ttl { color: var(--muted); font-weight: 500; }
.note { color: var(--muted); font-size: 12px; text-align: center; margin: 12px 0 0; }
.empty { text-align: center; color: var(--muted); padding: 44px 20px; }
@@ -296,9 +370,9 @@
@keyframes sk { from { background-position: 200% 0; } to { background-position: -200% 0; } }
/* ── drawers: one item's whole story, and the settings ── */
- .scrim { position: fixed; inset: 0; background: var(--scrim); opacity: 0; pointer-events: none; transition: opacity .18s; z-index: 30; }
+ .scrim { position: fixed; inset: 0; background: var(--scrim); opacity: 0; pointer-events: none; transition: opacity .3s var(--ease); z-index: 30; }
.scrim.on { opacity: 1; pointer-events: auto; }
- .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(840px, 100vw); background: var(--bg); border-left: 1px solid var(--border); transform: translateX(104%); transition: transform .22s ease; z-index: 31; display: flex; flex-direction: column; }
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(840px, 100vw); background: var(--bg); border-left: 1px solid var(--border); transform: translateX(104%); transition: transform .42s var(--ease); z-index: 31; display: flex; flex-direction: column; }
/* The shadow only while open: parked off-screen, it would still fall across the
page's right edge, invisible on dark and a grey band on white. */
.drawer.on { transform: none; box-shadow: var(--shadow-drawer); }
@@ -338,8 +412,8 @@
.codebox .btn { position: absolute; top: 8px; right: 8px; }
/* ── tables ── */
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
- .card > h3 { margin: 0 0 4px; font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; box-shadow: var(--elev); }
+ .card > h3 { margin: 0 0 4px; font-size: 15px; font-weight: 650; letter-spacing: -.008em; display: flex; align-items: center; gap: 8px; }
.card > .desc { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th { text-align: left; color: var(--muted); font-weight: 500; font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
@@ -349,9 +423,9 @@
.tbl .nw { white-space: nowrap; }
.tbl th.num { text-align: right; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
- .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; }
+ .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--elev); }
.stat .lbl { color: var(--muted); font-size: 12.5px; }
- .stat .val { font-size: 26px; font-weight: 650; font-family: var(--mono); margin-top: 2px; }
+ .stat .val { font-size: 28px; font-weight: 680; font-family: var(--num); font-variant-numeric: tabular-nums; letter-spacing: -.02em; margin-top: 2px; }
.stat.bad.on .val { color: var(--bad); } .stat.warn.on .val { color: var(--warn); } .stat.ok .val { color: var(--ok); }
/* ── editors and forms ── */
@@ -379,16 +453,30 @@
/* ── dialogs and toasts ── */
.modal { position: fixed; inset: 0; display: none; place-items: center; z-index: 40; background: var(--scrim-strong); padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left); }
- .modal.on { display: grid; }
- .modal-card { width: min(460px, calc(100vw - 32px)); max-height: 100%; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow-card); padding: 20px; }
- .modal-card h3 { margin: 0 0 8px; font-size: 16px; }
+ .modal.on { display: grid; animation: fade-in .25s var(--ease) both; }
+ .modal.on .modal-card { animation: pop-in .32s var(--ease) both; }
+ @keyframes fade-in { from { opacity: 0; } }
+ @keyframes pop-in { from { opacity: 0; transform: scale(.95) translateY(6px); } }
+ @keyframes sheet-up { from { transform: translateY(100%); } }
+ .modal-card { width: min(460px, calc(100vw - 32px)); max-height: 100%; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-card); padding: 22px; }
+ .modal-card h3 { margin: 0 0 8px; font-size: 17px; letter-spacing: -.01em; }
.modal-card .body { color: var(--muted); font-size: 13.5px; line-height: 1.6; }
.modal-card .body p { margin: 0 0 8px; }
.modal-card .acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.toasts { position: fixed; right: max(20px, var(--safe-right)); bottom: calc(20px + var(--safe-bottom)); display: grid; gap: 8px; z-index: 50; }
- .toast { display: flex; align-items: center; gap: 10px; min-width: 240px; max-width: 440px; padding: 10px 14px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); box-shadow: var(--shadow-toast); font-size: 13px; cursor: pointer; animation: toast-life 5s ease forwards; }
+ .toast { display: flex; align-items: center; gap: 10px; min-width: 240px; max-width: 440px; padding: 11px 15px; border-radius: 14px; background: var(--glass); -webkit-backdrop-filter: saturate(1.8) blur(22px); backdrop-filter: saturate(1.8) blur(22px); border: 1px solid var(--hairline); box-shadow: var(--shadow-toast); font-size: 13px; font-weight: 500; cursor: pointer; animation: toast-life 5s var(--ease) forwards; }
.toast.ok svg.i { color: var(--ok); } .toast.bad svg.i { color: var(--bad); }
- @keyframes toast-life { 0% { opacity: 0; transform: translateY(8px); } 6% { opacity: 1; transform: none; } 88% { opacity: 1; } 100% { opacity: 0; transform: translateY(4px); } }
+ @keyframes toast-life { 0% { opacity: 0; transform: translateY(10px) scale(.96); } 7% { opacity: 1; transform: none; } 88% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translateY(4px) scale(.98); } }
+ @keyframes toast-drop { 0% { opacity: 0; transform: translateY(-18px) scale(.9); } 8% { opacity: 1; transform: none; } 88% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translateY(-10px) scale(.96); } }
+ /* The pull that refreshes an installed board (the app shell): a ring that
+ turns as it is pulled, and spins once when let go far enough. */
+ .ptr { position: fixed; left: 50%; top: 0; z-index: 19; width: 34px; height: 34px; margin-left: -17px; border-radius: 50%;
+ display: grid; place-items: center; color: var(--muted); background: var(--surface); box-shadow: var(--shadow-toast);
+ opacity: 0; pointer-events: none; transform: translateY(calc(var(--pull, 0) * 1px - 40px)) rotate(calc(var(--pull, 0) * 5deg)); }
+ .ptr.armed { color: var(--accent); }
+ .ptr.spin { animation: ptr-spin .7s var(--ease) both; }
+ .ptr.back { transition: transform .3s var(--ease), opacity .2s; }
+ @keyframes ptr-spin { from { opacity: 1; transform: translateY(24px) rotate(0); } 80% { opacity: 1; } to { opacity: 0; transform: translateY(24px) rotate(360deg); } }
/* The prompt the token wiring reveals on a 401, as a card that says what to type. */
#tokenbar.show { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(180px, 280px) auto; gap: 12px; align-items: center; padding: 14px 16px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 7%, var(--surface)); }
@@ -409,11 +497,48 @@
.brand-sub { display: none; }
}
@media (max-width: 640px) {
- .top-in, .wrap { padding-left: max(12px, var(--safe-left)); padding-right: max(12px, var(--safe-right)); }
- .top-in { flex-wrap: wrap; gap: 10px; }
- .search { order: 3; flex: 1 1 100%; max-width: none; margin: 0; }
- .top-right { margin-left: auto; }
- .stamp, .search kbd { display: none; }
+ .top-in, .wrap { padding-left: max(14px, var(--safe-left)); padding-right: max(14px, var(--safe-right)); }
+ /* The header is the app's: who this is on the left; on the right only what a
+ thumb needs — search, settings, and the live dot (the word appears when the
+ board is NOT live). Language, light or dark and refresh are in the settings
+ drawer already, and an installed board refreshes by being pulled. The
+ border appears once something has scrolled under the header. */
+ .top { border-bottom-color: transparent; }
+ .top.titled { border-bottom-color: var(--hairline); }
+ .top-in { flex-wrap: wrap; gap: 4px 6px; min-height: 50px; padding-top: 7px; padding-bottom: 7px; position: relative; }
+ .brand b { font-size: 17px; }
+ .search { display: none; order: 3; flex: 1 1 100%; max-width: none; margin: 2px 0 1px; }
+ .top.find .search { display: flex; }
+ .btn.top-search { display: inline-flex; }
+ .top-right { margin-left: auto; gap: 2px; }
+ .top-right .btn.ghost { width: 34px; height: 34px; padding: 0; justify-content: center; border-radius: 10px; }
+ .top-right .btn.ghost svg.i { width: 19px; height: 19px; }
+ #langBtn, #rTheme, #rNow, .stamp { display: none; }
+ .rc .live.on { font-size: 0; gap: 0; padding: 0 10px; }
+ .top-title { display: block; position: absolute; left: 50%; top: 0; height: 50px; line-height: 50px; max-width: 52%;
+ transform: translateX(-50%); font-size: 16px; font-weight: 680; letter-spacing: -.01em; opacity: 0; pointer-events: none;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: opacity .2s; }
+ .top.titled .top-title { opacity: 1; }
+ .top.titled .brand b { opacity: 0; }
+ .wrap { padding-top: 12px; padding-bottom: calc(40px + var(--tabbar-h)); }
+ /* The strip's margin under it on a desk would lift the tab bar off the bottom edge. */
+ .wrap > .tabs { margin: 0; }
+ .panel { border-radius: 18px; }
+ .headline { font-size: 21px; }
+ .headline-sub, .hero-actions { margin-left: 0; }
+ .tile .val { font-size: 32px; }
+ /* A dialog is a sheet that rises from the bottom edge, its answers full
+ width and tall enough for a thumb; a toast drops from under the status
+ bar as a capsule, where an eye already is, clear of the tab bar. */
+ .modal { place-items: end stretch; padding: var(--safe-top) 0 0; }
+ .modal-card { width: 100%; max-height: 88vh; border-radius: 22px 22px 0 0; border-bottom: 0; padding: 24px max(20px, var(--safe-right)) calc(18px + var(--safe-bottom)) max(20px, var(--safe-left)); }
+ .modal.on .modal-card { animation: sheet-up .44s var(--ease) both; }
+ .modal-card .acts { flex-direction: column-reverse; gap: 8px; }
+ .modal-card .acts .btn { height: 46px; justify-content: center; font-size: 15.5px; border-radius: 13px; }
+ .toasts { left: max(12px, var(--safe-left)); right: max(12px, var(--safe-right)); top: calc(8px + var(--safe-top)); bottom: auto; justify-items: center; }
+ .toast { min-width: 0; max-width: 100%; border-radius: 999px; padding: 11px 18px; animation-name: toast-drop; }
+ .search kbd { display: none; }
+ .seg button { padding: 5px 8px; gap: 5px; }
/* iOS zooms the page in to a field whose text is under 16px when it takes
focus, and does not zoom back out: on a phone every field types at 16px,
over whatever size its component gives it on a desk. */
@@ -432,7 +557,14 @@
.hp th, .hp td { padding: 6px 7px; overflow-wrap: anywhere; }
.tbl td .pill, .hp td .pill { white-space: normal; height: auto; min-height: 22px; padding: 2px 9px 2px 8px; line-height: 1.35; border-radius: 11px; }
#tokenbar.show { grid-template-columns: 1fr; }
- .headline-sub, .hero-actions { margin-left: 0; }
+ }
+ :root { --tabbar-h: 0px; }
+ @media (max-width: 640px) { :root { --tabbar-h: calc(64px + var(--safe-bottom)); } }
+ /* Whoever asked the system for less motion gets the same boards standing
+ still: no rising, glowing, breathing or springing. A toast keeps its life,
+ which is how long it stays, not how it moves. */
+ @media (prefers-reduced-motion: reduce) {
+ *:not(.toast), *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
/* ── end components ─────────────────────────────────────────────────────── */
/* ── hookstack token bar · keep this block identical in all three pages ── */
@@ -666,6 +798,7 @@
The knobs that matter
diff --git a/hookprobe/hookprobe/static/icon-192.png b/hookprobe/hookprobe/static/icon-192.png
index 90568b35..2183721c 100644
Binary files a/hookprobe/hookprobe/static/icon-192.png and b/hookprobe/hookprobe/static/icon-192.png differ
diff --git a/hookprobe/hookprobe/static/icon-512.png b/hookprobe/hookprobe/static/icon-512.png
index 3df0f693..5a3b15db 100644
Binary files a/hookprobe/hookprobe/static/icon-512.png and b/hookprobe/hookprobe/static/icon-512.png differ
diff --git a/hookprobe/hookprobe/static/icon-maskable-512.png b/hookprobe/hookprobe/static/icon-maskable-512.png
index b0b6d251..66bbc6e7 100644
Binary files a/hookprobe/hookprobe/static/icon-maskable-512.png and b/hookprobe/hookprobe/static/icon-maskable-512.png differ
diff --git a/hookprobe/hookprobe/static/icon.svg b/hookprobe/hookprobe/static/icon.svg
index a208d0e3..17834bcf 100644
--- a/hookprobe/hookprobe/static/icon.svg
+++ b/hookprobe/hookprobe/static/icon.svg
@@ -1 +1 @@
-
+
diff --git a/hookprobe/hookprobe/ui.html b/hookprobe/hookprobe/ui.html
index 664446d2..225ba86a 100644
--- a/hookprobe/hookprobe/ui.html
+++ b/hookprobe/hookprobe/ui.html
@@ -38,17 +38,31 @@
--text: #d7dde6; --muted: #8b94a3; --accent: #4c8dff;
--ok: #3dd68c; --warn: #f5a524; --bad: #e5484d;
/* The surfaces between those, and the depth under a drawer or a dialog. */
- --surface-2: #161b25; --hover: #19202c; --code: #080b10; --radius: 12px;
+ --surface-2: #161b25; --hover: #19202c; --code: #080b10; --radius: 14px;
--seg-track: #161b25; --seg-pill: #2c3544;
--border-strong: #2c3544; --ring: #3a4352; --well: #0e1219; --code-text: #c3cad6;
--topbar: rgba(11, 14, 20, .9); --scrim: rgba(3, 5, 9, .55); --scrim-strong: rgba(3, 5, 9, .62);
--sheen: rgba(255, 255, 255, .04);
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, .45); --shadow-card: 0 20px 60px rgba(0, 0, 0, .5);
--shadow-toast: 0 10px 30px rgba(0, 0, 0, .4);
+ /* Depth, glass and motion (2026-11-12, when the boards became apps rather
+ than pages): a card sits on the page by a light edge in the dark and a
+ soft shadow in the light; the bars a phone keeps on screen are frosted, so
+ what scrolls under them stays faintly there; and everything that moves
+ eases the way the phone's own sheets do — fast out, long settle. */
+ --elev: inset 0 1px 0 rgba(255, 255, 255, .035);
+ --elev-up: inset 0 1px 0 rgba(255, 255, 255, .05), 0 10px 28px rgba(0, 0, 0, .35);
+ --glass: rgba(13, 17, 24, .82); --hairline: rgba(255, 255, 255, .07);
+ --ease: cubic-bezier(.32, .72, 0, 1); --spring: cubic-bezier(.34, 1.4, .64, 1);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* "PingFang SC" stays in the stack: alert titles arrive in whatever language
the monitoring system speaks, and they are rendered here verbatim. */
--ui: 14px/1.55 -apple-system, "PingFang SC", "Segoe UI", sans-serif;
+ /* A number a person counts (alerts, verdicts, dollars) is set in the
+ system's rounded face where it has one, with figures of one width so a
+ changing count does not shuffle its neighbours; an id or a key a machine
+ wrote stays in --mono. */
+ --num: ui-rounded, "SF Pro Rounded", -apple-system, "PingFang SC", "Segoe UI", sans-serif;
color-scheme: dark;
}
/* The same roles valued for a white page. Which set applies is data-theme on
@@ -66,64 +80,96 @@
--sheen: rgba(15, 23, 42, .05);
--shadow-drawer: -18px 0 48px rgba(15, 23, 42, .14); --shadow-card: 0 18px 50px rgba(15, 23, 42, .18);
--shadow-toast: 0 8px 24px rgba(15, 23, 42, .14);
+ --elev: 0 1px 2px rgba(15, 23, 42, .04), 0 2px 10px rgba(15, 23, 42, .035);
+ --elev-up: 0 2px 4px rgba(15, 23, 42, .05), 0 12px 28px rgba(15, 23, 42, .08);
+ --glass: rgba(250, 251, 253, .84); --hairline: rgba(15, 23, 42, .09);
color-scheme: light;
}
- .rc { display: flex; gap: 6px; align-items: center; }
- .rc button, .rc select { background: var(--surface); border: 1px solid var(--border);
- color: var(--text); border-radius: 6px; padding: 3px 8px; font: inherit; font-size: 12px;
- cursor: pointer; }
+ /* The header's own switches: light or dark, refresh, and whether the board is
+ live — a dot that breathes while the service is streaming to it and stands
+ still, amber and named, when it is not. */
+ .rc { display: flex; gap: 2px; align-items: center; }
+ .rc button, .rc select { background: transparent; border: 0; color: var(--muted); border-radius: 8px;
+ min-width: 30px; height: 30px; padding: 0 7px; font: inherit; font-size: 14px; cursor: pointer;
+ display: inline-grid; place-items: center; transition: background-color .15s, color .15s; }
.rc button svg { display: block; }
- .rc button:hover, .rc select:hover { border-color: var(--accent); }
- .rc .live { font-size: 11px; color: var(--muted); border: 1px solid var(--border);
- border-radius: 10px; padding: 2px 8px; white-space: nowrap; }
- .rc .live.on { color: var(--ok); border-color: var(--ok); }
- .rc .live.off { color: var(--warn); border-color: var(--warn); }
+ .rc button:hover, .rc select:hover { background: var(--surface-2); color: var(--text); }
+ .rc .live { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--muted);
+ padding: 0 6px 0 8px; white-space: nowrap; letter-spacing: .01em; }
+ .rc .live::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
+ .rc .live.on { color: var(--ok); }
+ .rc .live.on::before { animation: live-breathe 2.8s ease-out infinite; }
+ .rc .live.off { color: var(--warn); }
+ @keyframes live-breathe {
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent); }
+ 70%, 100% { box-shadow: 0 0 0 7px color-mix(in srgb, currentColor 0%, transparent); }
+ }
/* ── end design tokens ────────────────────────────────────────────────────── */
/* ── hookstack tab shell · keep this block identical in all three pages ── */
.tabs { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
.tab { background: none; border: 0; color: var(--muted); padding: 8px 12px; cursor: pointer; font: inherit;
- font-size: 13px; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
+ font-size: 13px; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap;
+ transition: color .15s, border-color .2s; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-group { display: flex; align-items: center; gap: 2px; margin-left: 10px; padding-left: 10px; border-left: 1px solid var(--border); }
.tab-group-label { color: var(--muted); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; padding-right: 6px; }
- .tab .count { color: var(--muted); font-family: var(--mono); font-size: 11px; margin-left: 4px; }
+ .tab .count { color: var(--muted); font-family: var(--num); font-variant-numeric: tabular-nums; font-size: 11.5px; margin-left: 4px; }
.tab.active .count { color: var(--accent); }
.tab-group-label [data-icon] { display: none; }
.tab-group-label[data-view] { cursor: pointer; }
- /* On a phone the strip is one row of equal cells, the icon over the name: no
- row sliding sideways under a thumb, which the operator found ugly
- (2026-10-08). A page with groups shows each group as one cell that opens
- the group's first page, and the group of the page on screen unfolds its
- pages as a second row, a segmented control under the first. The line under
- the first row is a pseudo-element so it stays under the first row. */
+ /* The large title and the group's pages, which the app shell draws on a phone
+ (below); a desk has the strip, and neither. */
+ .ltitle, .subseg, .top-title { display: none; }
+ /* On a phone the strip is the app's tab bar (2026-11-12): pinned to the
+ bottom edge where a thumb already rests, frosted, one row of equal cells,
+ the icon over the name, never sliding sideways (the operator found a
+ sliding row ugly, 2026-10-08). The page on screen is marked by a soft pill
+ behind its icon; a count that needs somebody is a filled badge on the icon,
+ any other count a quiet one. A page with groups shows each group as one
+ cell that opens the group's first page; the group's own pages are a
+ segmented control the app shell puts under the large title, where a phone
+ looks for it, rather than a second bar at the bottom. */
@media (max-width: 640px) {
- .wrap > .tabs { flex-wrap: wrap; gap: 0; border-bottom: 0; align-items: stretch; }
- .wrap > .tabs::after { content: ""; order: 1; flex: 0 0 100%; border-bottom: 1px solid var(--border); }
+ .wrap > .tabs { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; margin: 0; flex-wrap: nowrap; gap: 0;
+ align-items: stretch; border: 0; border-top: 1px solid var(--hairline);
+ padding: 5px max(6px, var(--safe-right)) calc(4px + var(--safe-bottom)) max(6px, var(--safe-left));
+ background: var(--glass); -webkit-backdrop-filter: saturate(1.8) blur(22px); backdrop-filter: saturate(1.8) blur(22px); }
.wrap > .tabs > .tab, .wrap > .tabs .tab-group-label {
- display: flex; flex: 1 1 0; min-width: 0; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
- position: relative; margin: 0 0 -1px; padding: 9px 1px 7px; color: var(--muted);
- font-size: clamp(10.5px, 2.8vw, 11.5px); line-height: 1.2; letter-spacing: 0; text-transform: capitalize;
- border-bottom: 2px solid transparent; }
+ display: flex; flex: 1 1 0; min-width: 0; flex-direction: column; align-items: center; justify-content: flex-start; gap: 3px;
+ position: relative; margin: 0; padding: 4px 1px 3px; color: var(--muted); border: 0;
+ font-size: clamp(10px, 2.7vw, 11px); font-weight: 500; line-height: 1.2; letter-spacing: 0; text-transform: capitalize; }
.wrap > .tabs > .tab > [data-t], .wrap > .tabs .tab-group-label > [data-t] {
display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .wrap > .tabs > .tab svg.i, .wrap > .tabs .tab-group-label svg.i { width: 19px; height: 19px; }
- .wrap > .tabs > .tab [data-icon], .wrap > .tabs .tab-group-label [data-icon] { display: inline-flex; }
- .wrap > .tabs > .tab .count { position: absolute; top: 5px; left: calc(50% + 9px); margin: 0; font-size: 10px; line-height: 1; }
- .wrap > .tabs > .tab.active, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label { color: var(--text); border-bottom-color: var(--accent); }
- .wrap > .tabs > .tab.active svg.i, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label svg.i { color: var(--accent); }
+ .wrap > .tabs > .tab [data-icon], .wrap > .tabs .tab-group-label [data-icon] {
+ display: grid; place-items: center; width: 54px; height: 30px; border-radius: 999px; position: relative;
+ transition: background-color .3s var(--ease), transform .35s var(--spring); }
+ .wrap > .tabs > .tab svg.i, .wrap > .tabs .tab-group-label svg.i { width: 22px; height: 22px; stroke-width: 1.7; }
+ .wrap > .tabs > .tab.active, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label { color: var(--text); font-weight: 600; }
+ .wrap > .tabs > .tab.active [data-icon], .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label [data-icon] {
+ background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
+ .wrap > .tabs > .tab.active svg.i, .wrap > .tabs .tab-group:has(> .tab.active) > .tab-group-label svg.i { stroke-width: 2; }
+ .wrap > .tabs > .tab:active [data-icon], .wrap > .tabs .tab-group-label:active [data-icon] { transform: scale(.88); }
+ .wrap > .tabs > .tab .count { position: absolute; top: 1px; left: calc(50% + 7px); margin: 0; min-width: 17px; height: 17px;
+ padding: 0 5px; border-radius: 999px; font-size: 10.5px; font-weight: 650; line-height: 17px; text-align: center;
+ background: var(--surface-2); color: var(--muted); box-shadow: 0 0 0 2px var(--bg); }
+ .wrap > .tabs > .tab .count:empty { display: none; }
+ .wrap > .tabs > .tab .count.warn { background: var(--warn); color: var(--bg); }
+ .wrap > .tabs > .tab .count.bad { background: var(--bad); color: var(--bg); }
+ /* A bar fixed to the bottom edge is in the way of a keyboard: the phone
+ lifts it over the composer the keyboard opened for (why a fixed bar was
+ turned down on 2026-10-06). While a field is typed in, the bar steps
+ aside, as the phone's own apps' bars do, and comes back on the blur. */
+ body:has(:is(input:not([type=checkbox]):not([type=radio]), textarea, select):focus) .wrap > .tabs { display: none; }
.wrap > .tabs .tab-group { display: contents; }
.wrap > .tabs .tab-group > .tab { display: none; }
- .wrap > .tabs .tab-group:has(> .tab.active) > .tab {
- display: flex; order: 2; flex: 1 1 0; min-width: 0; justify-content: center; position: relative;
- margin: 10px 0 0; padding: 7px 4px; border: 0; border-radius: 0;
- background: var(--seg-track); color: var(--muted); font-size: 12.5px; line-height: 1.2; }
- .wrap > .tabs .tab-group > .tab:first-of-type { border-radius: 9px 0 0 9px; }
- .wrap > .tabs .tab-group > .tab:last-of-type { border-radius: 0 9px 9px 0; }
- .wrap > .tabs .tab-group > .tab [data-icon], .wrap > .tabs .tab-group > .tab .count { display: none; }
- .wrap > .tabs .tab-group > .tab > [data-t] { position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .wrap > .tabs .tab-group > .tab.active { color: var(--text); font-weight: 600; }
- .wrap > .tabs .tab-group > .tab.active::before { content: ""; position: absolute; inset: 2px; border-radius: 7px; background: var(--seg-pill); }
+ .view-head h2.echo { display: none; }
+ .ltitle { display: block; margin: 4px 2px 14px; font-size: 31px; font-weight: 750; letter-spacing: -.022em; line-height: 1.12; }
+ .subseg:not(:empty) { display: flex; gap: 2px; margin: -2px 0 16px; padding: 2px; border-radius: 10px; background: var(--seg-track); }
+ .subseg button { flex: 1 1 0; min-width: 0; border: 0; background: transparent; color: var(--muted); padding: 7px 4px;
+ border-radius: 8px; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;
+ transition: background-color .25s var(--ease), color .2s; }
+ .subseg button.on { background: var(--seg-pill); color: var(--text); font-weight: 600; box-shadow: var(--elev); }
}
/* ── end tab shell ──────────────────────────────────────────────────────── */
/* ── hookstack components · keep this block identical in all three pages ── */
@@ -163,11 +209,11 @@
:root { --safe-top: env(safe-area-inset-top); --safe-right: env(safe-area-inset-right); --safe-bottom: env(safe-area-inset-bottom); --safe-left: env(safe-area-inset-left); }
/* Read by the install block: env() is a CSS value, and a padding is the way to a number in script. */
.safe-probe { position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
- .top { position: sticky; top: 0; z-index: 20; padding-top: var(--safe-top); background: var(--topbar); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); }
+ .top { position: sticky; top: 0; z-index: 20; padding-top: var(--safe-top); background: var(--glass); -webkit-backdrop-filter: saturate(1.8) blur(22px); backdrop-filter: saturate(1.8) blur(22px); border-bottom: 1px solid var(--hairline); transition: border-color .25s; }
.top-in { max-width: 1280px; margin: 0 auto; padding: 10px max(24px, var(--safe-right)) 10px max(24px, var(--safe-left)); display: flex; align-items: center; gap: 18px; }
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); white-space: nowrap; }
.brand:hover { text-decoration: none; }
- .brand b { font-size: 16px; letter-spacing: .01em; }
+ .brand b { font-size: 16px; letter-spacing: -.005em; transition: opacity .2s; }
.brand-sub { color: var(--muted); font-size: 12px; }
.logo { width: 24px; height: 24px; flex: 0 0 auto; }
.search { flex: 1 1 auto; max-width: 560px; margin: 0 auto; position: relative; display: flex; align-items: center; }
@@ -179,6 +225,9 @@
kbd { font-family: var(--mono); font-size: 11px; padding: 1px 6px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; color: var(--muted); background: var(--bg); }
.top-right { display: flex; align-items: center; gap: 8px; }
.stamp { color: var(--muted); font-size: 12px; font-family: var(--mono); white-space: nowrap; }
+ /* The phone's search button and the small title the header takes once the
+ large one has scrolled under it: both the app shell's, both phone-only. */
+ .btn.top-search { display: none; }
.wrap { max-width: 1280px; margin: 0 auto; padding: 18px max(24px, var(--safe-right)) 80px max(24px, var(--safe-left)); }
.wrap > .tabs { margin: 0 0 20px; }
@@ -186,13 +235,18 @@
.tab .count.warn { color: var(--warn); }
.tab .count.bad { color: var(--bad); }
.view-head { margin: 0 0 16px; }
- .view-head h2 { margin: 0 0 4px; font-size: 18px; font-weight: 650; }
+ .view-head h2 { margin: 0 0 4px; font-size: 18px; font-weight: 680; letter-spacing: -.012em; }
.view-head p { margin: 0; color: var(--muted); font-size: 13px; }
+ /* A page shown arrives: it rises a few pixels into place as it fades in. Only
+ when a view is SHOWN — a live refetch redraws inside it and moves nothing. */
+ .view:not([hidden]) { animation: view-in .38s var(--ease) both; }
+ @keyframes view-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ── controls ── */
- .btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-size: 13px; white-space: nowrap; text-decoration: none; }
+ .btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; text-decoration: none; transition: background-color .15s, border-color .15s, transform .25s var(--ease); }
+ .btn:active:not([disabled]) { transform: scale(.96); }
.btn:hover { border-color: var(--border-strong); background: var(--surface-2); text-decoration: none; }
- .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 35%, transparent), inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent); }
.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }
.btn.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
@@ -205,21 +259,21 @@
textarea.inp { height: auto; }
.check { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; cursor: pointer; user-select: none; }
.spacer { flex: 1 1 auto; }
- .seg { display: inline-flex; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 3px; gap: 2px; flex-wrap: wrap; }
- .seg button { border: 0; background: transparent; color: var(--muted); padding: 5px 11px; border-radius: 7px; cursor: pointer; font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
+ .seg { display: inline-flex; background: var(--seg-track); border: 0; border-radius: 10px; padding: 3px; gap: 2px; flex-wrap: wrap; }
+ .seg button { border: 0; background: transparent; color: var(--muted); padding: 5px 11px; border-radius: 7px; cursor: pointer; font-size: 13px; display: inline-flex; gap: 6px; align-items: center; transition: background-color .25s var(--ease), color .15s; }
.seg button:hover { color: var(--text); }
- .seg button.on { background: var(--surface-2); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
- .seg .n { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
+ .seg button.on { background: var(--seg-pill); color: var(--text); font-weight: 600; box-shadow: var(--elev), inset 0 0 0 1px var(--hairline); }
+ .seg .n { font-family: var(--num); font-variant-numeric: tabular-nums; font-size: 12px; font-weight: 500; color: var(--muted); }
.seg .n.warn { color: var(--warn); } .seg .n.bad { color: var(--bad); } .seg .n.info { color: var(--accent); } .seg .n.ok { color: var(--ok); }
.chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); font-size: 12.5px; cursor: pointer; }
/* ── status: a word, earned colour ── */
- .pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px 0 8px; border-radius: 999px; font-size: 12px; white-space: nowrap; border: 1px solid var(--border); color: var(--muted); }
+ .pill { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px 0 8px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; border: 1px solid var(--border); color: var(--muted); }
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
- .pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 10%, transparent); }
- .pill.info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
- .pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); background: color-mix(in srgb, var(--ok) 9%, transparent); }
- .pill.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 50%, transparent); background: color-mix(in srgb, var(--bad) 10%, transparent); }
+ .pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 26%, transparent); background: color-mix(in srgb, var(--warn) 10%, transparent); }
+ .pill.info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 26%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
+ .pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 26%, transparent); background: color-mix(in srgb, var(--ok) 9%, transparent); }
+ .pill.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 30%, transparent); background: color-mix(in srgb, var(--bad) 10%, transparent); }
.src { font-family: var(--mono); font-size: 11.5px; padding: 1px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--muted); white-space: nowrap; }
.kind { font-size: 10.5px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--border); color: var(--muted); font-family: var(--mono); }
/* A chip that means something keeps its colour: .kind comes after the text
@@ -228,21 +282,39 @@
/* ── the answer first: one sentence, a chart, a few numbers ── */
.hero { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 14px; margin-bottom: 14px; }
- .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 18px 20px; min-height: 124px; }
- .headline { display: flex; align-items: center; gap: 12px; font-size: 21px; font-weight: 650; letter-spacing: .01em; }
- .headline .badge { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; flex: 0 0 auto; }
+ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 18px 20px; min-height: 124px; box-shadow: var(--elev); }
+ /* The card holding the one sentence glows from its corner in the colour of
+ what it says — colour earned by state, as everywhere, only spent as light
+ rather than ink: calm green when nothing needs anyone, amber or red when
+ something does, and then the badge sends out a slow ring, the way a phone
+ says a call is waiting. */
+ .panel:has(> .headline) { position: relative; isolation: isolate; overflow: hidden; }
+ .panel:has(> .headline)::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
+ background: radial-gradient(110% 130% at 0% 0%, color-mix(in srgb, var(--glow, var(--accent)) 15%, transparent), transparent 62%),
+ radial-gradient(60% 90% at 100% 100%, color-mix(in srgb, var(--glow, var(--accent)) 6%, transparent), transparent 70%); }
+ .panel:has(> .headline.ok) { --glow: var(--ok); } .panel:has(> .headline.warn) { --glow: var(--warn); }
+ .panel:has(> .headline.bad) { --glow: var(--bad); } .panel:has(> .headline.info) { --glow: var(--accent); }
+ .panel:has(> .headline.warn), .panel:has(> .headline.bad) { border-color: color-mix(in srgb, var(--glow) 28%, var(--border)); }
+ .headline { display: flex; align-items: center; gap: 13px; font-size: 22px; font-weight: 720; letter-spacing: -.018em; line-height: 1.3; }
+ .headline .badge { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; flex: 0 0 auto; position: relative; }
+ .headline.warn .badge::after, .headline.bad .badge::after { content: ""; position: absolute; inset: 0; border-radius: inherit; animation: beacon 2.8s var(--ease) infinite; }
+ @keyframes beacon {
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 32%, transparent); }
+ 75%, 100% { box-shadow: 0 0 0 13px color-mix(in srgb, currentColor 0%, transparent); }
+ }
.headline .badge svg.i { width: 20px; height: 20px; }
.headline.ok .badge { background: color-mix(in srgb, var(--ok) 15%, transparent); color: var(--ok); }
.headline.warn .badge { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
.headline.bad .badge { background: color-mix(in srgb, var(--bad) 16%, transparent); color: var(--bad); }
.headline.info .badge { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
- .headline-sub { color: var(--muted); margin: 10px 0 0 50px; font-size: 13px; }
- .hero-actions { margin: 12px 0 0 50px; display: flex; gap: 8px; flex-wrap: wrap; }
+ .headline-sub { color: var(--muted); margin: 10px 0 0 53px; font-size: 13px; }
+ .hero-actions { margin: 14px 0 0 53px; display: flex; gap: 8px; flex-wrap: wrap; }
.chart-head { display: flex; justify-content: space-between; align-items: baseline; color: var(--muted); font-size: 12px; }
.chart-head b { color: var(--text); font-weight: 600; font-size: 13px; }
.bars { display: flex; align-items: stretch; gap: 3px; height: 70px; margin-top: 12px; }
.bar { flex: 1 1 0; display: flex; flex-direction: column-reverse; gap: 1px; min-width: 0; border-radius: 2px; }
- .bar i { display: block; border-radius: 2px; flex: 0 0 auto; }
+ .bar i { display: block; border-radius: 3px; flex: 0 0 auto; transform-origin: bottom; animation: grow .6s var(--ease) both; }
+ @keyframes grow { from { transform: scaleY(0); } }
.bar i.bad { background: var(--bad); } .bar i.warn { background: var(--warn); }
.bar i.rest { background: var(--accent); opacity: .8; }
.bar i.quiet { background: var(--muted); opacity: .25; }
@@ -254,19 +326,20 @@
.legend .lb::before { background: var(--bad); } .legend .lw::before { background: var(--warn); }
.legend .la::before { background: var(--accent); } .legend .lq::before { background: var(--muted); opacity: .4; }
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
- .tile { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; cursor: pointer; }
- .tile:hover { border-color: var(--border-strong); background: var(--surface-2); }
+ .tile { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; cursor: pointer; box-shadow: var(--elev); transition: box-shadow .25s var(--ease), transform .25s var(--ease), border-color .2s, background-color .2s; }
+ .tile:active { transform: scale(.975); }
+ @media (hover: hover) { .tile:hover { border-color: var(--border-strong); box-shadow: var(--elev-up); transform: translateY(-1px); } }
.tile .lbl { color: var(--muted); font-size: 12.5px; }
- .tile .val { font-size: 28px; font-weight: 650; font-family: var(--mono); line-height: 1.2; margin-top: 4px; }
+ .tile .val { font-size: 30px; font-weight: 680; font-family: var(--num); font-variant-numeric: tabular-nums; letter-spacing: -.02em; line-height: 1.15; margin-top: 4px; }
.tile .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
- .tile.warn.on { border-color: color-mix(in srgb, var(--warn) 55%, transparent); } .tile.warn.on .val { color: var(--warn); }
+ .tile.warn.on { border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: linear-gradient(160deg, color-mix(in srgb, var(--warn) 7%, var(--surface)), var(--surface) 70%); } .tile.warn.on .val { color: var(--warn); }
.tile.info.on .val { color: var(--accent); }
- .tile.bad.on { border-color: color-mix(in srgb, var(--bad) 55%, transparent); } .tile.bad.on .val { color: var(--bad); }
+ .tile.bad.on { border-color: color-mix(in srgb, var(--bad) 45%, transparent); background: linear-gradient(160deg, color-mix(in srgb, var(--bad) 7%, var(--surface)), var(--surface) 70%); } .tile.bad.on .val { color: var(--bad); }
.tile.ok.on .val { color: var(--ok); }
.tile.active { box-shadow: inset 0 0 0 1px var(--accent); }
.banners { display: grid; gap: 8px; margin-bottom: 14px; }
.banners:empty { display: none; }
- .banner { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); font-size: 13px; }
+ .banner { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 12px; border: 1px solid var(--border); background: var(--surface); font-size: 13px; }
.banner .grow { flex: 1 1 auto; min-width: 0; }
.banner.warn { border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: color-mix(in srgb, var(--warn) 7%, var(--surface)); } .banner.warn svg.i { color: var(--warn); }
.banner.bad { border-color: color-mix(in srgb, var(--bad) 45%, transparent); background: color-mix(in srgb, var(--bad) 8%, var(--surface)); } .banner.bad svg.i { color: var(--bad); }
@@ -274,9 +347,10 @@
/* ── lists: one row per item, two lines, grouped by day ── */
.filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 12px; }
- .list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); }
+ .list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); box-shadow: var(--elev); }
.day { padding: 7px 16px; font-size: 12px; color: var(--muted); background: var(--well); border-bottom: 1px solid var(--border-soft); display: flex; justify-content: space-between; }
- .row { display: grid; grid-template-columns: 136px minmax(0, 1fr) auto; grid-template-areas: "st ttl m1" "st sub m2"; gap: 3px 14px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--border-soft); cursor: pointer; position: relative; }
+ .row { display: grid; grid-template-columns: 136px minmax(0, 1fr) auto; grid-template-areas: "st ttl m1" "st sub m2"; gap: 3px 14px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--border-soft); cursor: pointer; position: relative; transition: background-color .15s; }
+ .row:active { background: var(--hover); }
.row:last-child { border-bottom: 0; }
.row:hover { background: var(--hover); }
.row.cur { background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
@@ -288,7 +362,7 @@
.row .m1 { grid-area: m1; display: flex; gap: 10px; align-items: center; justify-content: flex-end; color: var(--muted); font-size: 12px; }
.row .m2 { grid-area: m2; display: flex; gap: 10px; align-items: center; justify-content: flex-end; }
.row .when { font-family: var(--mono); font-size: 12px; }
- .row .cost { color: var(--ok); font-family: var(--mono); font-size: 12px; }
+ .row .cost { color: var(--ok); font-family: var(--num); font-variant-numeric: tabular-nums; font-size: 12.5px; font-weight: 500; }
.row.quiet .ttl { color: var(--muted); font-weight: 500; }
.note { color: var(--muted); font-size: 12px; text-align: center; margin: 12px 0 0; }
.empty { text-align: center; color: var(--muted); padding: 44px 20px; }
@@ -298,9 +372,9 @@
@keyframes sk { from { background-position: 200% 0; } to { background-position: -200% 0; } }
/* ── drawers: one item's whole story, and the settings ── */
- .scrim { position: fixed; inset: 0; background: var(--scrim); opacity: 0; pointer-events: none; transition: opacity .18s; z-index: 30; }
+ .scrim { position: fixed; inset: 0; background: var(--scrim); opacity: 0; pointer-events: none; transition: opacity .3s var(--ease); z-index: 30; }
.scrim.on { opacity: 1; pointer-events: auto; }
- .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(840px, 100vw); background: var(--bg); border-left: 1px solid var(--border); transform: translateX(104%); transition: transform .22s ease; z-index: 31; display: flex; flex-direction: column; }
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(840px, 100vw); background: var(--bg); border-left: 1px solid var(--border); transform: translateX(104%); transition: transform .42s var(--ease); z-index: 31; display: flex; flex-direction: column; }
/* The shadow only while open: parked off-screen, it would still fall across the
page's right edge, invisible on dark and a grey band on white. */
.drawer.on { transform: none; box-shadow: var(--shadow-drawer); }
@@ -340,8 +414,8 @@
.codebox .btn { position: absolute; top: 8px; right: 8px; }
/* ── tables ── */
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
- .card > h3 { margin: 0 0 4px; font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; box-shadow: var(--elev); }
+ .card > h3 { margin: 0 0 4px; font-size: 15px; font-weight: 650; letter-spacing: -.008em; display: flex; align-items: center; gap: 8px; }
.card > .desc { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th { text-align: left; color: var(--muted); font-weight: 500; font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
@@ -351,9 +425,9 @@
.tbl .nw { white-space: nowrap; }
.tbl th.num { text-align: right; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
- .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; }
+ .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--elev); }
.stat .lbl { color: var(--muted); font-size: 12.5px; }
- .stat .val { font-size: 26px; font-weight: 650; font-family: var(--mono); margin-top: 2px; }
+ .stat .val { font-size: 28px; font-weight: 680; font-family: var(--num); font-variant-numeric: tabular-nums; letter-spacing: -.02em; margin-top: 2px; }
.stat.bad.on .val { color: var(--bad); } .stat.warn.on .val { color: var(--warn); } .stat.ok .val { color: var(--ok); }
/* ── editors and forms ── */
@@ -381,16 +455,30 @@
/* ── dialogs and toasts ── */
.modal { position: fixed; inset: 0; display: none; place-items: center; z-index: 40; background: var(--scrim-strong); padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left); }
- .modal.on { display: grid; }
- .modal-card { width: min(460px, calc(100vw - 32px)); max-height: 100%; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow-card); padding: 20px; }
- .modal-card h3 { margin: 0 0 8px; font-size: 16px; }
+ .modal.on { display: grid; animation: fade-in .25s var(--ease) both; }
+ .modal.on .modal-card { animation: pop-in .32s var(--ease) both; }
+ @keyframes fade-in { from { opacity: 0; } }
+ @keyframes pop-in { from { opacity: 0; transform: scale(.95) translateY(6px); } }
+ @keyframes sheet-up { from { transform: translateY(100%); } }
+ .modal-card { width: min(460px, calc(100vw - 32px)); max-height: 100%; overflow: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-card); padding: 22px; }
+ .modal-card h3 { margin: 0 0 8px; font-size: 17px; letter-spacing: -.01em; }
.modal-card .body { color: var(--muted); font-size: 13.5px; line-height: 1.6; }
.modal-card .body p { margin: 0 0 8px; }
.modal-card .acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.toasts { position: fixed; right: max(20px, var(--safe-right)); bottom: calc(20px + var(--safe-bottom)); display: grid; gap: 8px; z-index: 50; }
- .toast { display: flex; align-items: center; gap: 10px; min-width: 240px; max-width: 440px; padding: 10px 14px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); box-shadow: var(--shadow-toast); font-size: 13px; cursor: pointer; animation: toast-life 5s ease forwards; }
+ .toast { display: flex; align-items: center; gap: 10px; min-width: 240px; max-width: 440px; padding: 11px 15px; border-radius: 14px; background: var(--glass); -webkit-backdrop-filter: saturate(1.8) blur(22px); backdrop-filter: saturate(1.8) blur(22px); border: 1px solid var(--hairline); box-shadow: var(--shadow-toast); font-size: 13px; font-weight: 500; cursor: pointer; animation: toast-life 5s var(--ease) forwards; }
.toast.ok svg.i { color: var(--ok); } .toast.bad svg.i { color: var(--bad); }
- @keyframes toast-life { 0% { opacity: 0; transform: translateY(8px); } 6% { opacity: 1; transform: none; } 88% { opacity: 1; } 100% { opacity: 0; transform: translateY(4px); } }
+ @keyframes toast-life { 0% { opacity: 0; transform: translateY(10px) scale(.96); } 7% { opacity: 1; transform: none; } 88% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translateY(4px) scale(.98); } }
+ @keyframes toast-drop { 0% { opacity: 0; transform: translateY(-18px) scale(.9); } 8% { opacity: 1; transform: none; } 88% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translateY(-10px) scale(.96); } }
+ /* The pull that refreshes an installed board (the app shell): a ring that
+ turns as it is pulled, and spins once when let go far enough. */
+ .ptr { position: fixed; left: 50%; top: 0; z-index: 19; width: 34px; height: 34px; margin-left: -17px; border-radius: 50%;
+ display: grid; place-items: center; color: var(--muted); background: var(--surface); box-shadow: var(--shadow-toast);
+ opacity: 0; pointer-events: none; transform: translateY(calc(var(--pull, 0) * 1px - 40px)) rotate(calc(var(--pull, 0) * 5deg)); }
+ .ptr.armed { color: var(--accent); }
+ .ptr.spin { animation: ptr-spin .7s var(--ease) both; }
+ .ptr.back { transition: transform .3s var(--ease), opacity .2s; }
+ @keyframes ptr-spin { from { opacity: 1; transform: translateY(24px) rotate(0); } 80% { opacity: 1; } to { opacity: 0; transform: translateY(24px) rotate(360deg); } }
/* The prompt the token wiring reveals on a 401, as a card that says what to type. */
#tokenbar.show { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(180px, 280px) auto; gap: 12px; align-items: center; padding: 14px 16px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 7%, var(--surface)); }
@@ -411,11 +499,48 @@
.brand-sub { display: none; }
}
@media (max-width: 640px) {
- .top-in, .wrap { padding-left: max(12px, var(--safe-left)); padding-right: max(12px, var(--safe-right)); }
- .top-in { flex-wrap: wrap; gap: 10px; }
- .search { order: 3; flex: 1 1 100%; max-width: none; margin: 0; }
- .top-right { margin-left: auto; }
- .stamp, .search kbd { display: none; }
+ .top-in, .wrap { padding-left: max(14px, var(--safe-left)); padding-right: max(14px, var(--safe-right)); }
+ /* The header is the app's: who this is on the left; on the right only what a
+ thumb needs — search, settings, and the live dot (the word appears when the
+ board is NOT live). Language, light or dark and refresh are in the settings
+ drawer already, and an installed board refreshes by being pulled. The
+ border appears once something has scrolled under the header. */
+ .top { border-bottom-color: transparent; }
+ .top.titled { border-bottom-color: var(--hairline); }
+ .top-in { flex-wrap: wrap; gap: 4px 6px; min-height: 50px; padding-top: 7px; padding-bottom: 7px; position: relative; }
+ .brand b { font-size: 17px; }
+ .search { display: none; order: 3; flex: 1 1 100%; max-width: none; margin: 2px 0 1px; }
+ .top.find .search { display: flex; }
+ .btn.top-search { display: inline-flex; }
+ .top-right { margin-left: auto; gap: 2px; }
+ .top-right .btn.ghost { width: 34px; height: 34px; padding: 0; justify-content: center; border-radius: 10px; }
+ .top-right .btn.ghost svg.i { width: 19px; height: 19px; }
+ #langBtn, #rTheme, #rNow, .stamp { display: none; }
+ .rc .live.on { font-size: 0; gap: 0; padding: 0 10px; }
+ .top-title { display: block; position: absolute; left: 50%; top: 0; height: 50px; line-height: 50px; max-width: 52%;
+ transform: translateX(-50%); font-size: 16px; font-weight: 680; letter-spacing: -.01em; opacity: 0; pointer-events: none;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: opacity .2s; }
+ .top.titled .top-title { opacity: 1; }
+ .top.titled .brand b { opacity: 0; }
+ .wrap { padding-top: 12px; padding-bottom: calc(40px + var(--tabbar-h)); }
+ /* The strip's margin under it on a desk would lift the tab bar off the bottom edge. */
+ .wrap > .tabs { margin: 0; }
+ .panel { border-radius: 18px; }
+ .headline { font-size: 21px; }
+ .headline-sub, .hero-actions { margin-left: 0; }
+ .tile .val { font-size: 32px; }
+ /* A dialog is a sheet that rises from the bottom edge, its answers full
+ width and tall enough for a thumb; a toast drops from under the status
+ bar as a capsule, where an eye already is, clear of the tab bar. */
+ .modal { place-items: end stretch; padding: var(--safe-top) 0 0; }
+ .modal-card { width: 100%; max-height: 88vh; border-radius: 22px 22px 0 0; border-bottom: 0; padding: 24px max(20px, var(--safe-right)) calc(18px + var(--safe-bottom)) max(20px, var(--safe-left)); }
+ .modal.on .modal-card { animation: sheet-up .44s var(--ease) both; }
+ .modal-card .acts { flex-direction: column-reverse; gap: 8px; }
+ .modal-card .acts .btn { height: 46px; justify-content: center; font-size: 15.5px; border-radius: 13px; }
+ .toasts { left: max(12px, var(--safe-left)); right: max(12px, var(--safe-right)); top: calc(8px + var(--safe-top)); bottom: auto; justify-items: center; }
+ .toast { min-width: 0; max-width: 100%; border-radius: 999px; padding: 11px 18px; animation-name: toast-drop; }
+ .search kbd { display: none; }
+ .seg button { padding: 5px 8px; gap: 5px; }
/* iOS zooms the page in to a field whose text is under 16px when it takes
focus, and does not zoom back out: on a phone every field types at 16px,
over whatever size its component gives it on a desk. */
@@ -434,7 +559,14 @@
.hp th, .hp td { padding: 6px 7px; overflow-wrap: anywhere; }
.tbl td .pill, .hp td .pill { white-space: normal; height: auto; min-height: 22px; padding: 2px 9px 2px 8px; line-height: 1.35; border-radius: 11px; }
#tokenbar.show { grid-template-columns: 1fr; }
- .headline-sub, .hero-actions { margin-left: 0; }
+ }
+ :root { --tabbar-h: 0px; }
+ @media (max-width: 640px) { :root { --tabbar-h: calc(64px + var(--safe-bottom)); } }
+ /* Whoever asked the system for less motion gets the same boards standing
+ still: no rising, glowing, breathing or springing. A toast keeps its life,
+ which is how long it stays, not how it moves. */
+ @media (prefers-reduced-motion: reduce) {
+ *:not(.toast), *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
/* ── end components ─────────────────────────────────────────────────────── */
/* ── hookstack token bar · keep this block identical in all three pages ── */
@@ -465,12 +597,16 @@
.chip-top { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); font-size: 12px; font-family: var(--mono); white-space: nowrap; }
.chip-top.hot { color: var(--bad); border-color: var(--bad); }
.chip-top.unmeasured { border-style: dashed; }
- /* The budget chip is this page's own header item, and with it the header's
- first line is wider than a phone: the page scrolled sideways and the
- header's border stopped short of its own controls (2026-10-06). On a phone
- the chip takes a line of its own, under the search. Here and not in the
- shared block above: the other two pages have no chip. */
- @media (max-width: 640px) { .top-in > .chip-top { order: 4; } }
+ /* The budget chip is this page's own header item. On a phone the header is
+ one line since the boards became apps (2026-11-12) — search, language,
+ theme and refresh left it — so the chip fits on it again, beside the
+ switches, where it had needed a line of its own under the search
+ (2026-10-06). Here and not in the shared block: the other two pages have
+ no chip. */
+ @media (max-width: 640px) {
+ .top-in > .chip-top { margin-left: auto; height: 26px; padding: 0 9px; font-size: 11.5px; }
+ .top-in > .chip-top:not([hidden]) + .top-right { margin-left: 0; }
+ }
.tabs .tab-group { flex-wrap: nowrap; }
.tabs .tab-group-label { white-space: nowrap; }
/* Eleven tabs and two group labels: below a very wide screen the grouped tabs
@@ -627,6 +763,23 @@
.wf-row { grid-template-columns: 26px minmax(80px, 1fr); }
.wf-text { grid-column: 1 / -1; }
}
+ /* The approvals' two tables are a line of text and a cell of buttons that do
+ not wrap. On a phone the buttons took the row and left the text a column one
+ word wide, the session key broken a letter at a time (found 2026-11-12). On
+ a phone each row is a block instead: the text, then the buttons under it. */
+ @media (max-width: 640px) {
+ .appr-t, .appr-t tbody, .appr-t tr, .appr-t td { display: block; }
+ .appr-t tr { padding: 10px 0; border-bottom: 1px solid var(--border-soft); }
+ .appr-t tr:last-child { border-bottom: 0; }
+ .appr-t td, .appr-t tr:last-child td { padding: 0; border: 0; }
+ .appr-t td.nw { margin-top: 8px; text-align: left !important; white-space: normal; }
+ }
+ /* On a phone the header is one line, the large title stands over the pane and
+ the tab bar covers the bottom edge: the pane ends above the bar, so the last
+ session in a long list can be scrolled into reach. */
+ @media (max-width: 640px) {
+ .split .pane { height: calc(100dvh - 136px - var(--safe-top) - var(--tabbar-h)); min-height: 360px; }
+ }
@@ -921,6 +1074,7 @@
diff --git a/scripts/assert_design.py b/scripts/assert_design.py
index ad65c0fd..b03b3783 100755
--- a/scripts/assert_design.py
+++ b/scripts/assert_design.py
@@ -115,6 +115,17 @@
"// ── install on a phone · keep this block identical in all three pages ─────",
"// ── end install ───────────────────────────────────────────────────────────",
),
+ # Added 2026-11-12, when the boards became apps on a phone: the tab strip as
+ # a bottom bar, a large title the header takes over once it scrolls away,
+ # a group's pages as a segmented control, the search behind a button, and
+ # the pull that refreshes an installed board. Three copies of a scroll
+ # listener and a touch handler are exactly the parts that would drift
+ # unnoticed, because only a phone shows them.
+ (
+ "app shell script",
+ "// ── hookstack app shell · keep this block identical in all three pages ─────",
+ "// ── end app shell ─────────────────────────────────────────────────────────",
+ ),
)
# Whole files copied between the services, byte for byte. The service worker
diff --git a/scripts/make_app_icons.py b/scripts/make_app_icons.py
index c9fdda10..d072fce9 100755
--- a/scripts/make_app_icons.py
+++ b/scripts/make_app_icons.py
@@ -3,6 +3,9 @@
Each page carries one SVG favicon inline — a glyph in the accent colour on the
surface colour: the pipe's hook, the judge's scales, the investigator's lens.
+On the home screen (since 2026-11-12) the surface is lit from above, a shade
+lighter at the top than the page's own dark, and the glyph sits in a faint glow
+of its accent: an icon among other apps' icons, not a favicon blown up.
A phone's home screen wants PNGs — 192 and 512, and a "maskable" one whose
glyph sits inside the safe zone so Android can crop it to any shape. There is
no image library in any venv and none is worth adding for nine files, so this
@@ -22,7 +25,10 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
-SURFACE, ACCENT, OK = (0x11, 0x15, 0x1D), (0x4C, 0x8D, 0xFF), (0x3D, 0xD6, 0x8C)
+ACCENT, OK = (0x4C, 0x8D, 0xFF), (0x3D, 0xD6, 0x8C)
+# The icon's surface, top to bottom (the favicon's flat #11151d lies between the
+# two), and how strongly the accent glows at the glyph's centre.
+LIT, DEEP, GLOW = (0x1E, 0x26, 0x37), (0x0B, 0x0E, 0x14), 0.2
Colour = tuple[int, int, int]
# (distance from a point to the shape's centre line, the half width painted, the colour, the bounding box)
Shape = tuple[Callable[[float, float], float], float, Colour, tuple[float, float, float, float]]
@@ -116,6 +122,12 @@ def _blend(base: Colour, colour: Colour, alpha: float) -> Colour:
return tuple(round(b + (c - b) * alpha) for b, c in zip(base, colour, strict=True)) # type: ignore[return-value]
+def _surface(down: float, gx: float, gy: float) -> Colour:
+ """The ground under the glyph: lit from above, and a soft glow at the glyph's centre."""
+ reach = max(0.0, 1.0 - math.hypot(gx - 12.0, gy - 12.0) / 11.0)
+ return _blend(_blend(LIT, DEEP, down), ACCENT, GLOW * reach * reach)
+
+
def render(size: int, shapes: list[Shape], *, maskable: bool) -> bytes:
"""One PNG. Maskable: full-bleed background and the glyph at 70% in the centre."""
unit = size / 24.0
@@ -135,7 +147,7 @@ def render(size: int, shapes: list[Shape], *, maskable: bool) -> bytes:
row += bytes((0, 0, 0, 0))
continue
gx, gy = (px / unit - offset) / glyph_scale, (py / unit - offset) / glyph_scale
- colour = SURFACE
+ colour = _surface(py / size, gx, gy)
for distance, half_width, paint, (x0, y0, x1, y1) in shapes:
if x0 <= gx <= x1 and y0 <= gy <= y1:
colour = _blend(colour, paint, _coverage(distance(gx, gy), half_width, px_unit))