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/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}" 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))