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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Runtime session overrides (per source) are persisted to `active_sessions.json` i

## Web App

`web/` is `index.html` (markup) + `app.css` + eleven scripts under `web/js/`, and **still has no build
`web/` is `index.html` (markup) + `app.css` + twelve scripts under `web/js/`, and **still has no build
step** — plain `<script src>` tags, loaded in the order they are listed. It's deployed to Cloudflare
Pages. It carries a mobile terminal keyboard, PWA support, and agent-icon detection.

Expand All @@ -135,7 +135,8 @@ Three things about that layout are load-bearing:
- **The relay serves `web/` as a directory** (`web_asset`), not from a list of filenames — see
below. Adding a file needs no relay change.

`web/js/` in load order: `state` (globals, settings, theme, socket) → `markdown` → `diff` →
`web/js/theme.js` runs in the head before CSS. The remaining scripts load in order:
`state` (globals, settings, socket) → `markdown` → `diff` →
`triage` → `spaces` (grouping and naming lookups) → `cards` (what a row is called and how it
renders) → `mirror` (the terminal reconciler) → `upload` (attach and paste) → `nav` → `palette` →
`push`.
Expand All @@ -158,8 +159,8 @@ file.
attributes and therefore follow the OS rather than the pin, so a pinned reader is given the pinned
colour in *both*. The choice is a **bare string** in `localStorage.herdr_theme`, written by
`setTheme` and read by a script in `<head>` before first paint; `JSON.stringify` there would store
`"dark"` *with* the quotes and the anti-flash would silently never fire again. Three exclusive
choices are a `radiogroup`, not three `aria-pressed` toggles announcing three independent
`"dark"` *with* the quotes and the anti-flash would silently never fire again. The exclusive
choices are a `radiogroup`, not `aria-pressed` toggles announcing independent
switches, and the selected one fills with the inverted neutral the session view's toggles already
use — blue would read as the *selection* colour the chips own.
- **`--on-accent` is the text ON a saturated fill.** A saturated token is dark in the light theme
Expand All @@ -179,6 +180,14 @@ file.
layout assertion in `tests/test_web_*.py` — the key labels that must not clip at 320px, the herd
row that gives up the project before the tab, the chrome budgets — passes unchanged under it.

`web/js/theme.js` now owns appearance and runs synchronously in `<head>` before the stylesheet,
including the pre-paint restore. Optional Custom palettes are strict, bounded JSON: a light/dark
`colorScheme` and ten allowlisted `#RRGGBB` semantic colors, validated for text contrast before use.
They live in `localStorage.herdr_custom_theme`; `herdr_theme = custom` selects one. Leaving Custom
removes all its CSS overrides, including the header and native color scheme. Invalid saved palettes
fall back safely; unavailable storage still permits changes for this tab. The editor and reset are
in Settings, and terminal tokens/ANSI rendering are deliberately outside this feature.

`tests/test_web_theme.py` measures all of it in a browser: the two grounds, a pin beating the OS in
both directions, the stale pin coming off, the pre-paint stamp surviving a reload, the metas, the
contrast on each saturated fill, and "monospace" as two equal-length strings of different glyphs
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,53 @@ Access hostname), set `HERDR_TRUSTED_ORIGINS`:
export HERDR_TRUSTED_ORIGINS="https://herdr.example.com"
```

## Web appearance

In **Settings → Appearance**, Auto follows the system; Light and Dark pin the existing palette.
For a different palette, expand **Customize colors**, edit or paste the prefilled JSON, and choose
**Apply custom colors**. The Custom option then becomes available alongside the original choices.
Switching back to Auto, Light or Dark keeps the saved custom palette; **Reset custom colors**
removes it and returns to Auto if Custom was selected.

A palette contains a fixed native color scheme and exactly ten semantic colors, for example:

```json
{
"colorScheme": "light",
"colors": {
"bg": "#f5f5f5", "surface": "#ffffff", "border": "#e5e5e5",
"text": "#0a0a0a", "muted": "#5d5d5d",
"green": "#006a29", "red": "#ac0010", "blue": "#005bb5",
"orange": "#804a00", "on-accent": "#ffffff"
}
}
```

Use `light` or `dark` for `colorScheme`, and `#RRGGBB` for every color. Text, muted text and
status colors must have at least 4.5:1 contrast against both backgrounds; `on-accent` is the text
on the four accent-colored buttons and must also meet that ratio. Invalid palettes leave the
active appearance unchanged. CSS, remote theme URLs and terminal color tokens are not accepted.

