diff --git a/docs/UI-REMAINING-WORK.md b/docs/UI-REMAINING-WORK.md
new file mode 100644
index 0000000..a24df90
--- /dev/null
+++ b/docs/UI-REMAINING-WORK.md
@@ -0,0 +1,61 @@
+# UI work — remaining, measured
+
+What is left after the first pass on the two operator surfaces. Each item was
+observed on a real render, not inferred. Kept as a list so a gap is recorded
+rather than remembered.
+
+## Device console (`bfos/ui_gui.py`)
+
+Reviewed against a screenshot of the running window (`spectacle`, 1440x900),
+with the token values read back out of `bfos/ui_theme.py`.
+
+**Landed** — grouping into headed regions; the privilege state moved out of a
+monospace paragraph into the header and a banner; the duplicate `Çık` removed;
+the output well styled as read-only; status colours from the shared tokens. The
+theme file carries the same values as `web/static/tokens.css` (`#1d4ed8`,
+`#15803d`, `#b45309`) — verified by comparison, not by assertion.
+
+**Still true at the time of writing:**
+
+1. **The widgets still read as default ttk.** Inputs, comboboxes, checkbuttons
+ and the scrollbar keep the renderer's native chrome. `ui_theme.py` selects
+ `clam` because it honours `background`/`bordercolor` on the elements it
+ restyles, but a themed *element* is not a redesigned control. Closing this
+ needs `Style.element_create` work per control, or hand-drawn widgets, and it
+ is the single largest remaining gap between this console and the web panel.
+2. **The product name appears twice.** `root.title("Blueforce Field OS — Yönetim")`
+ and the in-window header band carry the same words. One of them should go;
+ the window title is what the taskbar and window switcher show, so the header
+ band is the better candidate.
+3. **`Cihaz durumu: YOK` is ambiguous** when no target is selected: it reads as
+ a state a device is in rather than "nothing selected yet".
+4. **`HAZIR` in the action bar looks like a disabled button** — it is a status
+ label, but it is styled in the same register as the controls beside it.
+5. **The operation list has no unambiguous selected/disabled distinction.** The
+ first row carries a focus ring while being unavailable; the ring reads as
+ "actionable" and the reason is only in the row's trailing text.
+
+## Central panel (`web/`)
+
+Reviewed against served HTML and captured pages at 1440x900.
+
+**Fixed in this pass** — the accent and the `KURULDU` state were the same blue
+(`--state-provisioned: var(--info)`, and `--info` equals `--accent`), so a
+selected row and a state badge were indistinguishable; both now resolve to
+different colours, asserted by a test that resolves aliases before comparing.
+The wave cell printed the bare code and then a label that already contains it
+("P2 P2 — PILOT-2"); it now names each wave once.
+
+**Still true:**
+
+1. **No scroll affordance on the device table.** The page reports 13/13 while
+ only part of the list is in view, and nothing signals that more rows exist.
+2. **The read-only privilege is repeated** in the header note and a second line
+ at the far right; one is enough.
+3. **The `HAZIR` count appears in the loud figure and again in the breakdown.**
+ Defensible (summary then detail), but worth a decision rather than an
+ accident.
+4. **Timestamps are ISO-8601** (`2026-09-16T06:20:00+03:00`). Precise and
+ sortable, but slow to scan for a human at a glance.
+5. **A `HATA` device offers no next action** — the panel says a person is needed
+ and stops. For a fleet panel this is the workflow gap to close next.
diff --git a/tests/test_web_static.py b/tests/test_web_static.py
new file mode 100644
index 0000000..aa4b459
--- /dev/null
+++ b/tests/test_web_static.py
@@ -0,0 +1,1292 @@
+#!/usr/bin/env python3
+"""web panel — static and contract tests (stdlib unittest).
+
+No port is bound, no browser is opened and no server process is started: the
+whole HTTP surface is covered through ``web.panel.route()``, which is a pure
+function, and every rendering claim is checked on the rendered bytes.
+
+What these tests defend (the things that could silently regress):
+
+ * the Turkish status vocabulary still matches docs/DESIGN.md exactly, so the
+ device console and the central panel cannot drift apart;
+ * every user-visible string is non-empty, Turkish and free of placeholders;
+ * no external URL, no webfont, no hex colour and no new colour token anywhere
+ in the templates or stylesheets: colour comes from tokens.css only;
+ * the panel imports nothing outside the standard library and can therefore be
+ installed on the air-gapped fleet it manages;
+ * only GET/HEAD are served, static assets are allow-listed, and a full sweep
+ over every endpoint leaves the filesystem untouched;
+ * the first viewport is ordered and populated as docs/DESIGN.md requires.
+
+Run:
+ python3 tests/test_web_static.py -v
+"""
+
+from __future__ import annotations
+
+import ast
+import contextlib
+import io
+import json
+import re
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+WEB_DIR = REPO_ROOT / "web"
+TEMPLATES_DIR = WEB_DIR / "templates"
+STATIC_DIR = WEB_DIR / "static"
+TOKENS_CSS = STATIC_DIR / "tokens.css"
+DESIGN_DOC = REPO_ROOT / "docs" / "DESIGN.md"
+
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+import web # noqa: E402 (path set above)
+from web import fleet_data, panel, render, server # noqa: E402
+
+# The panel must be the one in this checkout, not some unrelated "web" package.
+assert Path(web.__file__).resolve().parent == WEB_DIR, f"yanlis paket: {web.__file__}"
+
+NOW = "2026-09-16T22:00:00+03:00"
+STATE_WORDS = ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK")
+
+# Everything an air-gapped, framework-free surface must never contain.
+FORBIDDEN_ASSET_MARKERS = (
+ "http://",
+ "https://",
+ "//cdn",
+ "cdn.jsdelivr",
+ "cdnjs",
+ "unpkg",
+ "fonts.googleapis",
+ "fonts.gstatic",
+ "@import",
+ "@font-face",
+ "src=",
+ "xlink:href=",
+)
+FORBIDDEN_IMPORT_ROOTS = ("fastapi", "flask", "jinja2", "uvicorn", "django", "werkzeug", "starlette", "requests")
+FORBIDDEN_IMPORTS = ("urllib.request", "http.client", "socket", "subprocess", "shutil", "ftplib")
+FORBIDDEN_SOURCE_MARKERS = ("os.system(", "subprocess.", "urllib.request", "requests.", "eval(", "exec(")
+
+HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{3,8}\b")
+CUSTOM_PROPERTY = re.compile(r"^\s*--[A-Za-z0-9-]+\s*:", re.M)
+# A custom-property declaration, e.g. `--state-ready: var(--ok);`
+CUSTOM_PROPERTY_DECL = re.compile(r"^\s*--([A-Za-z0-9-]+)\s*:\s*([^;]+);", re.M)
+# A single `var(--name)` reference inside a value.
+VAR_REFERENCE = re.compile(r"var\(\s*--([A-Za-z0-9-]+)\s*\)")
+DEFINED_TOKEN = re.compile(r"^\s*(--[a-z0-9-]+)\s*:", re.M)
+USED_TOKEN = re.compile(r"var\((--[a-z0-9-]+)\)")
+TEXT_TAGS = ("td", "th", "span", "h1", "h2", "h3", "p", "li", "option", "dd", "dt", "button", "a", "caption", "label")
+ELEMENT = re.compile(r"<(%s)\b[^>]*>(.*?)\1>" % "|".join(TEXT_TAGS), re.S)
+ROW_BADGE = re.compile(r']*>([^<]*)')
+
+
+# --------------------------------------------------------------------------
+# helpers
+# --------------------------------------------------------------------------
+
+
+def token_values(text: str) -> dict[str, str]:
+ """Every custom-property declaration in a stylesheet, name -> raw value."""
+ return {
+ name: value.strip()
+ for name, value in CUSTOM_PROPERTY_DECL.findall(text)
+ }
+
+
+def resolved_colour(name: str, values: dict[str, str], limit: int = 12) -> str | None:
+ """Follow ``var(--x)`` aliases to the colour a token actually paints.
+
+ Comparing raw declarations is not enough to prove two tokens differ: a
+ mutation writing ``--state-provisioned: var(--accent)`` leaves the strings
+ unequal while painting exactly the same blue. Only the resolved value is
+ evidence.
+ """
+ seen: set[str] = set()
+ while limit > 0:
+ value = values.get(name)
+ if value is None:
+ return None
+ reference = VAR_REFERENCE.fullmatch(value)
+ if reference is None:
+ return value
+ name = reference.group(1)
+ if name in seen:
+ return value # a cycle: report what was declared rather than loop
+ seen.add(name)
+ limit -= 1
+ return values.get(name)
+
+
+def design_vocabulary() -> list[tuple[str, str]]:
+ """Read the status vocabulary table out of docs/DESIGN.md."""
+ text = DESIGN_DOC.read_text(encoding="utf-8")
+ row = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*`(--state-[a-z-]+)`\s*\|", re.M)
+ return row.findall(text)
+
+
+def text_files() -> list[Path]:
+ """Templates and stylesheets: everything the browser receives from disk."""
+ return sorted(TEMPLATES_DIR.glob("*.html")) + sorted(STATIC_DIR.glob("*.css"))
+
+
+def empty_elements(markup: str) -> list[str]:
+ """Visible elements that carry no text at all (an empty label is a bug)."""
+ problems: list[str] = []
+ for match in ELEMENT.finditer(markup):
+ inner = re.sub(r"<[^>]+>", "", match.group(2))
+ if not inner.strip():
+ problems.append(match.group(0)[:140])
+ return problems
+
+
+def tree_fingerprint(root: Path) -> tuple:
+ """Sizes and mtimes of every source file, ignoring interpreter caches."""
+ entries = []
+ for path in sorted(root.rglob("*")):
+ if "__pycache__" in path.parts or not path.is_file():
+ continue
+ stat = path.stat()
+ entries.append((str(path.relative_to(root)), stat.st_size, stat.st_mtime_ns))
+ return tuple(entries)
+
+
+class PanelCase(unittest.TestCase):
+ """Shared fixture: the labelled demo snapshot and both privilege levels."""
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.snapshot = fleet_data.demo_snapshot(NOW)
+ cls.read_only = render.privilege_from_level("read-only")
+ cls.full = render.privilege_from_level("full")
+ cls.info = render.ServerInfo(
+ host="127.0.0.1",
+ port=8787,
+ privilege=cls.read_only,
+ python_version="3.13.0",
+ started_at=NOW,
+ )
+ cls.context = panel.PanelContext(
+ snapshot=cls.snapshot, privilege=cls.read_only, server_info=cls.info
+ )
+ full_info = render.ServerInfo(
+ host="127.0.0.1",
+ port=8787,
+ privilege=cls.full,
+ python_version="3.13.0",
+ started_at=NOW,
+ )
+ cls.full_context = panel.PanelContext(
+ snapshot=cls.snapshot, privilege=cls.full, server_info=full_info
+ )
+
+ # -- request helpers ---------------------------------------------------
+ def get(self, target: str, method: str = "GET", context: panel.PanelContext | None = None):
+ return panel.route(context or self.context, method, target)
+
+ def page(self, target: str = "/", method: str = "GET", context: panel.PanelContext | None = None) -> str:
+ response = self.get(target, method, context)
+ self.assertEqual(response.status, 200, f"{target}: {response.status} (beklenen 200)")
+ return response.body.decode("utf-8")
+
+ def list_region(self, page: str) -> str:
+ """The device table only, so about/detail badges never leak into a scan."""
+ start = page.index('data-region="list"')
+ end = page.index('data-region="detail"')
+ return page[start:end]
+
+ def row_words(self, page: str) -> list[str]:
+ return [word for _, word in ROW_BADGE.findall(self.list_region(page))]
+
+ def snapshot_from(self, document: object, *, now: str = NOW, filename: str = "fleet.json"):
+ """Write a temporary inventory document and load it through the loader."""
+ directory = tempfile.TemporaryDirectory(prefix="bf-panel-test-")
+ self.addCleanup(directory.cleanup)
+ path = Path(directory.name) / filename
+ if isinstance(document, str):
+ path.write_text(document, encoding="utf-8")
+ else:
+ path.write_text(json.dumps(document, ensure_ascii=False), encoding="utf-8")
+ return fleet_data.load_fleet(path, env={}, now=now)
+
+
+# --------------------------------------------------------------------------
+# vocabulary and design contract
+# --------------------------------------------------------------------------
+
+
+class TestVocabularyMatchesDesign(PanelCase):
+ def test_vocabulary_matches_docs_design_exactly(self) -> None:
+ """One word per state, shared with the device console; no fifth word."""
+ self.assertEqual(
+ design_vocabulary(),
+ [(spec.word, spec.token) for spec in fleet_data.STATE_VOCABULARY],
+ )
+
+ def test_design_document_still_has_the_five_words(self) -> None:
+ words = [word for word, _ in design_vocabulary()]
+ self.assertEqual(words, list(STATE_WORDS))
+
+ def test_every_word_is_upper_case_turkish_and_non_empty(self) -> None:
+ for spec in fleet_data.STATE_VOCABULARY:
+ with self.subTest(state=spec.state):
+ self.assertTrue(spec.word.strip(), spec.state)
+ self.assertEqual(spec.word, spec.word.upper())
+ self.assertTrue(spec.meaning.strip(), spec.word)
+ self.assertNotIn("None", spec.meaning)
+
+ def test_tokens_exist_in_tokens_css(self) -> None:
+ css = TOKENS_CSS.read_text(encoding="utf-8")
+ for spec in fleet_data.STATE_VOCABULARY:
+ with self.subTest(state=spec.state):
+ self.assertIn(f"{spec.token}:", css)
+ self.assertIn(f"{spec.soft_token}:", css)
+
+ def test_non_ready_states_sort_before_ready(self) -> None:
+ self.assertEqual(fleet_data.STATE_ORDER[-1], "READY")
+ self.assertEqual(set(fleet_data.STATE_ORDER), set(fleet_data.STATE_BY_NAME))
+
+ def test_demo_data_exercises_every_state_word(self) -> None:
+ counts = self.snapshot.counts
+ self.assertEqual(list(counts), list(STATE_WORDS))
+ for word, count in counts.items():
+ with self.subTest(word=word):
+ self.assertGreaterEqual(count, 1, f"{word} demo veride yok; sözlük test edilemez")
+
+ def test_demo_data_exercises_every_wave(self) -> None:
+ counts = fleet_data.wave_counts(self.snapshot.devices)
+ self.assertEqual(list(counts), [wave.code for wave in fleet_data.WAVES])
+ for code, count in counts.items():
+ with self.subTest(wave=code):
+ self.assertGreaterEqual(count, 1)
+
+ def test_waves_match_the_rollout_document(self) -> None:
+ """docs/18: LAB(2) -> P1(5) -> P2(20) -> W1(50) -> W2(100) -> PROD."""
+ self.assertEqual(
+ [(wave.code, wave.target) for wave in fleet_data.WAVES],
+ [("LAB", 2), ("P1", 5), ("P2", 20), ("W1", 50), ("W2", 100), ("PROD", 523)],
+ )
+
+ def test_four_documented_channels(self) -> None:
+ self.assertEqual(
+ [channel.label for channel in fleet_data.CHANNELS],
+ ["SSH-over-WG", "xRDP", "RustDesk", "MeshCentral"],
+ )
+ self.assertEqual(
+ [channel.key for channel in fleet_data.CHANNELS],
+ ["ssh_over_wg", "xrdp", "rustdesk", "meshcentral"],
+ )
+
+ def test_channel_status_words_are_turkish_and_non_empty(self) -> None:
+ for status, word in fleet_data.CHANNEL_STATUS_WORDS.items():
+ with self.subTest(status=status):
+ self.assertTrue(word.strip())
+ self.assertNotIn("None", word)
+ self.assertEqual(set(fleet_data.CHANNEL_STATUS_WORDS), set(fleet_data.CHANNEL_STATUSES))
+
+
+# --------------------------------------------------------------------------
+# no external assets, no invented colour
+# --------------------------------------------------------------------------
+
+
+class TestAssetsAndColour(PanelCase):
+ def test_no_external_url_or_remote_asset_in_any_template_or_stylesheet(self) -> None:
+ for path in text_files():
+ text = path.read_text(encoding="utf-8").lower()
+ for marker in FORBIDDEN_ASSET_MARKERS:
+ with self.subTest(file=path.name, marker=marker):
+ self.assertNotIn(marker, text)
+
+ def test_no_javascript_ships_with_the_panel(self) -> None:
+ for path in text_files():
+ text = path.read_text(encoding="utf-8").lower()
+ with self.subTest(file=path.name):
+ self.assertNotIn("