Files
spoopyghosty0 c477070772 GUI: plainer words, readable dim text, quieter empty tabs, one way to connect, portal switches
- Plain words: patch titles lose bracketed internals unless Show technical details is on (C.patch_title); a passed launch test just says so, how far it got shows only when it fails (C.test_line); Frame page readiness says Quest games / PC VR games and storage drops the file system (reason in a tooltip); the failure pop-up says where it stopped, that Resume continues and that saves and signing keys are kept. Engine and VR API stay on the game page; the OpenXR runtime and game launch capacity checks stay; the first start still names its tools.
- Readable dim text: TEXT_3 is #868A96 (Portal, Portal OLED) and #868B9C (Original), at least 4.5:1 on the window, sidebar, cards and raised panels (was 3.6-4.4:1); a test keeps the built-in themes there.
- Quieter empty tabs: the not-connected Files, Screenshots, Live view, Type on Frame and Monitor tabs show one sentence and Connect above a faint sketch of the tab (C.ghost_preview) instead of three feature cards; the Live view shows one Start button and no feature cards.
- The connect page has one way in: "Set up your Frame" up front (no password, nothing changes until you allow it), "Already set up?" below, the address and password behind "Connect by address…"; no 1/2/3 numbers.
- Consistency: every switch is C.switch, with the portal gradient painted under a transparent track (the real Switch keeps keyboard, focus and semantics), a dark thumb with a check when on and a grey one with a cross when off (the same size); where a patch came from is blue information, not orange; the platform filter says "Quest/Android"; the welcome screen says "Set up your Frame".
- Showcase: the live-view shot waits for "Ready when you are"; the install video clicks "Set up your Frame".
2026-10-12 15:46:14 -04:00

180 lines
8.7 KiB
Python

