diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml
index 5ceaee3a..630c8557 100644
--- a/.github/workflows/linux-installer.yml
+++ b/.github/workflows/linux-installer.yml
@@ -55,6 +55,27 @@ jobs:
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
+ - name: Set up Node.js (web build)
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
+ with:
+ node-version: "24"
+ cache: "npm"
+ cache-dependency-path: web/package-lock.json
+
+ # The web/vis frontends are gitignored build artifacts; without these
+ # steps PyInstaller froze a binary whose web UI 404'd on "/". The
+ # bundles are arch-independent, so they are built once on the host —
+ # the QEMU container build picks them up from the mounted checkout.
+ # The spec file now also refuses to freeze when the bundles are missing.
+ - name: Build web UI bundle
+ env:
+ PYTHINKER_WEB_STRICT_VERSION: "1"
+ PYTHINKER_WEB_EXPECT_VERSION: ${{ steps.ver.outputs.version }}
+ run: python3 scripts/build_web.py
+
+ - name: Build vis UI bundle
+ run: python3 scripts/build_vis.py
+
- name: Set up QEMU (aarch64 only)
if: matrix.qemu
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # pinned from v4.1.0
diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml
index f86fd212..cd9cbcd4 100644
--- a/.github/workflows/windows-installer.yml
+++ b/.github/workflows/windows-installer.yml
@@ -54,6 +54,25 @@ jobs:
- name: Sync project dependencies
run: uv sync --frozen --no-dev
+ - name: Set up Node.js (web build)
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
+ with:
+ node-version: "24"
+ cache: "npm"
+ cache-dependency-path: web/package-lock.json
+
+ # The web/vis frontends are gitignored build artifacts; without these
+ # steps PyInstaller froze a binary whose web UI 404'd on "/". The spec
+ # file now also refuses to freeze when the bundles are missing.
+ - name: Build web UI bundle
+ env:
+ PYTHINKER_WEB_STRICT_VERSION: "1"
+ PYTHINKER_WEB_EXPECT_VERSION: ${{ steps.ver.outputs.version }}
+ run: python scripts/build_web.py
+
+ - name: Build vis UI bundle
+ run: python scripts/build_vis.py
+
- name: Install PyInstaller
run: uv pip install pyinstaller
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4abc556c..796f5911 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,9 @@ GitHub Releases page; `0.8.0` is the new starting line.
## Unreleased
+- **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404.
+- **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode.
+
## 0.40.0 (2026-06-10)
- **Web: same-origin WebSockets accepted, version banner synced to the backend, and token bootstrap race fixed.** The local-mode web server now auto-populates the allowed-origin list (an empty allowlist rejects every `Origin`-bearing request, which previously broke all WebSocket handshakes with a 403). The UI version banner prefers the version the running backend reports (via the config API) over the stale build-time constant, and a transient version-fetch failure no longer permanently disables the backend banner for the session. The initial auth-token bootstrap race that could fail the first request is resolved. `ESC` now reliably terminates only the background tasks spawned by the interrupted turn, and recall context is re-framed so prior-session snippets can't be misread as new instructions.
diff --git a/packages/linux-installer/pythinker.spec b/packages/linux-installer/pythinker.spec
index 0a6b208c..507e2328 100644
--- a/packages/linux-installer/pythinker.spec
+++ b/packages/linux-installer/pythinker.spec
@@ -3,8 +3,30 @@
# / tarball). Mode: --onedir — fpm wraps the directory into the package and
# install-native.sh tar-gzips it for the curl-bash flow.
+import importlib.util
+from pathlib import Path
+
from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata
+# The web/vis frontends are gitignored build artifacts. collect_data_files()
+# silently collects nothing when they are missing, which shipped installers
+# whose web UI answered "/" with a 404. Fail the freeze loudly instead.
+_pkg_spec = importlib.util.find_spec("pythinker_code")
+if _pkg_spec is None or _pkg_spec.origin is None:
+ raise SystemExit("pythinker.spec: pythinker_code is not installed in the build environment")
+_pkg_root = Path(_pkg_spec.origin).resolve().parent
+_missing_ui = [
+ rel
+ for rel in ("web/static/index.html", "vis/static/index.html")
+ if not (_pkg_root / rel).is_file()
+]
+if _missing_ui:
+ raise SystemExit(
+ f"pythinker.spec: UI bundles missing from pythinker_code ({', '.join(_missing_ui)}). "
+ "Run `make build-web build-vis` (or scripts/build_web.py and scripts/build_vis.py) "
+ "before freezing, or the packaged web UI will 404 on '/'."
+ )
+
block_cipher = None
hiddenimports = []
diff --git a/packages/windows-installer/pythinker.spec b/packages/windows-installer/pythinker.spec
index 51241d21..2aa430f6 100644
--- a/packages/windows-installer/pythinker.spec
+++ b/packages/windows-installer/pythinker.spec
@@ -2,8 +2,30 @@
# PyInstaller spec for the Pythinker Code Windows native build.
# Mode: --onedir (faster startup, fewer AV false-positives than --onefile).
+import importlib.util
+from pathlib import Path
+
from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata
+# The web/vis frontends are gitignored build artifacts. collect_data_files()
+# silently collects nothing when they are missing, which shipped installers
+# whose web UI answered "/" with a 404. Fail the freeze loudly instead.
+_pkg_spec = importlib.util.find_spec("pythinker_code")
+if _pkg_spec is None or _pkg_spec.origin is None:
+ raise SystemExit("pythinker.spec: pythinker_code is not installed in the build environment")
+_pkg_root = Path(_pkg_spec.origin).resolve().parent
+_missing_ui = [
+ rel
+ for rel in ("web/static/index.html", "vis/static/index.html")
+ if not (_pkg_root / rel).is_file()
+]
+if _missing_ui:
+ raise SystemExit(
+ f"pythinker.spec: UI bundles missing from pythinker_code ({', '.join(_missing_ui)}). "
+ "Run `make build-web build-vis` (or scripts/build_web.py and scripts/build_vis.py) "
+ "before freezing, or the packaged web UI will 404 on '/'."
+ )
+
block_cipher = None
hiddenimports = []
diff --git a/pythinker.spec b/pythinker.spec
index adcfeeaa..79ea9732 100644
--- a/pythinker.spec
+++ b/pythinker.spec
@@ -1,7 +1,11 @@
# -*- mode: python ; coding: utf-8 -*-
import os
-from pythinker_code.utils.pyinstaller import datas, hiddenimports
+from pythinker_code.utils.pyinstaller import datas, hiddenimports, require_ui_assets
+
+# Fail loud when the gitignored web/vis bundles haven't been built; a freeze
+# without them ships a binary whose web UI 404s on "/".
+require_ui_assets()
# Read codesign identity from environment variable (for macOS signing in CI)
codesign_identity = os.environ.get("APPLE_SIGNING_IDENTITY", None)
diff --git a/src/pythinker_code/utils/pyinstaller.py b/src/pythinker_code/utils/pyinstaller.py
index f767b286..c52a8719 100644
--- a/src/pythinker_code/utils/pyinstaller.py
+++ b/src/pythinker_code/utils/pyinstaller.py
@@ -1,9 +1,32 @@
from __future__ import annotations
+from pathlib import Path
+
from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata
from pythinker_code.cli._lazy_group import LazySubcommandGroup
+# The web/vis frontends are gitignored build artifacts synced into the package
+# by scripts/build_web.py and scripts/build_vis.py. collect_data_files()
+# silently collects nothing for missing globs, which froze builds whose web UI
+# answered "/" with a 404 (Windows/Linux native installers).
+_REQUIRED_UI_ASSETS = ("web/static/index.html", "vis/static/index.html")
+
+
+def require_ui_assets(package_root: Path | None = None) -> None:
+ """Abort the freeze when the web/vis UI bundles haven't been built."""
+ if package_root is None:
+ package_root = Path(__file__).resolve().parents[1]
+ missing = [rel for rel in _REQUIRED_UI_ASSETS if not (package_root / rel).is_file()]
+ if missing:
+ raise SystemExit(
+ "PyInstaller build aborted: UI bundles missing from pythinker_code "
+ f"({', '.join(missing)}). They are gitignored build artifacts; run "
+ "`make build-web build-vis` before freezing, or the packaged web UI "
+ "will 404 on '/'."
+ )
+
+
lazy_cli_hiddenimports = [
module_name
for module_name, _attribute_name, _help_text in (LazySubcommandGroup.lazy_subcommands.values())
diff --git a/src/pythinker_code/utils/server.py b/src/pythinker_code/utils/server.py
index 6c1d963f..d61cdd88 100644
--- a/src/pythinker_code/utils/server.py
+++ b/src/pythinker_code/utils/server.py
@@ -4,8 +4,11 @@
import importlib
import socket
+import sys
import textwrap
+from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled
+
# Shared "PYTHINKER" wordmark used by the web and vis startup banners.
PYTHINKER_BANNER_ART = [
"
██████╗ ██╗ ██╗████████╗██╗ ██╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ",
@@ -16,6 +19,37 @@
"╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝",
]
+# Single-cell ASCII stand-ins for every non-ASCII character the banners emit.
+# All replacements are 1:1 so box alignment is preserved. Used when the
+# terminal can't render Unicode (legacy Windows code pages such as cp1252,
+# TERM=dumb, or an explicit PYTHINKER_ASCII_UI/PYTHINKER_TUI_GLYPHS opt-in);
+# raw print() of the block art would otherwise garble or raise
+# UnicodeEncodeError on those streams.
+_BANNER_ASCII_FALLBACKS = str.maketrans(
+ {
+ "█": "#",
+ "╔": " ",
+ "╗": " ",
+ "╚": " ",
+ "╝": " ",
+ "║": " ",
+ "═": " ",
+ "➜": ">",
+ "•": "*",
+ "⚠": "!",
+ }
+)
+
+
+def _print_banner_line(text: str) -> None:
+ """Print one banner line, degrading to ASCII if the stream rejects it."""
+ try:
+ print(text)
+ except UnicodeEncodeError:
+ encoding = getattr(sys.stdout, "encoding", None) or "ascii"
+ fallback = text.translate(_BANNER_ASCII_FALLBACKS)
+ print(fallback.encode(encoding, errors="replace").decode(encoding, errors="replace"))
+
def get_address_family(host: str) -> socket.AddressFamily:
"""Return AF_INET6 for IPv6 addresses, AF_INET for IPv4 and hostnames."""
@@ -96,8 +130,39 @@ def get_network_addresses() -> list[str]:
return addresses
+def missing_ui_page(asset_path: str, build_command: str) -> str:
+ """HTML served on ``/`` when the bundled frontend assets are absent.
+
+ Packaged builds that skipped the frontend build would otherwise answer
+ ``/`` with a bare 404, which reads like a routing bug instead of a
+ packaging one.
+ """
+ return f"""
+
+
+
+ Pythinker UI unavailable
+
+
+ UI assets are missing
+ This Pythinker build was packaged without its frontend bundle
+ ({asset_path} was not found).
+ If you installed a packaged build (Windows installer, winget, scoop,
+ .deb/.rpm), update to the latest release or report this as a packaging
+ bug. When running from source, build the frontend first:
+ {build_command}
+ The REST API is unaffected and remains available under /api.
+
+
+"""
+
+
def print_banner(lines: list[str]) -> None:
"""Print a boxed banner with tag conventions (, ,
)."""
+ if ascii_glyphs_enabled():
+ lines = [line.translate(_BANNER_ASCII_FALLBACKS) for line in lines]
+
processed: list[str] = []
for line in lines:
if line == "
":
@@ -113,19 +178,21 @@ def strip_tags(s: str) -> str:
return s.removeprefix("").removeprefix("")
content_lines = [strip_tags(line) for line in processed if line != "
"]
- width = max(60, *(len(line) for line in content_lines))
+ # The leading 60 lives inside the list: `max(60, *[])` is a TypeError when
+ # every line is an
.
+ width = max([60, *(len(line) for line in content_lines)])
top = "+" + "=" * (width + 2) + "+"
- print(top)
+ _print_banner_line(top)
for line in processed:
if line == "
":
- print("|" + "-" * (width + 2) + "|")
+ _print_banner_line("|" + "-" * (width + 2) + "|")
elif line.startswith(""):
content = line.removeprefix("")
- print(f"| {content.center(width)} |")
+ _print_banner_line(f"| {content.center(width)} |")
elif line.startswith(""):
content = line.removeprefix("")
- print(f"| {content.ljust(width)} |")
+ _print_banner_line(f"| {content.ljust(width)} |")
else:
- print(f"| {line.ljust(width)} |")
- print(top)
+ _print_banner_line(f"| {line.ljust(width)} |")
+ _print_banner_line(top)
diff --git a/src/pythinker_code/vis/app.py b/src/pythinker_code/vis/app.py
index 48ac23a8..ad8bf2b9 100644
--- a/src/pythinker_code/vis/app.py
+++ b/src/pythinker_code/vis/app.py
@@ -10,6 +10,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
+from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pythinker_code.utils.server import (
@@ -18,6 +19,7 @@
format_url,
get_network_addresses,
is_local_host,
+ missing_ui_page,
print_banner,
)
from pythinker_code.vis.api import sessions_router, statistics_router, system_router
@@ -82,8 +84,18 @@ def create_app() -> FastAPI:
async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunction]
return {"status": "ok"}
- if STATIC_DIR.exists():
+ # Gate on index.html, not just the directory: a build that skipped the vis
+ # bundle would otherwise answer "/" with a bare 404 instead of a hint.
+ if (STATIC_DIR / "index.html").is_file():
application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+ else:
+
+ @application.get("/", include_in_schema=False)
+ async def missing_vis_ui() -> HTMLResponse: # pyright: ignore[reportUnusedFunction]
+ return HTMLResponse(
+ status_code=503,
+ content=missing_ui_page("pythinker_code/vis/static/index.html", "make build-vis"),
+ )
return application
diff --git a/src/pythinker_code/web/app.py b/src/pythinker_code/web/app.py
index d509a133..ebc12791 100644
--- a/src/pythinker_code/web/app.py
+++ b/src/pythinker_code/web/app.py
@@ -25,6 +25,7 @@
format_url,
get_network_addresses,
is_local_host,
+ missing_ui_page,
)
from pythinker_code.web.api import (
config_router,
@@ -218,9 +219,24 @@ async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunct
"""Health check endpoint."""
return {"status": "ok"}
- # Mount static files as fallback (must be last)
- if STATIC_DIR.exists():
+ # Mount static files as fallback (must be last). Gate on index.html, not
+ # just the directory: a build that skipped the web bundle can still ship
+ # the git-tracked brand/ files, and StaticFiles(html=True) would then 404
+ # on "/" with no hint of why.
+ if (STATIC_DIR / "index.html").is_file():
application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+ else:
+ logger.warning(
+ "Web UI assets missing at {static_dir}; serving setup instructions on /",
+ static_dir=STATIC_DIR,
+ )
+
+ @application.get("/", include_in_schema=False)
+ async def missing_web_ui() -> HTMLResponse: # pyright: ignore[reportUnusedFunction]
+ return HTMLResponse(
+ status_code=503,
+ content=missing_ui_page("pythinker_code/web/static/index.html", "make build-web"),
+ )
return application
diff --git a/tasks/todo.md b/tasks/todo.md
index 43879b94..c175bece 100644
--- a/tasks/todo.md
+++ b/tasks/todo.md
@@ -1,3 +1,61 @@
+# Task: Windows web 404 + PowerShell banner rendering (2026-06-10)
+
+## Diagnosis (verified)
+
+1. **`GET /?token=…` → 404 on Windows.** `src/pythinker_code/web/static/` and
+ `vis/static/` are gitignored build artifacts (`.gitignore:59`); only two
+ `web/static/brand/` files are tracked. `windows-installer.yml` and
+ `linux-installer.yml` freeze with PyInstaller **without building the web/vis
+ frontends** (unlike `release-pythinker-cli.yml`, which sets up Node and runs
+ the builds — the published 0.40.0 PyPI wheel contains all 448 web/static
+ files, so pip installs are fine). `collect_data_files()` silently collects
+ only the brand files → frozen app's `STATIC_DIR.exists()` is True but
+ `index.html` is missing → `StaticFiles(html=True)` 404s on `/`.
+2. **Banner garbled in PowerShell.** `print_banner()` (utils/server.py) raw-
+ prints Unicode block/box art. Verified it cannot encode to cp1252 →
+ UnicodeEncodeError on redirected Windows stdout, garbling in legacy
+ consoles. Existing `ascii_glyphs_enabled()` fallback infra is bypassed.
+ (Alignment itself is correct — all banner lines are exactly equal width;
+ the "long line" in the report was a paste artifact.)
+
+## Plan
+
+- [x] utils/server.py: ASCII fallback translation (1:1 width-preserving) wired
+ to `ascii_glyphs_enabled()` + crash-proof printing on UnicodeEncodeError.
+ → verified: 6 new tests in tests/utils/test_server.py + live cp1252 run
+- [x] web/app.py + vis/app.py: gate mount on `index.html`, serve explanatory
+ 503 page instead of bare 404 when assets missing.
+ → verified: tests/web/test_web_ui_assets.py (web + vis)
+- [x] utils/pyinstaller.py helper + root pythinker.spec + both installer specs:
+ fail the freeze loudly when web/vis bundles are missing.
+ → verified: 2 tests in tests/utils/test_pyinstaller_utils.py + default-path run
+- [x] windows-installer.yml + linux-installer.yml: set up Node and build
+ web/vis bundles before PyInstaller (mirrors release-pythinker-cli.yml).
+ → verified: YAML parses; spec syntax parses; spec guard enforces assets
+- [x] CHANGELOG.md Unreleased entry.
+- [x] Run ruff + pyright + targeted pytest.
+- [x] Bonus bug found during run: `print_banner` crashed with TypeError on an
+ all-`
` banner (`max(60, *[])`); fixed + regression test.
+
+## Review
+
+- Verification: `ruff check src tests` clean, `ruff format --check` clean,
+ `pyright` 0 errors on all touched src files (strict mode), typos clean,
+ 428 passed / 1 skipped across tests/utils + tests/web + tests/vis.
+- Live repro of the user scenario: `PYTHONIOENCODING=cp1252` run now prints a
+ perfectly aligned ASCII banner instead of raising UnicodeEncodeError.
+- clean-code-guard pass: no violations; one documented exception — the
+ required-UI-assets check is inlined in both installer specs in addition to
+ the shared `require_ui_assets()` helper, because those specs are
+ deliberately self-contained (their own comments forbid importing the shared
+ datas module).
+- Out of scope (observed, not changed): `except (ImportError, Exception)` at
+ utils/server.py get_network_addresses is redundant (Exception covers
+ ImportError) — pre-existing, harmless; `➜`/`⚠` may render double-width in
+ some Windows fonts (cosmetic; ASCII opt-ins now cover it).
+
+---
+
# Codex TUI adoption — Phase 1 (foundation)
Source of truth: `blackbox/codex-main/codex-rs/tui`. Backlog: user-provided 48-item
diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py
index 0dbe0580..27edaa47 100644
--- a/tests/utils/test_pyinstaller_utils.py
+++ b/tests/utils/test_pyinstaller_utils.py
@@ -5,6 +5,7 @@
from importlib.metadata import version
from pathlib import Path
+import pytest
from inline_snapshot import snapshot
@@ -332,3 +333,23 @@ def test_pyinstaller_hiddenimports_include_lazy_cli_subcommands():
}
assert expected_hiddenimports <= set(hiddenimports)
+
+
+def test_require_ui_assets_accepts_built_tree(tmp_path: Path) -> None:
+ from pythinker_code.utils.pyinstaller import require_ui_assets
+
+ for rel in ("web/static/index.html", "vis/static/index.html"):
+ target = tmp_path / rel
+ target.parent.mkdir(parents=True)
+ target.write_text("")
+
+ require_ui_assets(tmp_path) # must not raise
+
+
+def test_require_ui_assets_rejects_unbuilt_tree(tmp_path: Path) -> None:
+ """A freeze without the gitignored web/vis bundles ships a web UI that
+ 404s on "/"; the guard must abort the build with an actionable message."""
+ from pythinker_code.utils.pyinstaller import require_ui_assets
+
+ with pytest.raises(SystemExit, match="build-web"):
+ require_ui_assets(tmp_path)
diff --git a/tests/utils/test_server.py b/tests/utils/test_server.py
index 0278c111..ee7ff0dd 100644
--- a/tests/utils/test_server.py
+++ b/tests/utils/test_server.py
@@ -5,11 +5,13 @@
import pytest
from pythinker_code.utils.server import (
+ PYTHINKER_BANNER_ART,
find_available_port,
format_url,
get_address_family,
get_network_addresses,
is_local_host,
+ print_banner,
)
# ---------------------------------------------------------------------------
@@ -119,3 +121,99 @@ def test_returns_list(self) -> None:
def test_no_loopback(self) -> None:
for addr in get_network_addresses():
assert not addr.startswith("127.")
+
+
+# ---------------------------------------------------------------------------
+# print_banner — Unicode wordmark with ASCII fallbacks for legacy terminals
+# ---------------------------------------------------------------------------
+
+
+_GLYPH_ENV_VARS = ("PYTHINKER_TUI_GLYPHS", "PYTHINKER_ASCII_UI", "PYTHINKER_SAFE_GLYPHS")
+
+
+def _sample_banner_lines() -> list[str]:
+ return [
+ *PYTHINKER_BANNER_ART,
+ "",
+ "WEB UI (Technical Preview)",
+ "
",
+ " ➜ Local http://localhost:5494/?token=abc123",
+ " • Use -n / --network to share on LAN",
+ " ⚠ Sensitive APIs are restricted",
+ ]
+
+
+class TestPrintBanner:
+ def test_all_lines_share_one_width(
+ self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ for var in _GLYPH_ENV_VARS:
+ monkeypatch.delenv(var, raising=False)
+ print_banner(_sample_banner_lines())
+ widths = {len(line) for line in capsys.readouterr().out.splitlines()}
+ assert len(widths) == 1
+
+ def test_ascii_opt_in_strips_unicode_and_keeps_alignment(
+ self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("PYTHINKER_ASCII_UI", "1")
+ print_banner(_sample_banner_lines())
+ out = capsys.readouterr().out
+ assert out.isascii()
+ assert "#" in out # wordmark survives as its block silhouette
+ assert len({len(line) for line in out.splitlines()}) == 1
+
+ def test_legacy_windows_codepage_stdout_does_not_crash(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """PowerShell with redirected output encodes stdout as cp1252.
+
+ The block/box wordmark is not representable there; the banner must
+ degrade to ASCII instead of raising UnicodeEncodeError at startup.
+ """
+ import io
+ import sys
+
+ for var in _GLYPH_ENV_VARS:
+ monkeypatch.delenv(var, raising=False)
+ buffer = io.BytesIO()
+ stream = io.TextIOWrapper(buffer, encoding="cp1252", newline="")
+ monkeypatch.setattr(sys, "stdout", stream)
+ print_banner(_sample_banner_lines())
+ stream.flush()
+ out = buffer.getvalue().decode("cp1252")
+ assert "#" in out
+ assert "+" in out # box border intact
+
+ def test_stream_rejecting_unicode_falls_back_per_line(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Even when encoding detection can't see the limitation, a stream
+ that raises UnicodeEncodeError gets the ASCII rendition, not a crash."""
+ import io
+ import sys
+
+ for var in _GLYPH_ENV_VARS:
+ monkeypatch.delenv(var, raising=False)
+
+ class _StrictAsciiStdout(io.TextIOBase):
+ def __init__(self) -> None:
+ self.chunks: list[str] = []
+
+ def write(self, s: str) -> int:
+ s.encode("ascii")
+ self.chunks.append(s)
+ return len(s)
+
+ stream = _StrictAsciiStdout()
+ monkeypatch.setattr(sys, "stdout", stream)
+ print_banner(_sample_banner_lines())
+ out = "".join(stream.chunks)
+ assert "#" in out
+ assert "share on LAN" in out
+
+ def test_hr_only_banner_does_not_crash(self, capsys: pytest.CaptureFixture[str]) -> None:
+ """`max(60, *[])` is a TypeError; the minimum width must apply."""
+ print_banner(["
"])
+ out = capsys.readouterr().out.splitlines()
+ assert len({len(line) for line in out}) == 1
diff --git a/tests/web/test_web_ui_assets.py b/tests/web/test_web_ui_assets.py
new file mode 100644
index 00000000..b6046d80
--- /dev/null
+++ b/tests/web/test_web_ui_assets.py
@@ -0,0 +1,39 @@
+"""When the bundled frontend is missing, "/" must explain why, not 404.
+
+Regression guard for the native installers that froze without building the
+gitignored web/vis bundles: the served app answered ``GET /?token=...`` with a
+bare 404 (see windows-installer.yml / linux-installer.yml web build steps).
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from starlette.testclient import TestClient
+
+
+def test_web_root_explains_missing_assets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ import pythinker_code.web.app as web_app
+
+ monkeypatch.setattr(web_app, "STATIC_DIR", tmp_path)
+ app = web_app.create_app(session_token="test-token")
+ client = TestClient(app)
+ client.cookies.set("session_token", "test-token")
+
+ resp = client.get("/")
+
+ assert resp.status_code == 503
+ assert "make build-web" in resp.text
+ assert "pythinker_code/web/static/index.html" in resp.text
+
+
+def test_vis_root_explains_missing_assets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ import pythinker_code.vis.app as vis_app
+
+ monkeypatch.setattr(vis_app, "STATIC_DIR", tmp_path)
+ with TestClient(vis_app.create_app()) as client:
+ resp = client.get("/")
+
+ assert resp.status_code == 503
+ assert "make build-vis" in resp.text