From 8978ac6c082628144a5778ff56adc6444579c5f4 Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Wed, 5 Aug 2026 11:41:57 -0400 Subject: [PATCH 01/23] [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 dfc7042cc2339a7382727325fb50ad92b84c5f07 Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Wed, 5 Aug 2026 14:02:50 -0400 Subject: [PATCH 02/23] adding missed files --- .../apps/todo/appearance/black_and_white.yaml | 11 - .../todo/appearance/challenging_font.yaml | 9 - config/apps/todo/appearance/dark_theme.yaml | 11 - config/apps/todo/appearance/default.yaml | 10 - config/apps/todo/appearance/kanban_board.yaml | 12 - config/apps/todo/default.yaml | 6 +- config/config.yaml | 1 + config/config_parallel_tasks.yaml | 2 +- src/open_apps/apps/todo_app/main.py | 218 +++++++++--------- src/open_apps/mcp/README.md | 4 +- src/open_apps/mcp/appserver.py | 19 +- src/open_apps/mcp/registry.py | 10 +- src/open_apps/mcp/server.py | 16 +- src/open_apps/mcp/session.py | 4 + tests/test_mcp.py | 11 +- 15 files changed, 168 insertions(+), 176 deletions(-) delete mode 100644 config/apps/todo/appearance/black_and_white.yaml delete mode 100644 config/apps/todo/appearance/challenging_font.yaml delete mode 100644 config/apps/todo/appearance/dark_theme.yaml delete mode 100644 config/apps/todo/appearance/default.yaml delete mode 100644 config/apps/todo/appearance/kanban_board.yaml diff --git a/config/apps/todo/appearance/black_and_white.yaml b/config/apps/todo/appearance/black_and_white.yaml deleted file mode 100644 index ca0a9ff6..00000000 --- a/config/apps/todo/appearance/black_and_white.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# @package apps.todo -add_button_color: "#000000" -background_color: "#000000" -form_background_color: "#FFFFFF" -remove_button_color: "#000000" -edit_button_color: "#000000" -save_button_color: "#000000" -font_family: 'Times New Roman' -base_font_size: '16px' -font_color: "#000000" -edit_remove_save_button_font_color: "#FFFFFF" \ No newline at end of file diff --git a/config/apps/todo/appearance/challenging_font.yaml b/config/apps/todo/appearance/challenging_font.yaml deleted file mode 100644 index efa24740..00000000 --- a/config/apps/todo/appearance/challenging_font.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# @package apps.todo -add_button_color: "" -form_background_color: "light" -remove_button_color: "#B22222" -edit_button_color: "#36454F" -save_button_color: "" -font_family: 'Brush Script MT' -base_font_size: '16px' -font_color: "" \ No newline at end of file diff --git a/config/apps/todo/appearance/dark_theme.yaml b/config/apps/todo/appearance/dark_theme.yaml deleted file mode 100644 index 1f1d4416..00000000 --- a/config/apps/todo/appearance/dark_theme.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# @package apps.todo -add_button_color: "#000000" -background_color: "#FFFFFF" -form_background_color: "#000000" -remove_button_color: "#FFFFFF" -edit_button_color: "#FFFFFF" -save_button_color: "#FFFFFF" -font_family: 'Times New Roman' -base_font_size: '16px' -font_color: "#FFFFFF" -edit_remove_save_button_font_color: "#000000" \ No newline at end of file diff --git a/config/apps/todo/appearance/default.yaml b/config/apps/todo/appearance/default.yaml deleted file mode 100644 index d8b9bac2..00000000 --- a/config/apps/todo/appearance/default.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# @package apps.todo -layout: default -add_button_color: "" -form_background_color: "light" -remove_button_color: "#B22222" -edit_button_color: "#36454F" -save_button_color: "" -font_family: 'Times New Roman' -base_font_size: '16px' -font_color: "" \ No newline at end of file diff --git a/config/apps/todo/appearance/kanban_board.yaml b/config/apps/todo/appearance/kanban_board.yaml deleted file mode 100644 index ebb7ba8a..00000000 --- a/config/apps/todo/appearance/kanban_board.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# @package apps.todo -layout: kanban_board -add_button_color: "" -background_color: "#ffffff" -form_background_color: "#ebecf0" -remove_button_color: "#B22222" -edit_button_color: "#36454F" -save_button_color: "" -font_family: 'Times New Roman' -base_font_size: '16px' -font_color: "" -edit_remove_save_button_font_color: "" diff --git a/config/apps/todo/default.yaml b/config/apps/todo/default.yaml index 5f53569c..b21eaea4 100644 --- a/config/apps/todo/default.yaml +++ b/config/apps/todo/default.yaml @@ -1,6 +1,10 @@ defaults: - content: default - - appearance: default + - layout: default database_path: ${databases_dir}/todo.db + +# Per-app theme override. null = inherit the global `apps/theme` selection. +# Override for just this app with `apps.todo.theme=solarized`. +theme: null diff --git a/config/config.yaml b/config/config.yaml index c2244879..ce712fec 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -4,6 +4,7 @@ defaults: - agent: default - tasks: all_tasks - browsergym_env_args: default + - apps/theme: default - apps/code_editor: default - apps/maps: default - apps/messenger: default diff --git a/config/config_parallel_tasks.yaml b/config/config_parallel_tasks.yaml index 31b8bb75..9b15da8f 100644 --- a/config/config_parallel_tasks.yaml +++ b/config/config_parallel_tasks.yaml @@ -36,7 +36,7 @@ parallel_tasks: - [ "apps/start_page/appearance=dark_theme", "apps/calendar/appearance=dark_theme", - "apps/todo/appearance=dark_theme", + "apps.todo.theme=dark", "apps/messenger/appearance=dark_theme", "apps/maps/appearance=dark_theme", "apps/code_editor/appearance=dark_theme", diff --git a/src/open_apps/apps/todo_app/main.py b/src/open_apps/apps/todo_app/main.py index 7712b54f..a997cf1f 100644 --- a/src/open_apps/apps/todo_app/main.py +++ b/src/open_apps/apps/todo_app/main.py @@ -10,6 +10,7 @@ from typing import List from src.open_apps.apps.start_page.helper import create_logo_header from src.open_apps.frontend import local_hdrs +from src.open_apps.theme import theme_style @dataclass @@ -21,11 +22,109 @@ class Todo: app, rt = fast_app(default_hdrs=False, hdrs=local_hdrs()) logo_title_container = None -styles = Style("") + +# Static, theme-agnostic component styles. All colors/fonts are design tokens +# resolved per-request via `theme_style()` (see the `:root` block it emits), so +# this block never needs rebuilding when the theme or app config changes. +styles = Style(""" + body { + font-family: var(--font-family); + font-size: var(--font-size-base); + color: var(--color-fg); + background-color: var(--color-bg); + } + .todo, .card, .group, .add-btn { + color: var(--color-fg); + } + a { + color: var(--color-fg); + text-decoration: none; + } + .todo-item, .todo-controls { + list-style-type: none; + color: var(--color-fg); + } + .todo-general { + background-color: var(--color-surface); + } + .todo-controls { + margin-left: 12px; + } + .todo-btn { + transform: scale(.7); + color: var(--color-fg); + } + .edit-btn { + background-color: var(--color-neutral); + border: 1px solid var(--color-neutral); + color: var(--color-btn-fg); + } + .remove-btn { + background-color: var(--color-danger); + border: 1px solid var(--color-danger); + color: var(--color-btn-fg); + } + .add-btn { + background-color: var(--color-primary); + color: var(--color-on-primary); + } + .save-btn { + background-color: var(--color-accent); + border: 1px solid var(--color-accent); + color: var(--color-btn-fg); + } + .kanban-board { + width: 100%; + } + .kanban-columns { + display: flex; + gap: 1rem; + align-items: flex-start; + margin-top: 1rem; + overflow-x: auto; + padding-bottom: 0.5rem; + } + .kanban-column { + flex: 0 0 450px; + min-width: 450px; + background-color: var(--color-surface); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + min-height: 120px; + } + .kanban-column-title { + margin-top: 0.25rem; + } + .kanban-card { + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + } + .kanban-card-title { + margin-bottom: 0.4rem; + } + .kanban-card-controls { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + } + .kanban-edit input { + margin-bottom: 0.4rem; + } + .kanban-add { + margin-top: 0.5rem; + } + .kanban-header-edit { + display: flex; + gap: 0.25rem; + } +""") def set_environment(config): """Set environment variables for the todo app""" - global app, logo_title_container, styles + global app, logo_title_container app.config = config db = database(config.todo.database_path) global todos, kanban_status @@ -49,111 +148,12 @@ def set_environment(config): current_file_path=__file__ ) - font_family = app.config.todo.font_family - font_size = app.config.todo.base_font_size - font_color = getattr(app.config.todo, "font_color", "blue") - edit_remove_save_button_font_color = getattr(app.config.todo, "edit_remove_save_button_font_color", "") - edit_button_color = app.config.todo.edit_button_color - remove_button_color = app.config.todo.remove_button_color - add_button_color = app.config.todo.add_button_color - save_button_color = app.config.todo.save_button_color - background_color = getattr(app.config.todo, "background_color", "") - form_background_color = getattr(app.config.todo, "form_background_color", "") - styles.children = [f""" - body {{ - font-family: {font_family}; - font-size: {font_size}; - color: {font_color}; - background-color: {background_color} - }} - .todo, .card, .group, .add-btn {{ - background-color: {background_color} - color: {font_color}; - }} - a {{ - color: {font_color}; - text-decoration: none; - }} - .todo-item, .todo-controls {{ - list-style-type: none; - color: {font_color}; - }} - .todo-general {{ - background-color: {form_background_color} - }} - .todo-controls {{ - margin-left: 12px; - }} - .todo-btn {{ - transform: scale(.7); - color: {font_color}; - }} - .edit-btn {{ - background-color: {edit_button_color}; - border: 1px solid {edit_button_color}; - color: {edit_remove_save_button_font_color}; - }} - .remove-btn {{ - background-color: {remove_button_color}; - border: 1px solid {remove_button_color}; - color: {edit_remove_save_button_font_color}; - }} - .add-btn {{ - background-color: {add_button_color}; - }} - .save-btn {{ - background-color: {save_button_color}; - border: 1px solid {save_button_color}; - color: {edit_remove_save_button_font_color}; - }} - .kanban-board {{ - width: 100%; - }} - .kanban-columns {{ - display: flex; - gap: 1rem; - align-items: flex-start; - margin-top: 1rem; - overflow-x: auto; - padding-bottom: 0.5rem; - }} - .kanban-column {{ - flex: 0 0 450px; - min-width: 450px; - background-color: {form_background_color}; - border-radius: 8px; - padding: 0.5rem 0.75rem; - min-height: 120px; - }} - .kanban-column-title {{ - margin-top: 0.25rem; - }} - .kanban-card {{ - background-color: {background_color}; - border: 1px solid {edit_button_color}; - border-radius: 6px; - padding: 0.5rem 0.75rem; - margin-bottom: 0.5rem; - }} - .kanban-card-title {{ - margin-bottom: 0.4rem; - }} - .kanban-card-controls {{ - display: flex; - gap: 0.25rem; - flex-wrap: wrap; - }} - .kanban-edit input {{ - margin-bottom: 0.4rem; - }} - .kanban-add {{ - margin-top: 0.5rem; - }} - .kanban-header-edit {{ - display: flex; - gap: 0.25rem; - }} - """] + +def todo_theme(): + """The active theme's `:root` token block, resolved per-request so live + `reconfigure` theme swaps take effect.""" + return theme_style(app.config, "todo") + id_curr = "current-todo" @@ -340,7 +340,7 @@ def get(): cls="contrast", style="margin-top: 1rem;", ) - return Div(styles, logo_title_container, render_kanban_board(), home_button) + return Div(todo_theme(), styles, logo_title_container, render_kanban_board(), home_button) add = Form( Group( mk_input(), @@ -349,11 +349,11 @@ def get(): hx_post="/todo", # Update this path target_id="todo-list", hx_swap="beforeend", - data_theme=app.config.todo.form_background_color, ) card = (Card(Ul(*todos(), id="todo-list"), header=add, footer=Div(id=id_curr), cls="todo-general"),) home_button = A("Return to List of Apps", href="/", role="button", cls="contrast", style="margin-top: 1rem;") return Div( + todo_theme(), styles, logo_title_container, card, diff --git a/src/open_apps/mcp/README.md b/src/open_apps/mcp/README.md index 53ab3154..c676e163 100644 --- a/src/open_apps/mcp/README.md +++ b/src/open_apps/mcp/README.md @@ -45,9 +45,9 @@ the server is then ready for tool calls. | `load_task(key)` | Bind a task for scoring; returns its goal. Call `reset` after. | | `get_reward()` | Reward for the bound task (1.0 if complete, else 0.0). | | `set_goal(goal)` | Free-form goal, no automatic scoring. | -| `reconfigure(appearance, content, seed, extras)` | Live variant/seed change. | +| `reconfigure(theme, layout, appearance, content, seed, extras)` | Live variant/seed change. `theme` is the shared global design-token theme; `layout` is per-app structure; `appearance` is legacy (unmigrated apps). | | `list_apps()` | App keys actually registered (Java-aware). | -| `list_variants(app, group)` | Variant stems for `appearance`/`content`. | +| `list_variants(app, group)` | Variant stems for a group (`theme`/`layout`/`content`/`appearance`). | | `app_url(app=None)` | Absolute URL of an app's landing page. | **Actions** are BrowserGym action strings (full-resolution pixels): diff --git a/src/open_apps/mcp/appserver.py b/src/open_apps/mcp/appserver.py index 05c8e7db..3528b965 100644 --- a/src/open_apps/mcp/appserver.py +++ b/src/open_apps/mcp/appserver.py @@ -157,6 +157,8 @@ def reset(self) -> None: def reconfigure( self, *, + theme: str | None = None, + layout: str | None = None, appearance: str | None = None, content: str | None = None, seed: int | None = None, @@ -166,12 +168,19 @@ def reconfigure( FastHTML routes read ``app.config`` per-request, so the live config update propagates without restarting the server. This - only swaps appearance/content/seed/extras + re-seeds sqlite; it + only swaps theme/layout/content/seed/extras + re-seeds sqlite; it cannot add or remove apps (the registered set is fixed at init). Args: - appearance: Variant yaml stem under - ``config/apps//appearance/`` for ``self.app_name``. + theme: Shared design-token theme stem under + ``config/apps/theme/`` (e.g. ``solarized``). Global: applies + to every app that renders design tokens. + layout: Per-app structure variant stem under + ``config/apps//layout/`` for ``self.app_name`` + (e.g. ``kanban_board``). + appearance: Legacy per-app variant stem under + ``config/apps//appearance/`` for apps not yet migrated + to the theme/layout split. content: Variant yaml stem under ``config/apps//content/`` for ``self.app_name``. seed: Fresh integer seed for the OpenApps content samplers. @@ -181,6 +190,10 @@ def reconfigure( """ cfg_dir = config_dir_for(self.app_name) overrides: list[str] = [] + if theme is not None: + overrides.append(f"apps/theme={theme}") + if layout is not None: + overrides.append(f"apps/{cfg_dir}/layout={layout}") if appearance is not None: overrides.append(f"apps/{cfg_dir}/appearance={appearance}") if content is not None: diff --git a/src/open_apps/mcp/registry.py b/src/open_apps/mcp/registry.py index ef23462b..90e61e0e 100644 --- a/src/open_apps/mcp/registry.py +++ b/src/open_apps/mcp/registry.py @@ -48,14 +48,20 @@ def config_dir_for(app_name: str) -> str: def list_variants(app_name: str, group: str) -> list[str]: - """List Hydra variant yamls for an app's group (``appearance``/``content``). + """List Hydra variant yamls for a group (``theme``/``layout``/``content``/``appearance``). Returns a sorted list of variant stems (without ``.yaml``). ``"default"`` is forced to index 0 when present so it has a stable sampling identity. Returns ``["default"]`` if the group dir is missing. + + ``theme`` is a *shared* group (``config/apps/theme/``) applying to every + app, so ``app_name`` is ignored for it; all other groups are per-app. """ - group_dir = config_dir() / "apps" / config_dir_for(app_name) / group + if group == "theme": + group_dir = config_dir() / "apps" / "theme" + else: + group_dir = config_dir() / "apps" / config_dir_for(app_name) / group if not group_dir.is_dir(): return ["default"] stems = sorted(p.stem for p in group_dir.glob("*.yaml")) diff --git a/src/open_apps/mcp/server.py b/src/open_apps/mcp/server.py index fbaf3caa..72ad63db 100644 --- a/src/open_apps/mcp/server.py +++ b/src/open_apps/mcp/server.py @@ -86,14 +86,26 @@ async def reset(seed: int | None = None): @mcp.tool() async def reconfigure( + theme: str | None = None, + layout: str | None = None, appearance: str | None = None, content: str | None = None, seed: int | None = None, extras: dict | None = None, ) -> str: - """Swap appearance/content variant and seed (live) and re-seed app state.""" + """Swap theme/layout/content variant and seed (live) and re-seed app state. + + `theme` is the shared design-token theme (global); `layout` is the per-app + structure; `appearance` is the legacy per-app variant for apps not yet + migrated to the theme/layout split. + """ await _require().reconfigure( - appearance=appearance, content=content, seed=seed, extras=extras + theme=theme, + layout=layout, + appearance=appearance, + content=content, + seed=seed, + extras=extras, ) return "reconfigured" diff --git a/src/open_apps/mcp/session.py b/src/open_apps/mcp/session.py index 3bc99dca..66058381 100644 --- a/src/open_apps/mcp/session.py +++ b/src/open_apps/mcp/session.py @@ -208,6 +208,8 @@ async def reset(self, *, seed: int | None = None, options: dict | None = None) - async def reconfigure( self, *, + theme: str | None = None, + layout: str | None = None, appearance: str | None = None, content: str | None = None, seed: int | None = None, @@ -216,6 +218,8 @@ async def reconfigure( self._require_started() await asyncio.to_thread( self.appserver.reconfigure, + theme=theme, + layout=layout, appearance=appearance, content=content, seed=seed, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 51823e4a..b0beb908 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -103,9 +103,14 @@ def test_config_dir_for(self): assert registry.config_dir_for("todo") == "todo" def test_list_variants_default_first(self): - variants = registry.list_variants("todo", "appearance") - assert variants[0] == "default" - assert "dark_theme" in variants + # Per-app structure group. + layouts = registry.list_variants("todo", "layout") + assert layouts[0] == "default" + assert "kanban_board" in layouts + # Shared design-token theme group (app_name ignored). + themes = registry.list_variants("todo", "theme") + assert themes[0] == "default" + assert "solarized" in themes def _browsergym_calls(fn, *args): From 46f18cbb37eeb15a80bf14f80bb389607e6b3c1e Mon Sep 17 00:00:00 2001 From: Smokey Date: Tue, 11 Aug 2026 10:07:35 -0400 Subject: [PATCH 03/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/open_apps/theme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/open_apps/theme.py b/src/open_apps/theme.py index ef5e318c..fc254403 100644 --- a/src/open_apps/theme.py +++ b/src/open_apps/theme.py @@ -14,8 +14,8 @@ 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 +* ``+apps/theme=solarized`` -> global default for every app (requires adding the group if not in defaults) +* ``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:: From 91585de5ef46196a7783595fc4ef2bfec5411040 Mon Sep 17 00:00:00 2001 From: Smokey Date: Tue, 11 Aug 2026 10:07:49 -0400 Subject: [PATCH 04/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- config/apps/todo/layout/kanban_board.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/apps/todo/layout/kanban_board.yaml b/config/apps/todo/layout/kanban_board.yaml index 0a0ddb80..8302e50a 100644 --- a/config/apps/todo/layout/kanban_board.yaml +++ b/config/apps/todo/layout/kanban_board.yaml @@ -1,4 +1,4 @@ # @package apps.todo # Structure only -- colors/fonts come from the shared theme. Select with -# `apps/todo/layout=kanban_board`. +# `+apps/todo/layout=kanban_board` (or add `layout: default` to apps/todo/default.yaml defaults). layout: kanban_board From 26599d7fbb6b810bde53ca6dd279c89e85b45e45 Mon Sep 17 00:00:00 2001 From: Smokey Date: Tue, 11 Aug 2026 10:08:36 -0400 Subject: [PATCH 05/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/open_apps/theme.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/open_apps/theme.py b/src/open_apps/theme.py index fc254403..a90205dc 100644 --- a/src/open_apps/theme.py +++ b/src/open_apps/theme.py @@ -90,6 +90,10 @@ def resolve_theme(apps_config, app_name: str) -> dict: global_theme = getattr(apps_config, "theme", None) if global_theme is not None: + # Allow global theme to be provided either as a composed config node + # (apps/theme=) or as a plain string override (apps.theme=). + if isinstance(global_theme, str): + return load_theme(global_theme) theme = _as_plain(global_theme) theme.setdefault("tokens", {}) theme["tokens"] = _as_plain(theme["tokens"]) From 3064f676bc0c29bed2e40359406faf9c0365ab3f Mon Sep 17 00:00:00 2001 From: Smokey Date: Tue, 11 Aug 2026 10:09:42 -0400 Subject: [PATCH 06/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/open_apps/theme.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/open_apps/theme.py b/src/open_apps/theme.py index a90205dc..54f60757 100644 --- a/src/open_apps/theme.py +++ b/src/open_apps/theme.py @@ -108,8 +108,23 @@ def render_theme_tokens(theme: dict) -> Style: ``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()) + + safe_lines: list[str] = [] + for key, value in tokens.items(): + key = str(key) + # Allow only simple custom-property names to avoid broken CSS/injection. + if (not key) or any(not (c.isalnum() or c in "-_") for c in key): + continue + val = str(value).replace("\n", " ").replace("\r", " ") + safe_lines.append(f" --{key}: {val};") + + lines = "\n".join(safe_lines) + import_url = (theme.get("import_url") or "").strip() + # Avoid breaking out of the quoted @import string. + if any(c in import_url for c in ('"', "'", "\n", "\r")): + import_url = "" + import_rule = f'@import url("{import_url}");\n' if import_url else "" css = f"{import_rule}:root {{\n{lines}\n}}" return Style(css) From 5b526c27e3e31230641c0a0a9a8a7e4efddbc48e Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Tue, 11 Aug 2026 10:23:11 -0400 Subject: [PATCH 07/23] [Tweak] add theme unit tests --- tests/test_theme.py | 160 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/test_theme.py diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 00000000..4dd6286b --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,160 @@ +""" +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. +""" + +""" +Unit tests for shared design-token theming (open_apps.theme). + +Covers the two behaviours that are easy to regress when Hydra groups move +around: theme *precedence* (per-app override beats the global group) and +graceful *fallback* (an unknown theme name degrades to the default instead +of raising). +""" + +from fasthtml.common import to_xml +from omegaconf import OmegaConf + +from open_apps.theme import ( + load_theme, + render_theme_tokens, + resolve_theme, + theme_style, +) + + +def css(style) -> str: + """Render a ``Style`` FT element down to its CSS text.""" + return to_xml(style) + + +class TestLoadTheme: + + def test_loads_known_theme(self): + theme = load_theme("solarized") + assert theme["name"] == "solarized" + assert theme["tokens"]["color-bg"] == "#fdf6e3" + + def test_unknown_theme_falls_back_to_default(self): + theme = load_theme("does_not_exist") + default = load_theme("default") + assert theme["name"] == "default" + assert theme["tokens"] == default["tokens"] + + def test_always_has_required_keys(self): + theme = load_theme("dark") + assert set(theme) >= {"name", "tokens", "import_url"} + + +class TestResolveTheme: + + def test_per_app_overrides_global(self): + cfg = OmegaConf.create( + {"theme": {"name": "dark", "tokens": {"color-bg": "#000000"}}, + "todo": {"theme": "solarized"}} + ) + assert resolve_theme(cfg, "todo")["name"] == "solarized" + + def test_null_per_app_inherits_global(self): + cfg = OmegaConf.create( + {"theme": {"name": "dark", "tokens": {"color-bg": "#000000"}}, + "todo": {"theme": None}} + ) + theme = resolve_theme(cfg, "todo") + assert theme["name"] == "dark" + assert theme["tokens"]["color-bg"] == "#000000" + + def test_unset_per_app_inherits_global(self): + cfg = OmegaConf.create( + {"theme": {"name": "dark", "tokens": {}}, "todo": {}} + ) + assert resolve_theme(cfg, "todo")["name"] == "dark" + + def test_global_as_plain_string(self): + """``apps.theme=solarized`` (dotted override) rather than a composed group.""" + cfg = OmegaConf.create({"theme": "solarized", "todo": {}}) + theme = resolve_theme(cfg, "todo") + assert theme["name"] == "solarized" + assert theme["tokens"]["color-bg"] == "#fdf6e3" + + def test_no_theme_anywhere_falls_back_to_default(self): + cfg = OmegaConf.create({"todo": {}}) + assert resolve_theme(cfg, "default_probe") == load_theme("default") + + def test_unknown_app_falls_back_to_global(self): + cfg = OmegaConf.create({"theme": "solarized"}) + assert resolve_theme(cfg, "no_such_app")["name"] == "solarized" + + def test_unknown_per_app_name_degrades_to_default(self): + cfg = OmegaConf.create({"theme": "solarized", "todo": {"theme": "bogus"}}) + # A bad per-app override must not raise, and must not silently + # inherit the global theme either -- it resolves to the default file. + assert resolve_theme(cfg, "todo")["tokens"] == load_theme("default")["tokens"] + + def test_resolved_tokens_are_plain_dicts(self): + """Callers index tokens directly; OmegaConf nodes must be coerced.""" + cfg = OmegaConf.create({"theme": {"name": "x", "tokens": {"radius": "4px"}}}) + theme = resolve_theme(cfg, "todo") + assert isinstance(theme, dict) and isinstance(theme["tokens"], dict) + assert theme["tokens"]["radius"] == "4px" + + def test_apps_config_isolated_per_app(self): + cfg = OmegaConf.create( + {"theme": "default", + "todo": {"theme": "solarized"}, + "calendar": {}} + ) + assert resolve_theme(cfg, "todo")["name"] == "solarized" + assert resolve_theme(cfg, "calendar")["name"] == "default" + + +class TestRenderThemeTokens: + + def test_tokens_become_custom_properties(self): + out = css(render_theme_tokens( + {"tokens": {"color-bg": "#fff", "radius": "8px"}} + )) + assert ":root {" in out + assert "--color-bg: #fff;" in out + assert "--radius: 8px;" in out + + def test_empty_tokens_still_renders_valid_block(self): + out = css(render_theme_tokens({})) + assert ":root {" in out and "}" in out + assert "--" not in out + + def test_unsafe_token_names_are_dropped(self): + out = css(render_theme_tokens( + {"tokens": {"ok-name": "1", "bad; }": "2", "": "3", "a b": "4"}} + )) + assert "--ok-name: 1;" in out + assert "bad" not in out and "a b" not in out + + def test_newlines_in_values_are_flattened(self): + out = css(render_theme_tokens({"tokens": {"font-family": "a\nb\rc"}})) + assert "--font-family: a b c;" in out + + def test_import_url_is_emitted(self): + out = css(render_theme_tokens( + {"import_url": "https://example.com/f.css", "tokens": {}} + )) + assert '@import url("https://example.com/f.css");' in out + + def test_import_url_with_quotes_is_dropped(self): + out = css(render_theme_tokens( + {"import_url": 'https://x.test/f.css"); body{display:none', "tokens": {}} + )) + assert "@import" not in out + + def test_missing_import_url_emits_no_rule(self): + assert "@import" not in css(render_theme_tokens({"tokens": {"a": "b"}})) + + +class TestThemeStyle: + + def test_resolves_and_renders_in_one_call(self): + cfg = OmegaConf.create({"theme": "default", "todo": {"theme": "solarized"}}) + assert "--color-bg: #fdf6e3;" in css(theme_style(cfg, "todo")) + assert "--color-bg: #ffffff;" in css(theme_style(cfg, "calendar")) From f96f7c98630715e3db05a51305bba0b5816b7f38 Mon Sep 17 00:00:00 2001 From: Aaron Smulktis Date: Mon, 31 Aug 2026 16:05:14 -0400 Subject: [PATCH 08/23] [Feature] Make the shared theme visible in the appearance-based apps Only todo renders from the design tokens in `open_apps.theme`; calendar, messenger, maps, code editor, and the start page each build a `:root` block of their own from `config/apps//appearance/`, so an `apps/theme=` selection was invisible to them. Add a bridge rather than rewrite five stylesheets: `legacy_theme_css()` emits the theme's token block, aliases the legacy custom properties onto the tokens they should follow, and adds the shared page chrome plus the few per-app selectors whose colors are hard-coded. It returns "" on the `default` theme, so a deployment that never selects one renders exactly as before. Each app emits it after its own stylesheet and per-request, so `reconfigure` theme swaps take effect. Also give every app a `theme: null` field (inherit the global selection, override with `apps..theme=`), and extend save_screenshots.py with a `theme_` variation per theme file. Test plan: uv run -m pytest tests/ -> 724 passed, 7 skipped. --- config/apps/calendar/default.yaml | 3 + config/apps/code_editor/default.yaml | 6 +- config/apps/maps/default.yaml | 4 + config/apps/messenger/default.yaml | 6 +- config/apps/start_page/default.yaml | 4 + config/apps/todo/layout/kanban_board.yaml | 2 +- src/open_apps/apps/calendar_app/main.py | 16 ++- src/open_apps/apps/codeeditor_app/main.py | 17 ++- src/open_apps/apps/map_app/main.py | 4 + src/open_apps/apps/map_app/templates/map.html | 3 + src/open_apps/apps/messenger_app/main.py | 13 ++ src/open_apps/apps/start_page/helper.py | 9 +- src/open_apps/apps/start_page/main.py | 11 +- src/open_apps/theme.py | 128 +++++++++++++++++- tests/save_screenshots.py | 70 ++++++---- 15 files changed, 254 insertions(+), 42 deletions(-) diff --git a/config/apps/calendar/default.yaml b/config/apps/calendar/default.yaml index 420ab11f..f72c0b97 100644 --- a/config/apps/calendar/default.yaml +++ b/config/apps/calendar/default.yaml @@ -4,3 +4,6 @@ defaults: database_path: ${databases_dir}/calendar.db +# Per-app theme override. null = inherit the global `apps/theme` selection. +# Override for just this app with `apps.calendar.theme=solarized`. +theme: null diff --git a/config/apps/code_editor/default.yaml b/config/apps/code_editor/default.yaml index bf61aa66..eab65447 100644 --- a/config/apps/code_editor/default.yaml +++ b/config/apps/code_editor/default.yaml @@ -2,4 +2,8 @@ defaults: - content: default - appearance: default -database_path: ${databases_dir}/codeeditor \ No newline at end of file +database_path: ${databases_dir}/codeeditor + +# Per-app theme override. null = inherit the global `apps/theme` selection. +# Override for just this app with `apps.code_editor.theme=solarized`. +theme: null diff --git a/config/apps/maps/default.yaml b/config/apps/maps/default.yaml index e6a466af..556829f9 100644 --- a/config/apps/maps/default.yaml +++ b/config/apps/maps/default.yaml @@ -4,3 +4,7 @@ defaults: database_path: ${databases_dir}/maps.db otp_url: 'http://localhost:8080/' + +# Per-app theme override. null = inherit the global `apps/theme` selection. +# Override for just this app with `apps.maps.theme=solarized`. +theme: null diff --git a/config/apps/messenger/default.yaml b/config/apps/messenger/default.yaml index d5793271..3d80d5e2 100644 --- a/config/apps/messenger/default.yaml +++ b/config/apps/messenger/default.yaml @@ -2,4 +2,8 @@ defaults: - content: default - appearance: default -database_path: ${databases_dir}/messenger.db \ No newline at end of file +database_path: ${databases_dir}/messenger.db + +# Per-app theme override. null = inherit the global `apps/theme` selection. +# Override for just this app with `apps.messenger.theme=solarized`. +theme: null diff --git a/config/apps/start_page/default.yaml b/config/apps/start_page/default.yaml index c461049f..666a8292 100644 --- a/config/apps/start_page/default.yaml +++ b/config/apps/start_page/default.yaml @@ -1,3 +1,7 @@ defaults: - content: default - appearance: default + +# Per-app theme override. null = inherit the global `apps/theme` selection. +# Override for just this app with `apps.start_page.theme=solarized`. +theme: null diff --git a/config/apps/todo/layout/kanban_board.yaml b/config/apps/todo/layout/kanban_board.yaml index 8302e50a..0a0ddb80 100644 --- a/config/apps/todo/layout/kanban_board.yaml +++ b/config/apps/todo/layout/kanban_board.yaml @@ -1,4 +1,4 @@ # @package apps.todo # Structure only -- colors/fonts come from the shared theme. Select with -# `+apps/todo/layout=kanban_board` (or add `layout: default` to apps/todo/default.yaml defaults). +# `apps/todo/layout=kanban_board`. layout: kanban_board diff --git a/src/open_apps/apps/calendar_app/main.py b/src/open_apps/apps/calendar_app/main.py index 1b568f9b..0b702009 100644 --- a/src/open_apps/apps/calendar_app/main.py +++ b/src/open_apps/apps/calendar_app/main.py @@ -13,6 +13,7 @@ HighlightJS, database, dataclass) from datetime import datetime, timedelta from src.open_apps.frontend import local_hdrs +from src.open_apps.theme import legacy_theme_style import calendar import os import logging @@ -237,6 +238,15 @@ def set_environment(config): ) +def calendar_theme(): + """The active theme, mapped onto this app's `appearance` CSS variables. + + Empty on the default theme. Rendered per-request (and after `styles`, which + it has to override) so live `reconfigure` theme swaps take effect. + """ + return legacy_theme_style(app.config, "calendar") + + def update_db_from_hydra(): for event in app.config.calendar.events: @@ -595,6 +605,7 @@ def get(req): Title(app.config.start_page.apps.calendar.title), Container( styles, + calendar_theme(), error_div, show_main_layout(today.year, today.month, view) ), @@ -626,6 +637,7 @@ def get( Title(app.config.start_page.apps.calendar.title), Container( styles, + calendar_theme(), show_main_layout(year, month, view) ) ) @@ -707,7 +719,8 @@ def get(id: int): return ( Title(event.title), Container( - styles, + styles, + calendar_theme(), logo_title_container, Article( H3(event.title), @@ -770,6 +783,7 @@ def get_input_attrs(field_name: str, defaults: dict) -> dict: return (Title("Creating a new event"), Container( styles, + calendar_theme(), logo_title_container, Form( H3("Create New Event"), diff --git a/src/open_apps/apps/codeeditor_app/main.py b/src/open_apps/apps/codeeditor_app/main.py index 5c3565a0..e458a226 100644 --- a/src/open_apps/apps/codeeditor_app/main.py +++ b/src/open_apps/apps/codeeditor_app/main.py @@ -12,6 +12,7 @@ from starlette.responses import Response from src.open_apps.apps.start_page.helper import create_logo_header from src.open_apps.frontend import local_hdrs +from src.open_apps.theme import legacy_theme_style # Global variables _base_hdrs_no_highlight = ( @@ -185,6 +186,16 @@ def set_environment(config): current_file_path=__file__ ) +def codeeditor_theme(): + """The active theme, mapped onto this app's `appearance` CSS variables. + + Empty on the default theme. Rendered into the page body (not `app.hdrs`) + so it lands after `env_styles`, which it has to override, and so live + `reconfigure` theme swaps take effect. + """ + return legacy_theme_style(app.config, "code_editor") + + def return_to_index(): return A("Code Editor Index Page", href="/codeeditor", cls="btn btn-primary") @@ -517,7 +528,7 @@ def index(): ), ) page = Div(cls="flex space-x-2")(side_bar, main_screen) - return Div(logo_title_container, page) + return Div(codeeditor_theme(), logo_title_container, page) @app.get("/codeeditor/{path:path}") @@ -623,7 +634,7 @@ def get_folder(folder: str): ), ) page = Div(cls="flex space-x-2")(side_bar, main_screen) - return Div(logo_title_container, page) + return Div(codeeditor_theme(), logo_title_container, page) def get_file(file: str): side_bar = create_sidebar(file) @@ -946,7 +957,7 @@ def get_file(file: str): ), ) page = Div(cls="flex space-x-2")(side_bar, main_screen) - return Div(logo_title_container, page) + return Div(codeeditor_theme(), logo_title_container, page) @app.post("/codeeditor/create_folder/{folder:path}") def create_folder(folder: str): diff --git a/src/open_apps/apps/map_app/main.py b/src/open_apps/apps/map_app/main.py index c4028dd4..ae231e1f 100644 --- a/src/open_apps/apps/map_app/main.py +++ b/src/open_apps/apps/map_app/main.py @@ -14,6 +14,7 @@ from fasthtml.common import * import requests import json +from src.open_apps.theme import legacy_theme_css from datetime import datetime, timezone import subprocess import time @@ -122,6 +123,9 @@ async def map_page(request: Request): "calculate_button_hover_color": getattr(app.config.maps, "calculate_button_hover_color", "#2980b9"), "sidebar_background_color": getattr(app.config.maps, "sidebar_background_color", "#f8f9fa"), "allow_planning": app.config.maps.allow_planning, + # Resolved per-request so live `reconfigure` theme swaps take + # effect; empty string on the default theme. + "theme_css": legacy_theme_css(app.config, "maps"), }, ) diff --git a/src/open_apps/apps/map_app/templates/map.html b/src/open_apps/apps/map_app/templates/map.html index 0608c6aa..2bb54141 100644 --- a/src/open_apps/apps/map_app/templates/map.html +++ b/src/open_apps/apps/map_app/templates/map.html @@ -288,6 +288,9 @@ background-color: #e3f2fd; } + + diff --git a/src/open_apps/apps/messenger_app/main.py b/src/open_apps/apps/messenger_app/main.py index 44e992bf..cd7b6c15 100644 --- a/src/open_apps/apps/messenger_app/main.py +++ b/src/open_apps/apps/messenger_app/main.py @@ -12,6 +12,7 @@ import json from src.open_apps.apps.start_page.helper import create_logo_header from open_apps.frontend import local_hdrs +from open_apps.theme import legacy_theme_style @dataclass @@ -377,6 +378,16 @@ def set_environment(config): current_file_path=__file__ ) +def messenger_theme(): + """The active theme, mapped onto this app's `appearance` CSS variables. + + Empty on the default theme. Rendered into the page body (not `app.hdrs`) + so it lands after `env_styles`, which it has to override, and so live + `reconfigure` theme swaps take effect. + """ + return legacy_theme_style(app.config, "messenger") + + def populate_database(config, db): """Adds chat history to database""" chat_history = config.messenger.chat_history @@ -527,6 +538,7 @@ def index(): ) return Div( + messenger_theme(), logo_title_container, page ) @@ -633,6 +645,7 @@ def index(user_id: str): ) return Div( + messenger_theme(), logo_title_container, page ) diff --git a/src/open_apps/apps/start_page/helper.py b/src/open_apps/apps/start_page/helper.py index 203da42d..5b827044 100644 --- a/src/open_apps/apps/start_page/helper.py +++ b/src/open_apps/apps/start_page/helper.py @@ -300,14 +300,17 @@ def __init__(self, content): def __str__(self): return self.content -def PageWrapper(title, *content, config=None): +def PageWrapper(title, *content, config=None, theme_css=""): """ Create a page wrapper with custom styling from configuration. - + Args: title: Page title *content: Content elements config: Configuration dictionary with styling options + theme_css: Shared design-token CSS from ``open_apps.theme``. Emitted + last so it overrides the appearance-derived rules above it; empty + unless a non-default theme is selected. """ # Set defaults if config is None if config is None: @@ -547,6 +550,8 @@ def PageWrapper(title, *content, config=None): }} {custom_css} + + {theme_css} -