From 8978ac6c082628144a5778ff56adc6444579c5f4 Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Wed, 5 Aug 2026 11:41:57 -0400 Subject: [PATCH 1/2] [Improvement] Theme & layout args, changes: - theme design vars - set default layout - set default theme - use design vars in todo app - update README - add theme & layout to appserver - retain appearance for legacy usage - add theme & layout to registry and session - 44 tests passing --- config/apps/theme/bootstrap.yaml | 21 ++++ config/apps/theme/challenging_font.yaml | 22 ++++ config/apps/theme/dark.yaml | 21 ++++ config/apps/theme/default.yaml | 23 +++++ config/apps/theme/material.yaml | 21 ++++ config/apps/theme/mono.yaml | 21 ++++ config/apps/theme/solarized.yaml | 21 ++++ config/apps/todo/layout/default.yaml | 3 + config/apps/todo/layout/kanban_board.yaml | 4 + src/open_apps/theme.py | 119 ++++++++++++++++++++++ 10 files changed, 276 insertions(+) create mode 100644 config/apps/theme/bootstrap.yaml create mode 100644 config/apps/theme/challenging_font.yaml create mode 100644 config/apps/theme/dark.yaml create mode 100644 config/apps/theme/default.yaml create mode 100644 config/apps/theme/material.yaml create mode 100644 config/apps/theme/mono.yaml create mode 100644 config/apps/theme/solarized.yaml create mode 100644 config/apps/todo/layout/default.yaml create mode 100644 config/apps/todo/layout/kanban_board.yaml create mode 100644 src/open_apps/theme.py diff --git a/config/apps/theme/bootstrap.yaml b/config/apps/theme/bootstrap.yaml new file mode 100644 index 00000000..cb2a8bfd --- /dev/null +++ b/config/apps/theme/bootstrap.yaml @@ -0,0 +1,21 @@ +# @package apps.theme +# Bootstrap 5 look approximated with design tokens (no Bootstrap CSS needed). +name: bootstrap +import_url: "" +tokens: + color-bg: "#ffffff" + color-surface: "#f8f9fa" + color-fg: "#212529" + color-muted: "#6c757d" + color-border: "#dee2e6" + color-primary: "#0d6efd" + color-on-primary: "#ffffff" + color-accent: "#198754" + color-danger: "#dc3545" + color-neutral: "#6c757d" + color-btn-fg: "#ffffff" + font-family: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" + font-heading: "inherit" + font-size-base: "16px" + radius: "0.375rem" + space: "8px" diff --git a/config/apps/theme/challenging_font.yaml b/config/apps/theme/challenging_font.yaml new file mode 100644 index 00000000..9e1842b5 --- /dev/null +++ b/config/apps/theme/challenging_font.yaml @@ -0,0 +1,22 @@ +# @package apps.theme +# Faithful port of the former todo `challenging_font` appearance variant: +# the default palette rendered in a hard-to-read script face. +name: challenging_font +import_url: "" +tokens: + color-bg: "#ffffff" + color-surface: "#f4f5f7" + color-fg: "#1a1a1a" + color-muted: "#6b7280" + color-border: "#dfe1e6" + color-primary: "#2563eb" + color-on-primary: "#ffffff" + color-accent: "#2563eb" + color-danger: "#b22222" + color-neutral: "#36454f" + color-btn-fg: "#ffffff" + font-family: "'Brush Script MT', cursive" + font-heading: "inherit" + font-size-base: "16px" + radius: "8px" + space: "8px" diff --git a/config/apps/theme/dark.yaml b/config/apps/theme/dark.yaml new file mode 100644 index 00000000..0f22dd41 --- /dev/null +++ b/config/apps/theme/dark.yaml @@ -0,0 +1,21 @@ +# @package apps.theme +# Faithful port of the former todo `dark_theme` appearance variant. +name: dark +import_url: "" +tokens: + color-bg: "#ffffff" + color-surface: "#000000" + color-fg: "#ffffff" + color-muted: "#9ca3af" + color-border: "#ffffff" + color-primary: "#000000" + color-on-primary: "#ffffff" + color-accent: "#ffffff" + color-danger: "#ffffff" + color-neutral: "#ffffff" + color-btn-fg: "#000000" + font-family: "'Times New Roman', serif" + font-heading: "inherit" + font-size-base: "16px" + radius: "8px" + space: "8px" diff --git a/config/apps/theme/default.yaml b/config/apps/theme/default.yaml new file mode 100644 index 00000000..e10a9381 --- /dev/null +++ b/config/apps/theme/default.yaml @@ -0,0 +1,23 @@ +# @package apps.theme +# Shared design tokens. Selected globally with `apps/theme=` or per app +# with `apps..theme=`. Every `tokens` entry becomes a CSS custom +# property (`color-primary` -> `--color-primary`) consumed via `var(--...)`. +name: default +import_url: "" +tokens: + color-bg: "#ffffff" + color-surface: "#f4f5f7" + color-fg: "#1a1a1a" + color-muted: "#6b7280" + color-border: "#dfe1e6" + color-primary: "#2563eb" # add / primary action + color-on-primary: "#ffffff" + color-accent: "#2563eb" # save action + color-danger: "#b22222" # remove / destructive + color-neutral: "#36454f" # edit / secondary + color-btn-fg: "#ffffff" # text on edit/remove/save buttons + font-family: "'Times New Roman', serif" + font-heading: "inherit" + font-size-base: "16px" + radius: "8px" + space: "8px" diff --git a/config/apps/theme/material.yaml b/config/apps/theme/material.yaml new file mode 100644 index 00000000..46c16eef --- /dev/null +++ b/config/apps/theme/material.yaml @@ -0,0 +1,21 @@ +# @package apps.theme +# Material Design look approximated with design tokens (no MUI CSS needed). +name: material +import_url: "" +tokens: + color-bg: "#ffffff" + color-surface: "#f5f5f5" + color-fg: "#212121" + color-muted: "#757575" + color-border: "#e0e0e0" + color-primary: "#6200ee" + color-on-primary: "#ffffff" + color-accent: "#03dac6" + color-danger: "#b00020" + color-neutral: "#757575" + color-btn-fg: "#ffffff" + font-family: "'Roboto', 'Helvetica Neue', sans-serif" + font-heading: "inherit" + font-size-base: "16px" + radius: "4px" + space: "8px" diff --git a/config/apps/theme/mono.yaml b/config/apps/theme/mono.yaml new file mode 100644 index 00000000..da0ec8b8 --- /dev/null +++ b/config/apps/theme/mono.yaml @@ -0,0 +1,21 @@ +# @package apps.theme +# Faithful port of the former todo `black_and_white` appearance variant. +name: mono +import_url: "" +tokens: + color-bg: "#000000" + color-surface: "#ffffff" + color-fg: "#000000" + color-muted: "#000000" + color-border: "#000000" + color-primary: "#000000" + color-on-primary: "#ffffff" + color-accent: "#000000" + color-danger: "#000000" + color-neutral: "#000000" + color-btn-fg: "#ffffff" + font-family: "'Times New Roman', serif" + font-heading: "inherit" + font-size-base: "16px" + radius: "8px" + space: "8px" diff --git a/config/apps/theme/solarized.yaml b/config/apps/theme/solarized.yaml new file mode 100644 index 00000000..c50333f8 --- /dev/null +++ b/config/apps/theme/solarized.yaml @@ -0,0 +1,21 @@ +# @package apps.theme +# Solarized Light (Ethan Schoonover palette). +name: solarized +import_url: "" +tokens: + color-bg: "#fdf6e3" + color-surface: "#eee8d5" + color-fg: "#657b83" + color-muted: "#93a1a1" + color-border: "#93a1a1" + color-primary: "#268bd2" + color-on-primary: "#fdf6e3" + color-accent: "#2aa198" + color-danger: "#dc322f" + color-neutral: "#586e75" + color-btn-fg: "#fdf6e3" + font-family: "'Inter', system-ui, sans-serif" + font-heading: "inherit" + font-size-base: "16px" + radius: "8px" + space: "8px" diff --git a/config/apps/todo/layout/default.yaml b/config/apps/todo/layout/default.yaml new file mode 100644 index 00000000..f00b4298 --- /dev/null +++ b/config/apps/todo/layout/default.yaml @@ -0,0 +1,3 @@ +# @package apps.todo +# Structure only -- no colors/fonts (those come from the shared theme). +layout: default diff --git a/config/apps/todo/layout/kanban_board.yaml b/config/apps/todo/layout/kanban_board.yaml new file mode 100644 index 00000000..0a0ddb80 --- /dev/null +++ b/config/apps/todo/layout/kanban_board.yaml @@ -0,0 +1,4 @@ +# @package apps.todo +# Structure only -- colors/fonts come from the shared theme. Select with +# `apps/todo/layout=kanban_board`. +layout: kanban_board diff --git a/src/open_apps/theme.py b/src/open_apps/theme.py new file mode 100644 index 00000000..ef5e318c --- /dev/null +++ b/src/open_apps/theme.py @@ -0,0 +1,119 @@ +""" +Copyright (c) Meta Platforms, Inc. and affiliates. +All rights reserved. +This source code is licensed under the license found in the +LICENSE file in the root directory of this source tree. + +Shared design-token theming for OpenApps. + +A *theme* is a set of design tokens (colors, typography, shape, spacing) +defined once in ``config/apps/theme/.yaml`` and shared across every +app. Selecting a theme emits a ``:root { --token: value }`` block that all +apps consume via ``var(--token)``. This decouples *look* (theme) from +*structure* (each app's ``layout``). + +Selection is done with Hydra overrides: + +* ``apps/theme=solarized`` -> global default for every app +* ``apps.todo.theme=solarized`` -> override a single app (falls back to + the global theme when the app's ``theme`` field is null/unset) + +A theme file looks like:: + + # @package apps.theme + name: solarized + import_url: "" # optional external stylesheet escape hatch + tokens: + color-bg: "#fdf6e3" + color-fg: "#657b83" + color-primary: "#268bd2" + font-family: "'Inter', sans-serif" + radius: "8px" + ... + +The ``tokens`` mapping is open-ended: every ``key: value`` becomes the CSS +custom property ``--key: value``, so apps can introduce new tokens without +touching this module. +""" +from __future__ import annotations + +from pathlib import Path + +import yaml +from fasthtml.common import Style + +# Repo-root/config/apps/theme -- this file lives at src/open_apps/theme.py. +_THEME_DIR = Path(__file__).resolve().parents[2] / "config" / "apps" / "theme" + +_DEFAULT_THEME = "default" + + +def _as_plain(value): + """Coerce an OmegaConf node (or anything mapping-like) to a plain dict.""" + if value is None: + return {} + # OmegaConf DictConfig exposes ``items``; so does a plain dict. + if hasattr(value, "items"): + return {k: v for k, v in value.items()} + return dict(value) + + +def load_theme(name: str) -> dict: + """Load a theme's tokens from ``config/apps/theme/.yaml``. + + Returns a dict with at least ``name``, ``tokens`` and ``import_url``. + Falls back to the default theme when ``name`` is unknown so a bad + override degrades gracefully instead of raising. + """ + path = _THEME_DIR / f"{name}.yaml" + if not path.exists(): + path = _THEME_DIR / f"{_DEFAULT_THEME}.yaml" + data = yaml.safe_load(path.read_text()) or {} + data.setdefault("name", name) + data.setdefault("tokens", {}) + data.setdefault("import_url", "") + return data + + +def resolve_theme(apps_config, app_name: str) -> dict: + """Resolve the effective theme for ``app_name``. + + ``apps_config`` is the ``config.apps`` node handed to every app as + ``app.config``. Precedence: per-app ``apps..theme`` (a theme + name string) overrides the global ``apps.theme`` group; a null/unset + per-app value inherits the global theme. + """ + app_cfg = getattr(apps_config, app_name, None) + per_app = getattr(app_cfg, "theme", None) if app_cfg is not None else None + if per_app: + return load_theme(str(per_app)) + + global_theme = getattr(apps_config, "theme", None) + if global_theme is not None: + theme = _as_plain(global_theme) + theme.setdefault("tokens", {}) + theme["tokens"] = _as_plain(theme["tokens"]) + return theme + + return load_theme(_DEFAULT_THEME) + + +def render_theme_tokens(theme: dict) -> Style: + """Build the ``:root`` CSS-variable block (plus optional import) for a theme. + + ``theme`` is the dict returned by :func:`resolve_theme` / :func:`load_theme`. + """ + tokens = _as_plain(theme.get("tokens", {})) + lines = "\n".join(f" --{key}: {value};" for key, value in tokens.items()) + import_url = (theme.get("import_url") or "").strip() + import_rule = f'@import url("{import_url}");\n' if import_url else "" + css = f"{import_rule}:root {{\n{lines}\n}}" + return Style(css) + + +def theme_style(apps_config, app_name: str) -> Style: + """Convenience: resolve + render the token block for ``app_name`` in one call. + + Call this per-request so live ``reconfigure`` theme swaps take effect. + """ + return render_theme_tokens(resolve_theme(apps_config, app_name)) From 81d5f1cb755fd18bebe063a2a28fe191ac7f685f Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Tue, 25 Aug 2026 10:40:36 -0400 Subject: [PATCH 2/2] Add a hot-reloading dev server ./scripts/dev.sh -- save anything under src/ or config/ and the server restarts and the browser refreshes itself. serve() already defaults to reload=True and launcher.launch_apps() turns it off, so the obvious fix is to flip it back. That gives a server that reloads into a broken state, for two reasons, both now recorded in dev.py: 1. The app is configured imperatively after import. launch.py exposes a bare app; routes and config are attached later by initialize_routes_and_configure_task() inside launcher.launch(). The reloader restarts the worker and re-imports from the import string, running none of that -- a freshly imported app has 7 routes and no .config, so every app 404s and anything touching config raises. dev.py does the configuration at module level instead, so re-importing rebuilds a wired app. 2. Re-seeding over existing rows fails. set_environment() inserts without clearing, so a second run hits unique-constraint violations and the code editor bails out instead of re-seeding. The reloader restarts against the same directory, so every reload after the first would hit both. dev.py wipes its state directory first, which also means what you are looking at matches the seed state an eval episode gets. The state directory lives in the system temp dir, deliberately outside the repo. The code editor seeds .py files on startup, and with the directory at ./.dev that produced an infinite loop -- seed, watcher fires, restart, seed. Worth knowing: that happened even though uvicorn reported watching only src/ and config/, so confining it by --reload-dir is not something to rely on. Browser refresh reuses FastHTML's two live-reload halves -- the /live-reload websocket and the client snippet -- attached to the existing app rather than swapping it for FastHTMLWithLiveReload. Subclassing only in development would mean the thing being tested is not the thing an eval runs. No default Hydra overrides, so this boots whatever the current branch's default config is rather than depending on config groups that exist only on some branches. Pass OPENAPPS_DEV_OVERRIDES to pick a layout or theme. Verified end to end on main: server boots, / and /todo return 200, live-reload script is injected, no reload loop. On the desktop branch: edit a file, one reload fires, the change appears in served output, /todo re-seeds cleanly with zero constraint errors, and a syntax error mid-edit fails loudly and recovers on the next save. --- dev.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++++ scripts/dev.sh | 24 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 dev.py create mode 100755 scripts/dev.sh diff --git a/dev.py b/dev.py new file mode 100644 index 00000000..25a813cd --- /dev/null +++ b/dev.py @@ -0,0 +1,144 @@ +""" +Copyright (c) Meta Platforms, Inc. and affiliates. +All rights reserved. +This source code is licensed under the license found in the +LICENSE file in the root directory of this source tree. +""" + +""" +Hot-reloading development server. + + ./scripts/dev.sh # or: + uv run uvicorn dev:app --reload --reload-dir src --reload-dir config --port 5001 + +Save any file under ``src/`` or ``config/`` and the server restarts and the +browser refreshes itself. Not for evals -- use ``launch.py`` for those. + +## Why this file exists rather than `serve(reload=True)` + +``serve()`` already defaults to ``reload=True``; ``launcher.launch_apps()`` +turns it off deliberately, and just flipping it back gives a server that +reloads into a broken state. Two reasons, both worth knowing before touching +this: + +**1. The app is configured imperatively, after import.** ``launch.py`` exposes +a bare ``app``; the routes and config are attached later by +``initialize_routes_and_configure_task()`` inside ``launcher.launch()``. +Uvicorn's reloader restarts the worker and re-imports the app from its import +string, which runs none of that -- a freshly imported ``app`` has 7 routes and +no ``.config`` at all, so the reloaded server would 404 every app and crash on +the first request that touches configuration. + +This module fixes that by doing the configuration at *module level*, so +re-importing it is enough to rebuild a fully wired app. + +**2. Re-seeding a database that already has rows fails.** ``set_environment()`` +loops ``insert()`` over the configured rows without clearing first, so a second +run hits unique-constraint violations, and the code editor bails out with +"folder already exists. This is undesired!!!" instead of re-seeding. Since the +reloader restarts the process against the same directory on disk, every reload +after the first would hit both. + +So this module wipes its own database directory before initializing. Each +reload therefore starts from the configured seed state -- which is also what an +eval episode does, so what you are looking at matches what an agent would get. +The cost is that runtime state does not survive a reload: pinned apps, theme +mode and any todos you ticked all reset. That is a fair trade while iterating +on rendering, and the wrong one if you are debugging state, in which case run +``launch.py`` normally. +""" +import os +import shutil +import tempfile +from pathlib import Path + +from hydra import compose, initialize +from starlette.routing import WebSocketRoute + +from fasthtml.live_reload import LiveReloadJs, live_reload_ws + +from open_apps.apps.start_page.main import ( + app, + initialize_routes_and_configure_task, +) + +REPO_ROOT = Path(__file__).resolve().parent + +#: Deliberately OUTSIDE the repository. +#: +#: The code editor seeds .py files into its database directory on every +#: startup. Put that anywhere the reloader can see and you get an infinite +#: loop: seed -> watcher fires -> restart -> seed. Observed exactly that with +#: the directory at ./.dev, and note it happened even though uvicorn reported +#: watching only src/ and config/ -- so confining it by reload-dir alone is not +#: something to rely on. A path outside the tree cannot be watched by accident. +DEV_DIR = Path(tempfile.gettempdir()) / "openapps-dev" + +#: Empty by default so this boots whatever the current branch's default config +#: is. Hardcoding a layout or theme here would tie the dev server to config +#: groups that only exist on some branches, and it would fail to start on the +#: ones where they do not. +#: +#: Pass a space-separated list of Hydra overrides to pick something else: +#: OPENAPPS_DEV_OVERRIDES="apps/theme=dark apps/todo/layout=kanban_board" +DEFAULT_OVERRIDES = "" + + +def _build() -> None: + """Compose config, reset state, and wire up the app. Runs on every import.""" + overrides = os.environ.get("OPENAPPS_DEV_OVERRIDES", DEFAULT_OVERRIDES).split() + + # Wipe first. set_environment() cannot re-seed over existing rows, and the + # reloader hands us the same directory every time. + shutil.rmtree(DEV_DIR, ignore_errors=True) + DEV_DIR.mkdir(parents=True, exist_ok=True) + + with initialize(version_base=None, config_path="config"): + config = compose( + config_name="config", + overrides=[f"logs_dir={DEV_DIR}", "use_wandb=False", *overrides], + ) + Path(config.databases_dir).mkdir(parents=True, exist_ok=True) + initialize_routes_and_configure_task(config.apps) + + _install_live_reload() + + print(f"\n dev server ready — overrides: {' '.join(overrides) or '(none)'}") + print(f" state dir: {DEV_DIR} (wiped on every reload)\n") + + +def _install_live_reload() -> None: + """Add the ``/live-reload`` socket and the client snippet. + + FastHTML ships this as ``FastHTMLWithLiveReload``, a ``FastHTML`` subclass. + Swapping the app's class just for development would mean the thing you are + testing is not the thing that runs in an eval, so the two halves are + attached to the existing app instead: a websocket route, and a script that + reloads the page when that socket drops and comes back. + + Guarded because this module is re-imported on every reload in some + execution paths, and a duplicate route or a second copy of the script would + accumulate. + """ + if not any(getattr(r, "path", None) == "/live-reload" for r in app.routes): + app.routes.append(WebSocketRoute("/live-reload", live_reload_ws)) + if not any("live-reload" in str(h) for h in app.hdrs): + app.hdrs.append(LiveReloadJs()) + + +_build() + + +if __name__ == "__main__": + # Convenience path so `python dev.py` works as well as the uvicorn command. + # Passes the module import string, not the object, because the reloader + # needs something it can re-import in the restarted worker. + import uvicorn + + uvicorn.run( + "dev:app", + host="localhost", + port=int(os.environ.get("OPENAPPS_DEV_PORT", 5001)), + reload=True, + reload_dirs=[str(REPO_ROOT / "src"), str(REPO_ROOT / "config")], + ) diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 00000000..8f0d7935 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# +# Hot-reloading dev server. Save anything under src/ or config/ and the server +# restarts and the browser refreshes itself. +# +# ./scripts/dev.sh +# OPENAPPS_DEV_PORT=5005 ./scripts/dev.sh +# OPENAPPS_DEV_OVERRIDES="apps/theme=meta_dark" ./scripts/dev.sh +# +# See dev.py for why this cannot just be `serve(reload=True)`. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +PORT="${OPENAPPS_DEV_PORT:-5001}" +echo " http://localhost:${PORT}" + +# The import string, not the app object: the reloader restarts the worker and +# has to be able to re-import it. +exec uv run uvicorn dev:app \ + --reload \ + --reload-dir src \ + --reload-dir config \ + --host localhost \ + --port "${PORT}"