"""Colour themes (ui.theme: built-ins and installed theme files) and FramePort's own icons (ui.glyphs)."""
from __future__ import annotations
import ast
import json
from pathlib import Path
import flet as ft
import pytest
from frameport.ui import components as C
from frameport.ui import glyphs
from frameport.ui import theme as T
from frameport.ui.views import activity
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "src" / "frameport" / "ui"
@pytest.fixture(autouse=True)
def default_theme():
T.set_theme(T.DEFAULT_THEME)
yield
for tid in [t for t in T.THEMES if t.startswith(T.USER_PREFIX)]:
del T.THEMES[tid]
T.set_theme(T.DEFAULT_THEME)
def test_builtin_themes():
assert list(T.BUILTIN) == ["portal", "portal_oled", "original"]
for tid, theme in T.BUILTIN.items():
assert set(theme["colors"]) == set(T.TOKENS), tid
assert all(v.startswith("#") and len(v) == 7 for v in theme["colors"].values()), tid
T.parse_theme({"base": tid, "colors": theme["colors"]}) # every built-in passes the theme-file checks
assert T.BUILTIN["portal_oled"]["colors"]["BG"] == "#000000"
assert T.BUILTIN["portal"]["dual"] and not T.BUILTIN["original"]["dual"]
def test_set_theme_switches_tokens_and_falls_back():
T.set_theme("original")
assert (T.THEME, T.ACCENT, T.DUAL) == ("original", T.BUILTIN["original"]["colors"]["ACCENT"], False)
T.set_theme("classic") # the earlier name
assert T.THEME == "original"
T.set_theme("no-such-theme")
assert (T.THEME, T.ACCENT, T.DUAL) == (T.DEFAULT_THEME, T.BUILTIN[T.DEFAULT_THEME]["colors"]["ACCENT"], True)
assert T.theme_from_setting(None) == T.DEFAULT_THEME
def test_module_level_maps_follow_the_theme():
T.set_theme("original")
assert C.STATUS_STYLE["works"][1] == T.BUILTIN["original"]["colors"]["OK"]
assert C.INSTALL_STYLE["on_pc"][2] == T.BUILTIN["original"]["colors"]["PC"]
assert activity.STATE_STYLE["running"][1] == T.BUILTIN["original"]["colors"]["ACCENT"]
T.set_theme("portal")
assert C.STATUS_STYLE["works"][1] == T.BUILTIN["portal"]["colors"]["OK"]
assert activity.CHECK_ICON[False][1] == T.BUILTIN["portal"]["colors"]["ERROR"]
def test_no_theme_colour_is_frozen_in_a_default_argument():
"""A default like color=T.TEXT keeps the colour of the theme active at import (a theme switch wouldn't reach it)."""
found = []
for f in UI.rglob("*.py"):
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
for d in node.args.defaults + [d for d in node.args.kw_defaults if d is not None]:
for sub in ast.walk(d):
if isinstance(sub, ast.Attribute) and isinstance(sub.value, ast.Name) \
and sub.value.id == "T" and sub.attr in T.TOKENS:
found.append(f"{f.name}:{d.lineno} T.{sub.attr}")
# updater._notes_style's inner text() is defined anew on every call: its default is current
assert [x for x in found if not x.startswith("updater.py")] == []
def test_theme_file_partial_and_validated():
t = T.parse_theme({"name": "Ember", "colors": {"accent": "#f80", "secondary": "#00A0FF"}})
assert t["name"] == "Ember" and t["dual"] is True
assert t["colors"]["ACCENT"] == "#FF8800" and t["colors"]["SECONDARY"] == "#00A0FF"
assert t["colors"]["BG"] == T.BUILTIN["portal"]["colors"]["BG"] # the rest comes from the base
assert T.parse_theme({"base": "original", "colors": {}})["dual"] is False
bad = [({"colors": {"ACCENT": "orange"}}, "not a color"),
({"colors": {"GLOW": "#FFFFFF"}}, "unknown color"),
({"colors": {"BG": "#F4F4F4", "TEXT": "#111111"}}, "too light"),
({"colors": {"TEXT": "#202020"}}, "hard to read"),
({"base": "nope"}, "isn't a built-in"),
({"colours": {}}, "unknown field"),
([1, 2], "isn't a theme")]
for data, message in bad:
with pytest.raises(T.ThemeError, match=message):
T.parse_theme(data)
def test_install_load_and_remove_theme_files(tmp_path):
src = tmp_path / "download.json"
src.write_text(json.dumps({"name": "Ember Night", "colors": {"ACCENT": "#FFAA00"}}), encoding="utf-8")
folder = tmp_path / "themes"
tid = T.install_theme(src, folder)
assert tid == "user:ember-night" and (folder / "ember-night.json").is_file()
T.set_theme(tid)
assert (T.THEME, T.ACCENT) == (tid, "#FFAA00")
(folder / "broken.json").write_text("{not json", encoding="utf-8")
problems = T.load_user_themes(folder)
assert tid in T.THEMES and problems and problems[0].startswith("broken.json: not valid JSON")
T.remove_theme(tid)
assert tid not in T.THEMES and not (folder / "ember-night.json").exists()
assert T.theme_from_setting(tid) == T.DEFAULT_THEME # a removed theme falls back
with pytest.raises(T.ThemeError):
T.remove_theme("portal")
def test_exported_theme_reinstalls(tmp_path):
f = tmp_path / "mine.json"
f.write_text(T.theme_json("portal_oled"), encoding="utf-8")
assert T.read_theme_file(f)["colors"] == T.BUILTIN["portal_oled"]["colors"]
def test_documented_example_theme_is_valid():
t = T.read_theme_file(ROOT / "docs" / "themes" / "example-theme.json")
assert t["name"] and t["dual"]
def test_glyphs_install_and_tint(tmp_path):
folder = glyphs.install(tmp_path)
names = {p.stem for p in folder.glob("*.svg")}
assert {"logo", "logo-solid", "frame", "port", "test", "pc"} <= names
assert glyphs.path(glyphs.FRAME, tmp_path) == folder / "frame.svg"
before = (folder / "frame.svg").stat().st_mtime_ns
glyphs.install(tmp_path) # unchanged files aren't rewritten
assert (folder / "frame.svg").stat().st_mtime_ns == before
assert glyphs.is_glyph(glyphs.TEST) and not glyphs.is_glyph(ft.Icons.TUNE_ROUNDED)
def test_transit_portal_layers(tmp_path):
import re
colors = {"line": "#ffffff", "body": "#0d0e12", "screen": "#3aa8ff", "lens": "#ff8a1f", "near": "#3aa8ff",
"far": "#ff8a1f", "hole": "#08080b"}
parts = glyphs.transit_parts(tmp_path, colors)
assert {"monitor", "headset", "portal", "portal_back", "portal_front"} <= set(parts)
svg = {k: parts[k].read_text(encoding="utf-8") for k in ("portal", "portal_back", "portal_front")}
boxes = {re.search(r'viewBox="([^"]+)"', s).group(1) for s in svg.values()}
assert len(boxes) == 1 # the layers line up exactly
assert tuple(map(float, boxes.pop().split()[2:])) == glyphs.TRANSIT_SIZES["portal"]
back, front = svg["portal_back"], svg["portal_front"]
assert 'fill="#08080B"' in back and 'fill="#08080B"' not in front # the hole stays under the cover
assert 'fill="#000"' in back and "#000\"" not in front # the shadow too
assert "#3AA8FF" in front and "#FF8A1F" in front # the rim's gradient (it fades across the halves)
def xs(s): # x of the rim arcs' points (unrotated ellipse, centre x 32)
return [float(x) for d in re.findall(r'<path d="([^"]+)"', s)
for x in re.findall(r"(?:M|A[\d.]+ [\d.]+ 0 0 1 )([\d.]+) [\d.]+", d)]
assert xs(back) and max(xs(back)) < 32.5 # the near (PC) half, a little past the split
assert xs(front) and min(xs(front)) >= 32 # the far (Frame) half
assert glyphs.transit_parts(tmp_path, colors) == parts # cached per colour set
def test_wordmark_portal_follows_the_theme(tmp_path):
a = glyphs.portal_mark(tmp_path, "#ff8a1f", "#3AA8FF", "#0D0E12")
svg = a.read_text(encoding="utf-8")
assert "#FF8A1F" in svg and "#3AA8FF" in svg and "#0D0E12" in svg
assert glyphs.portal_mark(tmp_path, "#FF8A1F", "#3AA8FF", "#0D0E12") == a # cached per colour set
assert glyphs.portal_mark(tmp_path, "#FF5A36", "#5CE1E6", "#0D0E12") != a # an installed theme gets its own
T.set_theme("original")
assert isinstance(C.wordmark(17), ft.Text) # single-accent themes keep the plain wordmark
def test_as_icon_turns_glyphs_into_tinted_images():
img = C.as_icon(glyphs.FRAME, 20, T.ACCENT)
assert isinstance(img, ft.Image) and img.color == T.ACCENT and img.src.endswith("frame.svg")
assert isinstance(C.as_icon(ft.Icons.TUNE_ROUNDED, 20), ft.Icon)
assert C._btn_icon(ft.Icons.TUNE_ROUNDED, T.TEXT) == ft.Icons.TUNE_ROUNDED # buttons colour Material icons
def test_builtin_text_is_readable_on_every_card_surface():
# TEXT_3 carries the 11-12 px meta text (C.meta): it was #717583 (3.6:1 on SURFACE_2), now >= 4.5:1 (WCAG AA)
for name, theme in T.BUILTIN.items():
c = theme["colors"]
for token in ("TEXT", "TEXT_2", "TEXT_3"):
for surface in ("BG", "SIDEBAR", "SURFACE", "SURFACE_2"):
assert T.contrast(c[token], c[surface]) >= 4.5, (name, token, surface)