Custom colors apply to the web interface, including Conversation History, **not** the live terminal's
ANSI/truecolor output. Preferences stay in this browser, not on the relay or in the agent's settings.
If browser storage is unavailable, changes work for the current tab and a notice explains that they
cannot be saved. Copy the palette JSON to reuse it in another browser.

<details>
<summary>Color-theme examples</summary>

| Built-in Light (unchanged) | Custom warm light example | Custom cool dark example |
|:--:|:--:|:--:|
| <img src="public/web-theme-history-light.png" alt="Synthetic conversation history using the built-in Light palette" width="220"> | <img src="public/web-theme-history-custom.png" alt="The same synthetic history using a custom warm light palette" width="220"> | <img src="public/web-theme-history-custom-dark.png" alt="The same synthetic history using a custom cool dark palette" width="220"> |

<img src="public/web-theme-settings-custom.png" alt="Mobile Settings showing the Custom palette editor with synthetic preview data" width="300">

Browser-rendered previews using synthetic English data in a 390 × 844 viewport, not original
phone screenshots. The history comparisons use identical content and layout. The palettes are
configuration examples, not built-in presets; live terminal colors are unchanged.

</details>

## Telegram Bot

For an automatically restarting relay and Telegram bot:
Expand Down
Binary file added public/web-theme-history-custom-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/web-theme-history-custom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/web-theme-history-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/web-theme-settings-custom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ assert_eq "$?" "0" "no duplicate function declarations"

echo "9c2. every script the page loads exists"
MISSING=""
for src in $(grep -oE '<script src="[^"]+"' "$WEB" | sed -e 's/.*src="//' -e 's/"//'); do
for src in $(grep -oE '<script src="[^"]+"' "$WEB" | sed -e 's/.*src="//' -e 's/"//' -e 's/[?].*//'); do
[ -f "$DIR/web/$src" ] || MISSING="$MISSING $src"
done
[ -z "$MISSING" ]
Expand Down
17 changes: 16 additions & 1 deletion tests/test_web_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import types
import unittest
from unittest import mock
from urllib.parse import urlsplit

from test_herdr_relay import _http_stubs, loaded_relay

Expand All @@ -34,11 +35,25 @@ def referenced_assets():
for ref in found:
if ref.startswith(("http://", "https://", "data:")):
continue
paths.add(ref if ref.startswith("/") else "/" + ref.lstrip("./"))
path = urlsplit(ref).path
paths.add(path if path.startswith("/") else "/" + path.lstrip("./"))
return paths


class WebAssetRoutingTests(unittest.TestCase):
def test_versioned_theme_assets_are_served_without_a_token(self):
"""A new query bypasses stale browser caches, but the relay still resolves the same file."""
with loaded_relay(relay_token="test-token") as relay:
for path in ("app.css", "js/theme.js", "js/state.js"):
with self.subTest(path=path):
request = types.SimpleNamespace(
path="/" + path + "?v=themes-1",
headers=types.SimpleNamespace(raw_items=lambda: ()))
with mock.patch.dict(sys.modules, _http_stubs(), clear=False):
response = asyncio.run(relay.process_request(None, request))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.body, (WEB / path).read_bytes())

def test_every_asset_the_page_loads_is_served(self):
"""The failure this replaces: add a file to web/, forget the relay, ship it broken."""
assets = referenced_assets()
Expand Down
Loading
Loading