diff --git a/config/agent/default.yaml b/config/agent/default.yaml
index c14c72af..5fbe0c72 100644
--- a/config/agent/default.yaml
+++ b/config/agent/default.yaml
@@ -54,6 +54,15 @@ save_som: False # Add a set of marks to the screenshot.
# extract_visible_tag: False # Add a "visible" tag to visible elements in the AXTree.
# extract_clickable_tag: False # Add a "clickable" tag to clickable elements in the AXTree.
extract_coords: False # Add the coordinates of the elements.
+
+# --- coordinate space ---
+# What an (x, y) in the model's output means. null = raw viewport pixels
+# (UI-TARS, GPT-4o); an integer N = a normalized [0, N) grid, converted to
+# pixels by action_parsers.coords.rescale_xy. Qwen-VL / GLM-VL use 1000; the
+# PaliGemma/Gemma lineage bins locations to 1024. Leave null unless you have
+# measured the model's convention -- a wrong scale clicks somewhere plausible
+# and scores 0 silently.
+coord_scale: null
# filter_visible_elements_only: False # filter elements that are not visible
# use_focused_element: False # use focused element
diff --git a/config/agent/gemma-4-computer-use.yaml b/config/agent/gemma-4-2B-computer-use.yaml
similarity index 97%
rename from config/agent/gemma-4-computer-use.yaml
rename to config/agent/gemma-4-2B-computer-use.yaml
index a7b95e64..a1a0b261 100644
--- a/config/agent/gemma-4-computer-use.yaml
+++ b/config/agent/gemma-4-2B-computer-use.yaml
@@ -4,12 +4,12 @@ defaults:
# Must exactly match the model vLLM is serving (the --model / served_model_name).
model_name: "google/gemma-4-E2B-it"
-model_pretty_name: "gemma-4-computer-use"
+model_pretty_name: "gemma-4-2B-computer-use"
api_version: null
client_type: "vllm"
# For client_type=vllm the URL is built as http://${hostname}:${port}/v1
# (base_url is ignored). The vLLM node changes every SLURM allocation, so pass it
-# at launch, e.g.: uv run launch_agent.py agent=gemma-4-e2b-it agent.hostname=h200-000-026
+# at launch, e.g.: uv run launch_agent.py agent=gemma-4-e2b-it agent.hostname=node-001
hostname: null
port: "8000"
# vLLM does not check the key, but the OpenAI client requires a non-empty string.
diff --git a/config/agent/gemma-4-31B-computer-use.yaml b/config/agent/gemma-4-31B-computer-use.yaml
new file mode 100644
index 00000000..591ec973
--- /dev/null
+++ b/config/agent/gemma-4-31B-computer-use.yaml
@@ -0,0 +1,137 @@
+# Gemma 4 31B, screenshot-only via set-of-marks.
+#
+# The model sees ONLY the screenshot (no AXTree, no HTML), but the screenshot is
+# annotated with numbered boxes (save_som: true -> ObsFlags.use_som), so it can
+# target elements by bid instead of by pixel coordinate. Gemma is not trained
+# for pixel-precise GUI grounding the way UI-TARS is, so reading a printed bid
+# off the image is the reliable path.
+#
+# The coordinate-mode counterpart is gemma-4-31B-coords.yaml, which declares a
+# 1000x1000 grid and sets coord_scale to convert it to viewport pixels. Run
+# both to compare; this one is the safer default.
+#
+# Pair with: browsergym_env_args=screenshot
+defaults:
+ - default
+ - _self_
+
+# Must exactly match the model vLLM is serving (the --model / served_model_name).
+model_name: "google/gemma-4-31B-it"
+model_pretty_name: "gemma-4-31B-computer-use"
+api_version: null
+client_type: "vllm"
+# For client_type=vllm the URL is built as http://${hostname}:${port}/v1
+# (base_url is ignored). The vLLM node changes every SLURM allocation, so pass it
+# at launch, e.g.: uv run launch_agent.py agent=gemma-4-31B-computer-use agent.hostname=h200-000-026
+hostname: null
+port: "8000"
+# vLLM does not check the key, but the OpenAI client requires a non-empty string.
+api_key: "EMPTY"
+# Grounding wants determinism, not diversity.
+temperature: 0
+max_tokens: 5000
+aws_access_key: null
+aws_secret_key: null
+aws_session_token: null
+aws_region: us-west-2
+
+# Bid-based actions only. Kept deliberately small: every action here is
+# described in action_prompt below, and a smaller space is easier for a model
+# that is not GUI-pretrained. All of these pass through flexible_parser
+# untouched (the UI-TARS remaps in utils.uitars_parser only fire on
+# click(point=/start_box=/x=, type(content=, scroll(direction=, right_single(,
+# and hotkey(key=).
+custom_actions:
+- click
+- fill
+- select_option
+- scroll
+- noop
+
+use_html: false
+use_axtree: false
+use_screenshot: true
+save_som: true # feeds ObsFlags.use_som -> the bid-annotated screenshot
+extract_visible_tag: false
+extract_clickable_tag: false
+extract_coords: false
+filter_visible_elements_only: false
+use_focused_element: false
+prompt_txt:
+ system_prompt: |-
+ You are a GUI agent operating a web browser. At each step you are given a
+ task, your action history, and a screenshot of the current page.
+
+ The screenshot is annotated with a set of marks: every interactive element
+ is outlined with a coloured box and labelled with a short id (its "bid"),
+ drawn at the corner of the box. Target elements by that id. Never guess
+ pixel coordinates -- there is no coordinate-based action available to you.
+
+ Read the bid off the image carefully; it is the label attached to the box
+ around the element you want, not the element's visible text.
+ output_format: '
+
+
+
+
+
+
+
+
+ '
+
+ think_prompt: null
+ # Overridden: agentlab's default abstract example tells the model to compute
+ # coordinates, which contradicts the bid-only action space above.
+ think_abstract_example: |
+
+ Think step by step. Describe what your previous action changed on the page,
+ name the element you need next, and read its bid off the annotated
+ screenshot.
+
+ think_concrete_example: null
+ action_prompt: |
+ ## Action Space
+
+ click("bid")
+ Click the element with this bid. Use for links, buttons, checkboxes,
+ tabs, files in a sidebar, and to focus a text field.
+
+ fill("bid", "text")
+ Type text into the input, textarea or editor with this bid. This
+ REPLACES the whole current value, so when you are editing existing
+ content include the existing text plus your change in one call.
+
+ select_option("bid", "option label")
+ Choose an option in a dropdown.
+
+ scroll(delta_x, delta_y)
+ Scroll the page by a pixel amount. Positive delta_y scrolls down,
+ negative scrolls up. e.g. scroll(0, 400)
+
+ noop(wait_ms=5000)
+ Wait and re-screenshot. Use only when the page appears to still be
+ loading.
+
+ ## Note
+ - Exactly one action per step, inside a single tag.
+ - Use English in the `think` part.
+ - Write a short plan, then state your next action and the element it
+ targets in one sentence.
+ - Check the history: if your last action did not change the page, pick a
+ different target rather than repeating it.
+ - Saving usually needs an explicit click on a Save/Send/Add button after
+ you fill a field.
+ action_abstract_example: |
+ click("bid")
+
+ or, to enter text:
+
+ fill("bid", "the text to type")
+ action_concrete_example: |
+ click("92")
+
+ then on the next step, to replace the contents of a code editor whose box
+ is labelled 47 with the original text plus a new line:
+
+ fill("47", "import torch\nx = torch.randn(2, 3)\n# Reviewed by Bob")
diff --git a/config/agent/gemma-4-31B-coords.yaml b/config/agent/gemma-4-31B-coords.yaml
new file mode 100644
index 00000000..f7852eca
--- /dev/null
+++ b/config/agent/gemma-4-31B-coords.yaml
@@ -0,0 +1,171 @@
+# Gemma 4 31B, screenshot-only via pixel coordinates.
+#
+# NOT THE RECOMMENDED CONFIG. Use gemma-4-31B-computer-use.yaml (set-of-marks
+# bids). This one is kept as a measured negative result, so the experiment is
+# not repeated from scratch.
+#
+# ## What was measured
+#
+# The theory was that Gemma's off-target clicks were a coordinate-space
+# problem -- that it emitted normalized coordinates which were being fed to
+# playwright as raw viewport pixels. Two cluster runs falsified that. Taking
+# the executed action and inverting the conversion:
+#
+# model said (88, 238), coord_scale=1024, 1920x1080 -> executed (165, 251)
+# model said (611, 645), coord_scale=1000, 1920x1080 -> executed (1173, 697)
+#
+# Both exact to the pixel, at two different scales. The conversion put the
+# cursor precisely where the model asked; the model asked for the wrong place.
+# That is a grounding failure, not a scaling one, and no coord_scale value
+# fixes it -- see "Calibrating a new model" in src/open_apps/agent/README.md
+# for how to tell the two apart.
+#
+# A run with bids instead was mechanically sound by comparison -- clicks
+# executed and navigation worked -- but the model still misread which printed
+# label belonged to which row. So Gemma 4 31B struggles with pixel grounding
+# *and* with reading marks off an image. Feeding it the accessibility tree
+# (agent.use_axtree=true) turns "decode a label in an image" into "read a
+# name from text" and is the more promising direction.
+#
+# ## If you run this anyway
+#
+# The prompt declares a 1000x1000 grid and coord_scale matches it, so the
+# conversion is correct by construction as long as the model complies -- and
+# it does comply; that was never the failing part. Do not pass
+# agent.coord_scale=1024: it contradicts the declared grid and shrinks every
+# click ~2.3% toward the top-left.
+#
+# Pair with: browsergym_env_args=screenshot (also raises max_steps 10 -> 25;
+# on the default preset this task can run out of budget before it finishes).
+defaults:
+ - default
+ - _self_
+
+# Must exactly match the model vLLM is serving (the --model / served_model_name).
+model_name: "google/gemma-4-31B-it"
+model_pretty_name: "gemma-4-31B-coords"
+api_version: null
+client_type: "vllm"
+# For client_type=vllm the URL is built as http://${hostname}:${port}/v1
+# (base_url is ignored). The vLLM node changes every SLURM allocation, so pass it
+# at launch, e.g.: uv run launch_agent.py agent=gemma-4-31B-coords agent.hostname=h200-000-026
+hostname: null
+port: "8000"
+# vLLM does not check the key, but the OpenAI client requires a non-empty string.
+api_key: "EMPTY"
+# Grounding wants determinism, not diversity.
+temperature: 0
+max_tokens: 5000
+aws_access_key: null
+aws_secret_key: null
+aws_session_token: null
+aws_region: us-west-2
+
+# The model emits coordinates on the 0-1000 grid declared in system_prompt;
+# action_parsers.coords.rescale_xy converts them to viewport pixels.
+coord_scale: 1000
+
+# Coordinate-based actions only. Every action here is described in
+# action_prompt below and survives flexible_parser: mouse_click is emitted
+# directly, and keyboard_type / scroll / noop pass through untouched.
+custom_actions:
+- mouse_click
+- keyboard_type
+- keyboard_press
+- scroll
+- noop
+
+use_html: false
+use_axtree: false
+use_screenshot: true
+# No marks: this config is measuring raw pixel grounding, so the annotated
+# boxes would both give away the answer and clutter the image.
+save_som: false
+extract_visible_tag: false
+extract_clickable_tag: false
+extract_coords: false
+filter_visible_elements_only: false
+use_focused_element: false
+prompt_txt:
+ system_prompt: |-
+ You are a GUI agent operating a web browser. At each step you are given a
+ task, your action history, and a screenshot of the current page.
+
+ Coordinates use a fixed 1000x1000 grid, regardless of the real size of the
+ screenshot. x=0 is the left edge and x=1000 the right edge; y=0 is the top
+ edge and y=1000 the bottom edge. So the centre of the screen is (500, 500),
+ and a button in the bottom-right corner is near (900, 950).
+
+ Always give coordinates on that 1000x1000 grid. Never report coordinates in
+ the screenshot's own pixel size.
+
+ Aim for the centre of the element you want to click, not its edge or its
+ label's first character.
+ output_format: '
+
+
+
+
+
+
+
+
+ '
+
+ think_prompt: null
+ think_abstract_example: |
+
+ Think step by step. Describe what your previous action changed on the page,
+ name the element you need next, locate it in the screenshot, and convert
+ its centre to the 1000x1000 grid.
+
+ think_concrete_example: null
+ action_prompt: |
+ ## Action Space
+
+ mouse_click(x=X, y=Y)
+ Click at (X, Y) on the 1000x1000 grid. Use for links, buttons,
+ checkboxes, tabs, files in a sidebar, and to focus a text field.
+
+ keyboard_type(text="...")
+ Type text at the current keyboard focus. Click the field first to focus
+ it. This appends at the cursor, so to replace existing content select it
+ first with keyboard_press.
+
+ keyboard_press(key="...")
+ Press a key or chord, e.g. keyboard_press(key="Enter"),
+ keyboard_press(key="Control+a"), keyboard_press(key="Backspace").
+
+ scroll(delta_x, delta_y)
+ Scroll the page. Unlike mouse_click, these deltas are in screen pixels,
+ not grid units. Positive delta_y scrolls down, negative scrolls up.
+ e.g. scroll(0, 400)
+
+ noop(wait_ms=5000)
+ Wait and re-screenshot. Use only when the page appears to still be
+ loading.
+
+ ## Note
+ - Exactly one action per step, inside a single tag.
+ - Use English in the `think` part.
+ - Write a short plan, then state your next action and the element it
+ targets in one sentence.
+ - Check the history: if your last action did not change the page, your
+ coordinate probably missed. Re-locate the element and try a different
+ point rather than repeating the same one.
+ - Saving usually needs an explicit click on a Save/Send/Add button after
+ you type into a field.
+ action_abstract_example: |
+ mouse_click(x=X, y=Y)
+
+ or, to enter text once a field is focused:
+
+ keyboard_type(text="the text to type")
+ action_concrete_example: |
+ To click a Send button in the bottom-right of the chat panel:
+
+ mouse_click(x=870, y=940)
+
+ then on the next step, to type into the field it focused and submit:
+
+ keyboard_type(text="Hello world\n")
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/config/browsergym_env_args/screenshot.yaml b/config/browsergym_env_args/screenshot.yaml
new file mode 100644
index 00000000..7a8ab203
--- /dev/null
+++ b/config/browsergym_env_args/screenshot.yaml
@@ -0,0 +1,21 @@
+# Env args for screenshot-only agents. Select with:
+# uv run launch_agent.py agent=gemma-4-31B-computer-use browsergym_env_args=screenshot
+#
+# Differs from default.yaml in two ways:
+# * screen_resolution 1280x800 instead of 1920x1080. The screenshot is sent at
+# full size (agent.utils.image_to_jpg_base64_url does not downscale), so the
+# served model's image processor resizes it; the less it has to squash, the
+# more legible the set-of-marks labels are.
+# * max_steps 25 instead of 10. A screenshot-only agent spends steps
+# navigating (start page -> app -> item) and recovering from misclicks, and
+# 10 leaves no room for either.
+_target_: browsergym.experiments.EnvArgs
+task_name: "" # needs to be a registered task
+task_seed: 42 # not relevant for us we have only deterministic envrionments
+max_steps: 25 # maximum number of steps per episode
+headless: True # keep the browser open
+record_video: False
+
+task_kwargs:
+ base_url: null # this neets to be the localhost url, will be set automatically in launch_experiment.py
+ screen_resolution: [1280, 800]
diff --git a/config/tasks/original_tasks.yaml b/config/tasks/original_tasks.yaml
index 27e0f653..99d7af8a 100644
--- a/config/tasks/original_tasks.yaml
+++ b/config/tasks/original_tasks.yaml
@@ -193,3 +193,24 @@ navigate_to_messenger:
goal: Navigate to the Messenger app.
source_app: start_page
target_app: messages
+
+# ---- Code Editor tasks ---------------------------------------------------
+# EditFileTask walks the Code Editor file tree (the /codeeditor_all JSON)
+# and rewards once the target file's saved contents satisfy the content
+# requirement AND differ from the initial state (so an unsaved / unchanged
+# file earns no reward). Provide either `expected_content` (full-file match)
+# or `required_fragment` (substring match).
+
+edit_script_add_header_comment:
+ _target_: open_apps.tasks.tasks.EditFileTask
+ goal: Open 'script.py' in the Code Editor, add the line '# Reviewed by Bob' as a
+ comment, and save the file.
+ file_path: script.py
+ required_fragment: "# Reviewed by Bob"
+
+create_notes_file_in_code_editor:
+ _target_: open_apps.tasks.tasks.EditFileTask
+ goal: "In the Code Editor, create a new file named 'notes.txt' containing the text
+ 'TODO: refactor' and save it."
+ file_path: notes.txt
+ expected_content: "TODO: refactor"
diff --git a/docs/tasks.md b/docs/tasks.md
index ccd20b49..7674b173 100644
--- a/docs/tasks.md
+++ b/docs/tasks.md
@@ -61,6 +61,45 @@ my_custom_task:
Finally, ask your agent to solve the task by specifying `task_name=my_custom_task`.
+### Example: editing a file in the Code Editor
+
+`EditFileTask` makes the Code Editor a first-class task app. Unlike the other
+apps, the Code Editor state is excluded from the generic state comparison, so
+`EditFileTask` implements its own reward: it walks the current file tree (from
+the `/codeeditor_all` endpoint), finds the file at `file_path`, and checks its
+saved contents. It also compares against the **initial** state so an unsaved or
+unchanged file earns no reward.
+
+Provide either `required_fragment` (a substring that must appear in the file) or
+`expected_content` (a whitespace-normalized full-file match):
+
+```yaml
+edit_script_add_header_comment:
+ _target_: open_apps.tasks.tasks.EditFileTask
+ goal: Open 'script.py' in the Code Editor, add the line '# Reviewed by Bob' as a
+ comment, and save the file.
+ file_path: script.py
+ required_fragment: "# Reviewed by Bob"
+```
+
+The same class also covers creating a new file — if the file did not exist in
+the initial state, any matching saved content counts as a change:
+
+```yaml
+create_notes_file_in_code_editor:
+ _target_: open_apps.tasks.tasks.EditFileTask
+ goal: "In the Code Editor, create a new file named 'notes.txt' containing the text
+ 'TODO: refactor' and save it."
+ file_path: notes.txt
+ expected_content: "TODO: refactor"
+```
+
+Run it like any other task:
+
+```shell
+uv run launch_agent.py agent=gemma-4 task_name=edit_script_add_header_comment
+```
+
## Goal Variations
Tasks come with **goal variations**: the same task with its goal reworded in a
diff --git a/pyproject.toml b/pyproject.toml
index 6e844ccc..e19101f0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,3 +59,8 @@ build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
+
+[tool.pytest.ini_options]
+markers = [
+ "slow: spins up the real web app and/or a browser; deselect with -m 'not slow'",
+]
diff --git a/scripts/conduct_slurm.sh b/scripts/conduct_slurm.sh
index 730c3d32..5f95e095 100755
--- a/scripts/conduct_slurm.sh
+++ b/scripts/conduct_slurm.sh
@@ -3,23 +3,28 @@
# SLURM wrapper around scripts/conduct.sh.
#
# Requests a CPU allocation, auto-discovers the node running vLLM (serving the
-# target model on :8000), and launches the worker pool pointed at it. The eval
-# job talks to Gemma over the cluster-internal network, so no SSH tunnel from a
-# laptop is needed.
+# target model on :8000), and launches the worker pool pointed at it. This assume
+# the eval job is running on the same internal network as Gemme.
#
# Submit:
# AGENTS=gemma-4-computer-use COUNT=1 sbatch scripts/conduct_slurm.sh
# AGENTS="gemma-4-computer-use" COUNT=20 MAX_PARALLEL=4 sbatch scripts/conduct_slurm.sh
#
# Override discovery by pinning the node explicitly:
-# VLLM_HOST=h200-000-026 AGENTS=gemma-4-e2b-it COUNT=1 sbatch scripts/conduct_slurm.sh
+# VLLM_HOST=node-001 AGENTS=gemma-4-e2b-it COUNT=1 sbatch scripts/conduct_slurm.sh
#
# Any extra CLI args are forwarded verbatim to conduct.sh -> launch_agent.py.
#
+# The account, QOS and partition below are placeholders — sbatch will reject the
+# job until they name a real allocation. Edit them for your cluster, or leave
+# them and override at submit time, which takes precedence over these lines:
+#
+# sbatch --account=... --qos=... --partition=... scripts/conduct_slurm.sh
+#
#SBATCH --job-name=openapps-eval
-#SBATCH --account=memorization
-#SBATCH --qos=h200_memorization_high
-#SBATCH --partition=h200
+#SBATCH --account=example_replace_me
+#SBATCH --qos=qos_example_replace_me
+#SBATCH --partition=partition_example_replace_me
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=2
diff --git a/src/open_apps/agent/README.md b/src/open_apps/agent/README.md
index facba6da..8e965802 100644
--- a/src/open_apps/agent/README.md
+++ b/src/open_apps/agent/README.md
@@ -67,7 +67,52 @@ The `vllm_prompt.py` has several key components:
- Improve the prompt such that the model can output according to instruction.
- Improve the response parser to be more lenient in parsing. Note that if you change the prompt format, you might need to change the parser!
- Enable multi-action? (for example, don't some tasks require fill and click at the same time step?)
- - Figure out how to map coordinates correctly! Vision models use coordinates and it's unclear when a coordinate is correct but just off by a factor or a coordinate is wrong.
+
+## Coordinate Spaces
+
+Vision models disagree about what an `(x, y)` in their output means, and getting
+it wrong is silent: the click lands somewhere plausible and the episode scores 0
+without an error. `action_parsers/coords.py::rescale_xy` is the single place that
+conversion happens; every parser goes through `ActionParser.rescale`.
+
+| convention | `coord_scale` | who |
+| --- | --- | --- |
+| raw viewport pixels | `null` | UI-TARS 1.5, GPT-4o-style computer use |
+| normalized 0-1000 | `1000` | Qwen-VL, GLM-VL |
+| normalized [0, N) | `N` | PaliGemma/Gemma-lineage `` bins are 0-1024 |
+
+Each parser family carries a default (`uitars`: null, `qwen3vl`: 1000). Override
+per model in the agent yaml with `coord_scale: N`. Note this is one scalar applied
+against each viewport axis, which is what a *square* normalized grid means — it
+cannot express a model predicting in its own non-square resized image space.
+
+The most reliable setup is to *declare* the grid in the prompt and set
+`coord_scale` to match, rather than reverse-engineering a checkpoint's native
+convention — then the conversion is correct by construction as long as the model
+complies. `config/agent/Qwen3.6-VL-computer-use.yaml` and
+`config/agent/gemma-4-31B-coords.yaml` both do this with a 1000x1000 grid.
+
+Under the `uitars` grammar, rescaling applies to UI-TARS-native forms
+(`click(point=)`, `click(start_box=)`, `click(x=)`, `right_single(point=)`, and
+the `scroll(direction=, point=)` magnitude) *and* to models prompted directly in
+browsergym syntax (`mouse_click(x=, y=)` and friends). Bare `scroll(dx, dy)` is
+left alone — say so in the prompt, since a model on a normalized grid will
+otherwise not know what units to scroll in.
+
+### Calibrating a new model
+
+Don't guess the scale, measure it:
+
+1. Run a few episodes with `save_dir` set. Each step writes ground-truth element
+ boxes to `/set_of_marks_coordinates.json` (see `utils.save_som_coordinates`).
+2. For a step where the model clearly intended a particular element, compare the
+ raw predicted `(x, y)` (kept verbatim in `displayed_action`) against that
+ element's `bbox`.
+3. `predicted / actual` should come out near a constant ratio per axis. A ratio
+ of ~0.52 on a 1920-wide viewport means the model is emitting 0-1000; ~0.53
+ means 0-1024. A ratio near 1.0 with scattered error means the model is
+ grounding badly, not scaling wrong — no `coord_scale` will fix that. Fall back
+ to targeting elements by set-of-marks bid (`save_som: true`) instead.
## Configuration Options
diff --git a/src/open_apps/agent/action_parsers/__init__.py b/src/open_apps/agent/action_parsers/__init__.py
index d094756b..6f740e4d 100644
--- a/src/open_apps/agent/action_parsers/__init__.py
+++ b/src/open_apps/agent/action_parsers/__init__.py
@@ -3,6 +3,7 @@
Add a family: write an ``ActionParser`` subclass and register it below.
"""
from .base import ActionParser, ActionParserResult
+from .coords import rescale_xy
from .uitars import UITarsActionParser
from .qwen3vl import Qwen3VLActionParser
@@ -12,12 +13,28 @@
}
-def get_action_parser(name: str | None) -> ActionParser:
+def get_action_parser(
+ name: str | None, coord_scale: int | None = None
+) -> ActionParser:
+ """Build the action_parser for ``name``.
+
+ ``coord_scale`` overrides the family's default coordinate space when given;
+ None keeps the subclass default (uitars: raw pixels, qwen3vl: 0-1000).
+ """
if not name:
name = "uitars"
if name not in REGISTRY:
raise ValueError(f"Unknown action_parser {name!r}. Available: {sorted(REGISTRY)}")
- return REGISTRY[name]()
+ cls = REGISTRY[name]
+ if coord_scale is None:
+ return cls()
+ return cls(coord_scale=coord_scale)
-__all__ = ["ActionParser", "ActionParserResult", "REGISTRY", "get_action_parser"]
+__all__ = [
+ "ActionParser",
+ "ActionParserResult",
+ "REGISTRY",
+ "get_action_parser",
+ "rescale_xy",
+]
diff --git a/src/open_apps/agent/action_parsers/base.py b/src/open_apps/agent/action_parsers/base.py
index e46c7979..14a71534 100644
--- a/src/open_apps/agent/action_parsers/base.py
+++ b/src/open_apps/agent/action_parsers/base.py
@@ -7,6 +7,8 @@
from dataclasses import dataclass
from typing import TypedDict
+from .coords import rescale_xy
+
class ActionParserResult(TypedDict, total=False):
action: str
@@ -16,9 +18,18 @@ class ActionParserResult(TypedDict, total=False):
@dataclass
class ActionParser:
+ # Coordinate convention of this model family: None = raw viewport pixels,
+ # N = normalized [0, N) grid. Subclasses override the default; an agent yaml
+ # can override the subclass via ``agent.coord_scale``. See coords.rescale_xy.
+ coord_scale: int | None = None
+
def default_prompts(self) -> dict:
return {}
def parse(self, response: str, viewport: tuple[int, int]) -> ActionParserResult:
"""``viewport`` is (width, height) px. Raise ParseError on bad output."""
raise NotImplementedError
+
+ def rescale(self, x: float, y: float, viewport: tuple[int, int]) -> tuple[int, int]:
+ """Convert one model-space (x, y) pair into viewport pixels."""
+ return rescale_xy(x, y, self.coord_scale, viewport)
diff --git a/src/open_apps/agent/action_parsers/coords.py b/src/open_apps/agent/action_parsers/coords.py
new file mode 100644
index 00000000..14a66fbc
--- /dev/null
+++ b/src/open_apps/agent/action_parsers/coords.py
@@ -0,0 +1,41 @@
+"""Coordinate-space conversion shared by every action_parser.
+
+Models disagree on what an (x, y) in their output means. Three conventions are
+in play here:
+
+* raw viewport pixels -- UI-TARS 1.5, GPT-4o-style computer use. ``coord_scale=None``.
+* 0-1000 normalized -- Qwen-VL, GLM-VL. ``coord_scale=1000``.
+* any other normalized [0, N) grid -- e.g. PaliGemma/Gemma-lineage ````
+ bins are 0-1024. ``coord_scale=1024``.
+
+Getting this wrong is silent: the click lands somewhere plausible-looking on the
+page and the episode just scores 0. Set ``coord_scale`` per model family (see
+``ActionParser.coord_scale``), never per call site.
+"""
+from __future__ import annotations
+
+
+def rescale_xy(
+ x: float,
+ y: float,
+ coord_scale: int | None,
+ viewport: tuple[int, int],
+) -> tuple[int, int]:
+ """Map (x, y) into viewport pixels.
+
+ coord_scale=None: raw pixels (UI-TARS, GPT-4o). coord_scale=1000:
+ Qwen-VL / GLM-VL 0-1000 normalized. coord_scale=N: any normalized
+ [0, N) convention.
+
+ ``coord_scale`` is a single scalar applied against each viewport axis
+ independently, which is what a square normalized grid means. It cannot
+ express a model that predicts in its own non-square resized image space
+ (that needs a separate scale per axis).
+ """
+ vw, vh = viewport
+ if coord_scale:
+ return (
+ int(round(float(x) * vw / coord_scale)),
+ int(round(float(y) * vh / coord_scale)),
+ )
+ return int(round(float(x))), int(round(float(y)))
diff --git a/src/open_apps/agent/action_parsers/qwen3vl.py b/src/open_apps/agent/action_parsers/qwen3vl.py
index 3eca495c..c2b2d93f 100644
--- a/src/open_apps/agent/action_parsers/qwen3vl.py
+++ b/src/open_apps/agent/action_parsers/qwen3vl.py
@@ -8,22 +8,24 @@
import json
import re
+from dataclasses import dataclass
from agentlab.llm.llm_utils import ParseError
from .base import ActionParser, ActionParserResult
-# The model is prompted with a fictional 1000x1000 screen, so all coordinates
-# and scroll deltas it emits are in [0, 1000).
-_COORD_SPACE = 1000
-
# Closing tag may be absent on truncation; json.loads decides well-formedness.
_TOOL_CALL_RE = re.compile(r"\s*(\{.*?\})\s*(?: |$)", re.DOTALL)
_THINK_RE = re.compile(r"(.*?) ", re.DOTALL | re.IGNORECASE)
_ACTION_LINE_RE = re.compile(r"^\s*Action:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
+@dataclass
class Qwen3VLActionParser(ActionParser):
+ # The model is prompted with a fictional 1000x1000 screen, so all
+ # coordinates and scroll deltas it emits are in [0, 1000).
+ coord_scale: int | None = 1000
+
def parse(self, response: str, viewport: tuple[int, int]) -> ActionParserResult:
response = (response or "").strip()
if not response:
@@ -83,7 +85,7 @@ def _to_browsergym(self, action_name, args: dict, viewport: tuple[int, int]) ->
raise ParseError(
f"scroll 'delta' must be a [dx, dy] list, got {delta!r}."
)
- dx, dy = self._rescale(delta, viewport)
+ dx, dy = self.rescale(delta[0], delta[1], viewport)
return f"scroll({dx}, {dy})"
if action_name == "wait":
@@ -103,11 +105,4 @@ def _xy(self, args: dict, viewport: tuple[int, int]) -> tuple[int, int]:
coord = args.get("coordinate")
if not (isinstance(coord, (list, tuple)) and len(coord) == 2):
raise ParseError(f"'coordinate' must be a [x, y] list, got {coord!r}.")
- return self._rescale(coord, viewport)
-
- def _rescale(self, xy, viewport: tuple[int, int]) -> tuple[int, int]:
- vw, vh = viewport
- return (
- int(round(float(xy[0]) * vw / _COORD_SPACE)),
- int(round(float(xy[1]) * vh / _COORD_SPACE)),
- )
+ return self.rescale(coord[0], coord[1], viewport)
diff --git a/src/open_apps/agent/action_parsers/uitars.py b/src/open_apps/agent/action_parsers/uitars.py
index 95290f0d..84af2a2b 100644
--- a/src/open_apps/agent/action_parsers/uitars.py
+++ b/src/open_apps/agent/action_parsers/uitars.py
@@ -1,11 +1,29 @@
-"""UI-TARS action_parser (default): / ReAct, raw pixel coordinates."""
+"""UI-TARS action_parser (default): / ReAct grammar.
+
+UI-TARS grounds in raw viewport pixels, so ``coord_scale`` defaults to None and
+coordinates pass through untouched. Other models reuse this grammar with a
+normalized coordinate grid (Gemma emits a 0-N box); set ``agent.coord_scale``
+in their yaml and the same parser converts to pixels.
+"""
from __future__ import annotations
+from dataclasses import dataclass
+
from open_apps.agent.utils import flexible_parser
from .base import ActionParser, ActionParserResult
+@dataclass
class UITarsActionParser(ActionParser):
+ coord_scale: int | None = None
+
def parse(self, response: str, viewport: tuple[int, int]) -> ActionParserResult:
- return flexible_parser(response)
+ # Pass no hook at all when there is nothing to convert: flexible_parser
+ # treats "no rescale" as "leave every coordinate exactly as written",
+ # which is stricter than converting through an identity (that would
+ # still round floats and rewrite already-valid browsergym calls).
+ rescale = None
+ if self.coord_scale:
+ rescale = lambda x, y: self.rescale(x, y, viewport) # noqa: E731
+ return flexible_parser(response, rescale=rescale)
diff --git a/src/open_apps/agent/utils.py b/src/open_apps/agent/utils.py
index d8063f36..fe9dd3a9 100644
--- a/src/open_apps/agent/utils.py
+++ b/src/open_apps/agent/utils.py
@@ -187,9 +187,12 @@ def retry(
raise ParseError(f"Could not parse a valid value after {n_retry} retries.")
-def flexible_parser(response: str) -> dict:
+def flexible_parser(response: str, rescale=None) -> dict:
"""
A parser that tries to correct or interpret the LLMs output into a valid policy, e.g. if it did not close a parenthesis or tag.
+
+ ``rescale`` is an optional ``(x, y) -> (x, y)`` callable mapping the model's
+ coordinate space onto viewport pixels; None leaves coordinates untouched.
"""
response = response.strip()
result = {"action": None, "think": None}
@@ -257,7 +260,7 @@ def flexible_parser(response: str) -> dict:
# HACK to help UI TARS: remap UI TARS native actions to browser gym actions
result["displayed_action"] = result["action"] # store model native actions
- result = uitars_parser(result)
+ result = uitars_parser(result, rescale=rescale)
return result
@@ -321,11 +324,48 @@ def _normalize_hotkey_key(key: str) -> "str | None":
return "+".join(out)
-def uitars_parser(result):
- "Translates UITARS actions to browser gym actions"
+# mouse_click(x=870, y=940) / mouse_dblclick(x=1.5, y=2) / mouse_move(...),
+# capturing the two numbers and whatever trailing kwargs follow.
+_BG_MOUSE_XY_RE = re.compile(
+ r"^(?Pmouse_click|mouse_dblclick|mouse_move|mouse_down|mouse_up)"
+ r"\(\s*x\s*=\s*(?P-?\d+(?:\.\d+)?)\s*,"
+ r"\s*y\s*=\s*(?P-?\d+(?:\.\d+)?)\s*(?P[,)].*)$",
+ re.DOTALL,
+)
+
+
+def _rescale_browsergym_mouse_action(action: str, rescale) -> str:
+ """Convert x=/y= in an already-browsergym-form mouse action to pixels."""
+ match = _BG_MOUSE_XY_RE.match(action.strip())
+ if not match:
+ return action
+ x, y = rescale(match.group("x"), match.group("y"))
+ return f"{match.group('fn')}(x={x}, y={y}{match.group('rest')}"
+
+
+def uitars_parser(result, rescale=None):
+ """Translates UITARS actions to browser gym actions.
+
+ ``rescale`` is an optional ``(x, y) -> (x, y)`` callable converting the
+ model's coordinate space to viewport pixels (see
+ ``open_apps.agent.action_parsers.coords.rescale_xy``). UI-TARS itself emits
+ raw pixels, so the default is a no-op; models reusing this grammar with a
+ normalized grid (Gemma) pass one in.
+ """
# note karenu: I am not sure if the translation is perfect
# in particular if the coord are just transferable like that, but looks reasonable in practice
# also both browsergym and uitars docs are ass, so i have to guess
+ scaling = rescale is not None
+ if not scaling:
+ def rescale(x, y):
+ return int(round(float(x))), int(round(float(y)))
+
+ # A model prompted in browsergym syntax emits mouse_click(x=, y=) directly,
+ # so it never hits the UI-TARS remaps below and would otherwise skip
+ # rescaling entirely. Rewrite in place, preserving any other kwargs
+ # (button=...). Guarded on ``scaling`` so the default path is untouched.
+ if scaling:
+ result["action"] = _rescale_browsergym_mouse_action(result["action"], rescale)
# UITARS API -> BrowserGym API
@@ -341,7 +381,8 @@ def uitars_parser(result):
f"Could not parse two integer coordinates from click action: {result['action']!r}. "
"Expected format like click(point='(x,y)'), click(start_box='(x,y)'), or click(x=X, y=Y)."
)
- result["action"] = f"mouse_click(x={int(coords[0])}, y={int(coords[1])})"
+ x, y = rescale(coords[0], coords[1])
+ result["action"] = f"mouse_click(x={x}, y={y})"
# type(content=text) -> keyboard_type(text=text)
if result["action"].startswith("type(content="):
result["action"] = translate_uitars_type_action(result["action"])
@@ -355,7 +396,9 @@ def uitars_parser(result):
nums = re.findall(r"-?\d+", result["action"])
if dir_match and len(nums) >= 2:
direction = dir_match.group(1).lower()
- x, y = int(nums[0]), int(nums[1])
+ # The magnitude is read off the point, so it is in the same space as
+ # a click coordinate and needs the same conversion.
+ x, y = rescale(nums[0], nums[1])
if direction == "down":
dx, dy = 0, y
elif direction == "up":
@@ -373,9 +416,8 @@ def uitars_parser(result):
f"Could not parse two integer coordinates from right_single action: {result['action']!r}. "
"Expected format like right_single(point='(x,y)')."
)
- result["action"] = (
- f"mouse_click(x={int(coords[0])}, y={int(coords[1])}, button='right')"
- )
+ x, y = rescale(coords[0], coords[1])
+ result["action"] = f"mouse_click(x={x}, y={y}, button='right')"
# hotkey(key='ctrl alt e') -> keyboard_press(key='Control+Alt+e')
if result["action"].startswith("hotkey(key="):
key_comb = re.findall(r"hotkey\(key='(.*?)'\)", result["action"])
diff --git a/src/open_apps/agent/vLLM_agent.py b/src/open_apps/agent/vLLM_agent.py
index f40eff12..9e9dafcd 100644
--- a/src/open_apps/agent/vLLM_agent.py
+++ b/src/open_apps/agent/vLLM_agent.py
@@ -230,6 +230,10 @@ class AgentArgs(AgentLabAgentArgs):
# Per-model-family action_parser (parser + coordinate space). See
# open_apps.agent.action_parsers. Default preserves the flexible_parser path.
action_parser: str = "uitars"
+ # Coordinate space the model predicts in: None = raw viewport pixels,
+ # N = normalized [0, N) grid (Qwen-VL/GLM-VL use 1000). None keeps the
+ # action_parser's own default. See action_parsers.coords.rescale_xy.
+ coord_scale: int = None
# User-message sections to render; None = legacy default. See
# VllmMainPrompt._SECTION_RENDERERS.
prompt_sections: list[str] = None
@@ -296,6 +300,7 @@ def make_agent(self) -> Agent:
prompt_txt=self.prompt_txt,
save_dir=self.save_dir,
action_parser_name=self.action_parser,
+ coord_scale=self.coord_scale,
prompt_sections=self.prompt_sections,
)
@@ -309,6 +314,7 @@ def __init__(
max_retry: int = 3,
save_dir: str = None,
action_parser_name: str = "uitars",
+ coord_scale: int | None = None,
prompt_sections: list[str] | None = None,
):
logging.info("Initializing vllmAgent with flags: %s", asdict(flags))
@@ -318,7 +324,7 @@ def __init__(
self.flags = flags
self.action_set = flags.action.action_set.make_action_set()
self._obs_preprocessor = dp.make_obs_preprocessor(flags.obs)
- self.action_parser = get_action_parser(action_parser_name)
+ self.action_parser = get_action_parser(action_parser_name, coord_scale)
self.prompt_sections = (
list(prompt_sections) if prompt_sections is not None else None
)
diff --git a/src/open_apps/agent/vLLM_prompt.py b/src/open_apps/agent/vLLM_prompt.py
index 2691234e..65147d32 100644
--- a/src/open_apps/agent/vLLM_prompt.py
+++ b/src/open_apps/agent/vLLM_prompt.py
@@ -4,6 +4,7 @@
It is based on the dynamic_prompting module from the agentlab package.
"""
+import logging
from dataclasses import dataclass
from PIL import Image
import numpy as np
@@ -265,7 +266,14 @@ def __init__(
if screenshot is not None and hasattr(screenshot, "shape"):
self.viewport = (int(screenshot.shape[1]), int(screenshot.shape[0]))
else:
+ # Only reachable with use_screenshot off; any rescaling coordinates
+ # would silently land in the wrong place under a non-1080p preset.
self.viewport = (1920, 1080)
+ logging.warning(
+ "No screenshot in obs; falling back to a %s viewport for "
+ "coordinate rescaling.",
+ self.viewport,
+ )
self.action_prompt = ActionPrompt(action_set, action_flags=flags.action,
concrete_ex_txt=prompt_txt.get("action_concrete_example"),
diff --git a/src/open_apps/apps/assets/vendor/README.md b/src/open_apps/apps/assets/vendor/README.md
new file mode 100644
index 00000000..be64e1a2
--- /dev/null
+++ b/src/open_apps/apps/assets/vendor/README.md
@@ -0,0 +1,57 @@
+# Vendored frontend assets
+
+Third-party JavaScript and CSS, committed rather than fetched at page load.
+
+## Why these are checked in
+
+`fast_app()` / `FastHTML()` load htmx, Pico and three helper scripts from
+`cdn.jsdelivr.net` by default. On a host with no outbound network — which is
+what the SLURM eval nodes are — none of it arrives, and the failure is silent
+and severe:
+
+* **htmx missing** means every `hx-get` / `hx-post` / `hx-put` in every app is
+ inert. A checkbox still *appears* to toggle, because that is native browser
+ behaviour, but no request is sent and no server state changes. An agent
+ clicks the right element, the screenshot shows the click landed, and the task
+ scores zero.
+* **Pico missing** means every page renders unstyled. For a vision agent scored
+ on screenshots, that changes the observation itself.
+
+Neither failure raises an error anywhere. Serving these locally is what makes
+the environment behave the same offline as it does on a laptop.
+
+There is precedent: `../js/jquery.min.js` and `../css/fontawesome-all.min.css`
+are already vendored the same way.
+
+## Contents
+
+| File | Version | Upstream | License |
+|------|---------|----------|---------|
+| `htmx-2.0.4.min.js` | 2.0.4 | https://github.com/bigskysoftware/htmx | 0BSD |
+| `pico-2.1.1.min.css` | 2.1.1 | https://github.com/picocss/pico | MIT |
+
+Both are permissive and require no notice retention beyond the copyright
+headers already inside the files. Pico's header is intact at the top of the
+CSS; htmx's minified bundle carries no header, so its copyright is recorded
+here: *Copyright (c) 2020, Big Sky Software — Zero-Clause BSD.*
+
+Versions are pinned in the filename on purpose. FastHTML's default pulled
+`@picocss/pico@latest`, so the styling of an eval run depended on when it ran.
+
+## Updating
+
+```sh
+V=2.0.5
+curl -fsSL "https://cdn.jsdelivr.net/npm/htmx.org@${V}/dist/htmx.min.js" \
+ -o "src/open_apps/apps/assets/vendor/htmx-${V}.min.js"
+```
+
+Then update the constant in `src/open_apps/frontend.py`, delete the old file,
+and update the table above. `tests/test_no_egress.py` will fail if a page ends
+up referencing an origin that isn't explicitly allowed.
+
+## What is deliberately *not* vendored
+
+`fasthtml.js`, `surreal.js` and `css-scope-inline` are the remaining FastHTML
+defaults. Nothing in this repo uses them, so they are switched off rather than
+copied in — every vendored file is one more thing to keep patched.
diff --git a/src/open_apps/apps/assets/vendor/htmx-2.0.4.min.js b/src/open_apps/apps/assets/vendor/htmx-2.0.4.min.js
new file mode 100644
index 00000000..59937d71
--- /dev/null
+++ b/src/open_apps/apps/assets/vendor/htmx-2.0.4.min.js
@@ -0,0 +1 @@
+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q(''+t+" ");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/src/open_apps/apps/assets/vendor/pico-2.1.1.min.css b/src/open_apps/apps/assets/vendor/pico-2.1.1.min.css
new file mode 100644
index 00000000..e10ec26d
--- /dev/null
+++ b/src/open_apps/apps/assets/vendor/pico-2.1.1.min.css
@@ -0,0 +1,4 @@
+@charset "UTF-8";/*!
+ * Pico CSS ✨ v2.1.1 (https://picocss.com)
+ * Copyright 2019-2025 - Licensed under MIT
+ */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:""}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
\ No newline at end of file
diff --git a/src/open_apps/apps/calendar_app/main.py b/src/open_apps/apps/calendar_app/main.py
index 573d0590..1b568f9b 100644
--- a/src/open_apps/apps/calendar_app/main.py
+++ b/src/open_apps/apps/calendar_app/main.py
@@ -12,6 +12,7 @@
Ul, Li, Hr, Article, Button, RedirectResponse, Container, MarkdownJS,
HighlightJS, database, dataclass)
from datetime import datetime, timedelta
+from src.open_apps.frontend import local_hdrs
import calendar
import os
import logging
@@ -185,8 +186,9 @@ def generate_styles_from_config(config):
app, rt = fast_app(
- pico=True,
+ default_hdrs=False,
hdrs=(
+ *local_hdrs(),
MarkdownJS(),
HighlightJS(langs=["python", "javascript", "html", "css"]),
Script(src="https://unpkg.com/@phosphor-icons/web"),
diff --git a/src/open_apps/apps/codeeditor_app/main.py b/src/open_apps/apps/codeeditor_app/main.py
index de29785c..561b8caf 100644
--- a/src/open_apps/apps/codeeditor_app/main.py
+++ b/src/open_apps/apps/codeeditor_app/main.py
@@ -11,10 +11,12 @@
import json
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
# Global variables
_base_hdrs_no_highlight = (
- picolink,
+ # Pico + htmx from apps/assets/vendor, not jsdelivr (see frontend.py).
+ *local_hdrs(),
Script(src="https://cdn.tailwindcss.com"),
Link(
rel="stylesheet",
@@ -33,7 +35,7 @@
logo_title_container = None
# Initialize app with default headers
-app = FastHTML(hdrs=_base_hdrs, cls="p-4")
+app = FastHTML(hdrs=[*local_hdrs(), *_base_hdrs], cls="p-4", default_hdrs=False)
import yaml
import os
@@ -91,7 +93,8 @@ def set_environment(config):
update_db_from_hydra(config)
print(f"- Code editor filesystem created under {current_dir}")
_base_hdrs_with_highlight = (
- picolink,
+ # Pico + htmx from apps/assets/vendor, not jsdelivr (see frontend.py).
+ *local_hdrs(),
Script(src="https://cdn.tailwindcss.com"),
Link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/daisyui@4.11.1/dist/full.min.css"),
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css"),
@@ -201,6 +204,40 @@ def newfile_index(current_path):
i += 1
return i
+def editor_binding(options_js: str) -> str:
+ """JS that binds the page-global ``editor`` used by Save / the selectors.
+
+ With ``code_editor.highlight`` on, ``editor`` is a CodeMirror instance
+ wrapping the ``#editor`` textarea. With it off there is no CodeMirror on
+ the page (the CDN scripts are only added in the highlight branch of
+ ``set_environment``), so bind a small shim over the plain textarea that
+ exposes the handful of methods the page calls: ``getValue`` (Save),
+ ``setValue``, ``setOption`` (mode/theme selectors), and ``setSize``.
+
+ Without the shim the emitted JS was
+ ``var editor = (document.getElementById('editor'), {...});`` — the comma
+ operator, which bound ``editor`` to the *options object*. Every
+ ``editor.getValue()`` then threw a TypeError, so the Save button silently
+ did nothing: no POST, no reload, no error modal.
+ """
+ if app.config.code_editor.highlight:
+ return (
+ "var editor = CodeMirror.fromTextArea(document.getElementById('editor'), "
+ f"{options_js});"
+ )
+ return """
+ var editorTextarea = document.getElementById('editor');
+ var editor = {
+ getValue: function() { return editorTextarea.value; },
+ setValue: function(value) { editorTextarea.value = value; },
+ getOption: function() { return null; },
+ setOption: function() {},
+ setSize: function() {},
+ refresh: function() {},
+ focus: function() { editorTextarea.focus(); }
+ };"""
+
+
def get_file_tree(path: str) -> Dict:
"""Recursively build a file tree structure"""
# base_path = os.path.join(current_dir, "files")
@@ -413,6 +450,28 @@ def index():
# files_root = f"{current_dir}/files/"
files_root = current_dir
file_tree = get_file_tree(files_root)
+ editor_options = f"""{{
+ mode: '{app.config.code_editor.mode}',
+ theme: '{app.config.code_editor.theme}',
+ lineNumbers: true,
+ indentUnit: 4,
+ tabSize: 4,
+ indentWithTabs: false,
+ smartIndent: true,
+ lineWrapping: true,
+ extraKeys: {{
+ "Tab": function(cm) {{
+ if (cm.somethingSelected()) {{
+ cm.indentSelection("add");
+ }} else {{
+ cm.replaceSelection(" ", "end", "+input");
+ }}
+ }},
+ "Shift-Tab": function(cm) {{
+ cm.indentSelection("subtract");
+ }}
+ }}
+ }}"""
# by default, the main screen should display an empty code editor
main_screen = Div(cls="w-5/6")(
Div(cls="main-content p-4 rounded-lg styled-content")(
@@ -481,28 +540,7 @@ def index():
disabled="disabled"
),
Script(f"""
- var editor = {'CodeMirror.fromTextArea' if app.config.code_editor.highlight else ''} (document.getElementById('editor'), {{
- mode: '{app.config.code_editor.mode}',
- theme: '{app.config.code_editor.theme}',
- lineNumbers: true,
- indentUnit: 4,
- tabSize: 4,
- indentWithTabs: false,
- smartIndent: true,
- lineWrapping: true,
- extraKeys: {{
- "Tab": function(cm) {{
- if (cm.somethingSelected()) {{
- cm.indentSelection("add");
- }} else {{
- cm.replaceSelection(" ", "end", "+input");
- }}
- }},
- "Shift-Tab": function(cm) {{
- cm.indentSelection("subtract");
- }}
- }}
- }});
+ {editor_binding(editor_options)}
{f'editor.setSize("100%", "calc(100vh - 12rem)");' if app.config.code_editor.highlight else ''}
"""),
),
@@ -532,6 +570,12 @@ def get(path: str):
def get_folder(folder: str):
"""Handle folder view with empty editor"""
side_bar = create_sidebar(folder)
+ editor_options = f"""{{
+ mode: '{app.config.code_editor.mode}',
+ theme: '{app.config.code_editor.theme}',
+ lineNumbers: true,
+ readOnly: true
+ }}"""
main_screen = Div(cls="w-5/6")(
Div(cls="main-content p-4 rounded-lg styled-content")(
Div(cls="flex justify-between items-center")(
@@ -587,12 +631,7 @@ def get_folder(folder: str):
disabled="disabled"
),
Script(f"""
- var editor = {'CodeMirror.fromTextArea' if app.config.code_editor.highlight else ''} (document.getElementById('editor'), {{
- mode: '{app.config.code_editor.mode}',
- theme: '{app.config.code_editor.theme}',
- lineNumbers: true,
- readOnly: true
- }});
+ {editor_binding(editor_options)}
{f'editor.setSize("100%", "calc(100vh - 12rem)");' if app.config.code_editor.highlight else ''}
"""),
),
@@ -635,6 +674,35 @@ def get_file(file: str):
# files_root = f"{current_dir}/files/"
files_root = current_dir
file_tree = get_file_tree(files_root)
+ editor_options = f"""{{
+ mode: '{app.config.code_editor.mode}',
+ theme: '{app.config.code_editor.theme}',
+ lineNumbers: true,
+ indentUnit: 4,
+ tabSize: 4,
+ indentWithTabs: false,
+ smartIndent: true,
+ lineWrapping: true,
+ screenReaderLabel: 'Code editor',
+ inputStyle: 'contenteditable',
+ role: 'textbox',
+ 'aria-multiline': true,
+ 'aria-atomic': true,
+ 'aria-live': 'off',
+ announceMultiline: true,
+ extraKeys: {{
+ "Tab": function(cm) {{
+ if (cm.somethingSelected()) {{
+ cm.indentSelection("add");
+ }} else {{
+ cm.replaceSelection(" ", "end", "+input");
+ }}
+ }},
+ "Shift-Tab": function(cm) {{
+ cm.indentSelection("subtract");
+ }}
+ }}
+ }}"""
# same layout and sidebar as the main screen
side_bar = create_sidebar(file)
tab_bar = Div(cls="flex overflow-x-auto bg-gray-800 border-b border-gray-700")(
@@ -864,35 +932,7 @@ def get_file(file: str):
cls="sr-only"
)(f"Code editor for editing {file}"),
Script(f"""
- var editor = {'CodeMirror.fromTextArea' if app.config.code_editor.highlight else ''} (document.getElementById('editor'), {{
- mode: '{app.config.code_editor.mode}',
- theme: '{app.config.code_editor.theme}',
- lineNumbers: true,
- indentUnit: 4,
- tabSize: 4,
- indentWithTabs: false,
- smartIndent: true,
- lineWrapping: true,
- screenReaderLabel: 'Code editor',
- inputStyle: 'contenteditable',
- role: 'textbox',
- 'aria-multiline': true,
- 'aria-atomic': true,
- 'aria-live': 'off',
- announceMultiline: true,
- extraKeys: {{
- "Tab": function(cm) {{
- if (cm.somethingSelected()) {{
- cm.indentSelection("add");
- }} else {{
- cm.replaceSelection(" ", "end", "+input");
- }}
- }},
- "Shift-Tab": function(cm) {{
- cm.indentSelection("subtract");
- }}
- }}
- }});
+ {editor_binding(editor_options)}
{f'editor.setSize("100%", "calc(100vh - 12rem)");' if app.config.code_editor.highlight else ''}
"""),
),
diff --git a/src/open_apps/apps/messenger_app/main.py b/src/open_apps/apps/messenger_app/main.py
index b815be7e..44e992bf 100644
--- a/src/open_apps/apps/messenger_app/main.py
+++ b/src/open_apps/apps/messenger_app/main.py
@@ -11,6 +11,7 @@
import ast
import json
from src.open_apps.apps.start_page.helper import create_logo_header
+from open_apps.frontend import local_hdrs
@dataclass
@@ -253,8 +254,11 @@ class Messages:
""")
)
_base_hdrs = (
- picolink,
- Script(src="https://unpkg.com/htmx.org@1.9.10"), # Add this line if not present
+ # Pico and htmx served from apps/assets/vendor rather than a CDN. This app
+ # previously loaded picolink (Pico from jsdelivr) plus htmx 1.9.10 from
+ # unpkg, on top of the htmx 2.0.4 FastHTML injects by default -- two htmx
+ # versions racing on a page, both of which vanish on an offline host.
+ *local_hdrs(),
Script(src="https://cdn.tailwindcss.com"),
Link(
rel="stylesheet",
@@ -266,7 +270,7 @@ class Messages:
),
_base_chat_script,
)
-app = FastHTML(hdrs=_base_hdrs, cls="p-4 max-w-lg mx-auto")
+app = FastHTML(hdrs=_base_hdrs, cls="p-4 max-w-lg mx-auto", default_hdrs=False)
def set_environment(config):
diff --git a/src/open_apps/apps/start_page/helper.py b/src/open_apps/apps/start_page/helper.py
index 38aa7f67..203da42d 100644
--- a/src/open_apps/apps/start_page/helper.py
+++ b/src/open_apps/apps/start_page/helper.py
@@ -10,6 +10,8 @@
import subprocess
from pathlib import Path
+from open_apps.frontend import local_hdrs
+
# src/proficiency_playground/playground_server
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -598,7 +600,13 @@ def get_app(hdrs=None, *args, **kwargs):
href="/assets/css/main.css",
)
)
- app = FastHTML(hdrs=hdrs, *args, **kwargs)
+ # This is the app that actually serves every route -- the other apps' routes
+ # are mounted onto it -- so its headers are what the browser sees. htmx and
+ # Pico come from apps/assets/vendor via the static route below rather than
+ # from jsdelivr; default_hdrs=False is what stops FastHTML prepending the
+ # CDN copies. See src/open_apps/frontend.py for why that matters.
+ hdrs = local_hdrs() + hdrs
+ app = FastHTML(hdrs=hdrs, *args, default_hdrs=False, **kwargs)
@app.get("/{fname:path}.{ext:static}")
def static(fname: str, ext: str):
diff --git a/src/open_apps/apps/todo_app/main.py b/src/open_apps/apps/todo_app/main.py
index 5fd7fb98..7712b54f 100644
--- a/src/open_apps/apps/todo_app/main.py
+++ b/src/open_apps/apps/todo_app/main.py
@@ -9,6 +9,7 @@
import json
from typing import List
from src.open_apps.apps.start_page.helper import create_logo_header
+from src.open_apps.frontend import local_hdrs
@dataclass
@@ -18,7 +19,7 @@ class Todo:
done: bool
-app, rt = fast_app()
+app, rt = fast_app(default_hdrs=False, hdrs=local_hdrs())
logo_title_container = None
styles = Style("")
diff --git a/src/open_apps/frontend.py b/src/open_apps/frontend.py
new file mode 100644
index 00000000..1b08c5dc
--- /dev/null
+++ b/src/open_apps/frontend.py
@@ -0,0 +1,69 @@
+"""
+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.
+
+Locally-served frontend headers.
+
+``fast_app()`` and ``FastHTML()`` default to loading htmx, Pico and three
+helper scripts from ``cdn.jsdelivr.net``. That is fine on a laptop and useless
+on an eval node with no outbound network, where the assets never arrive and
+nothing says so:
+
+* without htmx, every ``hx-*`` attribute in every app is inert — a checkbox
+ still toggles visually, because that is the browser's own behaviour, but no
+ request is sent and no server state changes, so the task scores zero;
+* without Pico, every page renders unstyled, which for a screenshot-scored
+ agent changes the observation itself.
+
+Every app therefore constructs its FastHTML instance with
+``default_hdrs=False`` and takes its headers from here instead. The files live
+in ``apps/assets/vendor/`` and are served by the static route in
+``apps/start_page/helper.py``.
+
+Usage::
+
+ from open_apps.frontend import local_hdrs
+
+ app, rt = fast_app(default_hdrs=False, hdrs=local_hdrs())
+
+``default_hdrs=False`` is not optional. Omit it and FastHTML prepends the CDN
+tags anyway, the page works on a laptop, and the regression only shows up as a
+run of zero-reward episodes on the cluster.
+"""
+from __future__ import annotations
+
+from fasthtml.common import Link, Script
+
+# Pinned by filename. FastHTML's default pulled `@picocss/pico@latest`, which
+# made the styling of an eval run depend on the day it ran.
+HTMX_FILENAME = "htmx-2.0.4.min.js"
+PICO_FILENAME = "pico-2.1.1.min.css"
+
+VENDOR_URL = "/assets/vendor"
+
+HTMX_URL = f"{VENDOR_URL}/{HTMX_FILENAME}"
+PICO_URL = f"{VENDOR_URL}/{PICO_FILENAME}"
+
+
+def local_hdrs(pico: bool = True, htmx: bool = True) -> list:
+ """Return the header elements FastHTML would otherwise load from a CDN.
+
+ Args:
+ pico: include the Pico baseline stylesheet. Pass ``False`` for an app
+ that brings its own full stylesheet and only needs the behaviour.
+ htmx: include htmx. Effectively always wanted — every app in this repo
+ drives its state changes through ``hx-*`` attributes — but kept
+ explicit so a static page can opt out.
+
+ Returns a fresh list each call: FastHTML mutates the ``hdrs`` list it is
+ given, so a shared module-level constant would accumulate one app's headers
+ onto the next.
+ """
+ hdrs = []
+ if pico:
+ hdrs.append(Link(rel="stylesheet", href=PICO_URL))
+ if htmx:
+ hdrs.append(Script(src=HTMX_URL))
+ return hdrs
diff --git a/src/open_apps/launcher.py b/src/open_apps/launcher.py
index 715cc045..f22bea96 100644
--- a/src/open_apps/launcher.py
+++ b/src/open_apps/launcher.py
@@ -44,15 +44,10 @@
from open_apps.tasks.tasks import Task
from open_apps.utils import merge_plus_keys
-try:
- # Register the custom 'now' resolver
- OmegaConf.register_resolver(
- "now",
- lambda format_str="%Y-%m-%d_%H-%M-%S": datetime.now().strftime(format_str),
- )
-except AssertionError:
- # resolver already registered, ignore
- pass
+# Note: the "now" interpolation resolver (used as ${now:...} in the configs)
+# is provided by Hydra's own setup at run/compose time, so we don't register
+# one here. (A local registration previously lived here but was shadowed by
+# Hydra's and never actually ran.)
class OpenAppsLauncher:
@@ -307,18 +302,50 @@ def _log_agent_results_to_wandb(self, exp_record: dict, exp_result):
cum_reward = float(exp_record.get("cum_reward") or 0.0)
wandb.run.summary["cum_reward"] = cum_reward
wandb.run.summary["success"] = int(cum_reward >= 1.0)
+ steps_info = exp_result.steps_info
+
+ def action_error(i: int) -> str:
+ """BrowserGym's ``last_action_error`` for the action taken at step ``i``.
+
+ An observation describes the state *after* the preceding action, so
+ the error raised by ``steps_info[i].action`` lands in
+ ``steps_info[i + 1].obs``, not in step ``i``'s own obs. Without
+ this shift the column would blame each failure on the next action.
+ The final step has no successor (the loop ended), so its action —
+ if any — has no recorded outcome.
+ """
+ if i + 1 >= len(steps_info):
+ return ""
+ next_obs = steps_info[i + 1].obs
+ return str(next_obs.get("last_action_error", "") if next_obs else "")
+
actions_data = [
[
i,
str(step_info.action),
- str(step_info.obs["open_pages_urls"]),
- str(step_info.agent_info.get("think")),
+ str(step_info.obs.get("open_pages_urls") if step_info.obs else None),
+ str(
+ step_info.agent_info.get("think")
+ if step_info.agent_info
+ else None
+ ),
+ # Whether the environment actually accepted the action. Agents
+ # routinely narrate success in `think` for a click that never
+ # landed, so without this the table reads as a win either way.
+ action_error(i),
]
- for i, step_info in enumerate(exp_result.steps_info)
+ for i, step_info in enumerate(steps_info)
]
actions_table = wandb.Table(
- data=actions_data, columns=["step", "action", "open_pages_urls", "think"]
+ data=actions_data,
+ columns=[
+ "step",
+ "action",
+ "open_pages_urls",
+ "think",
+ "last_action_error",
+ ],
)
wandb.log({"actions": actions_table})
print("logging screenshots to wandb")
diff --git a/src/open_apps/tasks/__init__.py b/src/open_apps/tasks/__init__.py
index 1da4d1cd..0030d33f 100644
--- a/src/open_apps/tasks/__init__.py
+++ b/src/open_apps/tasks/__init__.py
@@ -18,6 +18,7 @@
AddToDoTask,
CompositeTask,
DeleteToDoTask,
+ EditFileTask,
MarkToDoDoneTask,
NavigateToAppTask,
RemoveEventTask,
@@ -33,6 +34,7 @@
"AddToDoTask",
"CompositeTask",
"DeleteToDoTask",
+ "EditFileTask",
"MarkToDoDoneTask",
"NavigateToAppTask",
"RemoveEventTask",
@@ -58,6 +60,7 @@
"SendMessageTask": "messages",
"SavePlaceTask": "map",
"RemoveLandmarkTask": "map",
+ "EditFileTask": "codeeditor",
}
diff --git a/src/open_apps/tasks/tasks.py b/src/open_apps/tasks/tasks.py
index 5984666a..b39b9d74 100644
--- a/src/open_apps/tasks/tasks.py
+++ b/src/open_apps/tasks/tasks.py
@@ -672,6 +672,7 @@ def check_if_task_is_complete(
)
+@dataclass
@dataclass
class CompositeTask(Task):
"""A meta-task made of several sub-tasks that must *all* be satisfied.
@@ -753,5 +754,94 @@ def check_if_task_is_complete(
return app_state_comparison.compare()
+@dataclass
+class EditFileTask(Task):
+ """Edit (and save) a file in the Code Editor app.
+
+ Because the Code Editor state is intentionally excluded from
+ ``AppStateComparison`` (see ``AppStateComparison.preprocess``), this
+ task implements its own reward logic that walks the Code Editor file
+ tree directly (the JSON returned by the ``/codeeditor_all`` endpoint).
+
+ Reward = 1 once the file at ``file_path`` in the *current* Code Editor
+ state satisfies the content requirement **and** its content differs
+ from the *initial* Code Editor state. The latter check ensures an
+ unsaved / unchanged file does not earn reward.
+
+ Provide exactly one content requirement:
+ * ``expected_content`` — the file's full contents must match this
+ (whitespace-normalized), or
+ * ``required_fragment`` — this text fragment must appear in the file.
+
+ This class also handles the "create a new file" case: if the file did
+ not exist in the initial state, any matching current content counts as
+ a change.
+ """
+
+ file_path: str
+ expected_content: str | None = None
+ required_fragment: str | None = None
+
+ @staticmethod
+ def _normalize(text: str) -> str:
+ # Normalize line endings and strip trailing whitespace per line so
+ # editor-added newlines / indentation noise don't cause spurious
+ # mismatches.
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+ return "\n".join(line.rstrip() for line in lines).strip()
+
+ def _find_file_content(self, tree: dict, target_path: str) -> str | None:
+ """Return the content of the file at ``target_path`` in the file
+ tree, or ``None`` if no such file exists."""
+ if not isinstance(tree, dict):
+ return None
+ for child in tree.get("children", []) or []:
+ if child.get("type") == "file" and child.get("path") == target_path:
+ return child.get("content", "")
+ if child.get("type") == "folder":
+ found = self._find_file_content(child, target_path)
+ if found is not None:
+ return found
+ return None
+
+ def _content_matches(self, content: str) -> bool:
+ if self.expected_content is not None:
+ return self._normalize(content) == self._normalize(self.expected_content)
+ if self.required_fragment is not None:
+ return self._normalize(self.required_fragment) in self._normalize(content)
+ raise ValueError(
+ "EditFileTask requires either 'expected_content' or 'required_fragment'"
+ )
+
+ def check_if_task_is_complete(
+ self, initial_state: dict, current_state: dict, current_url: str | None = None
+ ) -> bool:
+ if isinstance(current_state, DictConfig):
+ current_state = OmegaConf.to_container(current_state, resolve=True)
+ if isinstance(initial_state, DictConfig):
+ initial_state = OmegaConf.to_container(initial_state, resolve=True)
+
+ current_tree = current_state.get("codeeditor") or {}
+ current_content = self._find_file_content(current_tree, self.file_path)
+ if current_content is None:
+ # The target file does not exist in the Code Editor.
+ return False
+
+ if not self._content_matches(current_content):
+ # Wrong file / wrong contents.
+ return False
+
+ # Require the file to have actually changed vs. the initial state so
+ # that an unsaved or unchanged file does not earn reward.
+ initial_tree = initial_state.get("codeeditor") or {}
+ initial_content = self._find_file_content(initial_tree, self.file_path)
+ if initial_content is not None and self._normalize(
+ initial_content
+ ) == self._normalize(current_content):
+ return False
+
+ return True
+
+
if __name__ == "__main__":
pass
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))
diff --git a/tests/test_action_parsers.py b/tests/test_action_parsers.py
index 959d0c2c..4438df59 100644
--- a/tests/test_action_parsers.py
+++ b/tests/test_action_parsers.py
@@ -6,7 +6,7 @@
import pytest
from agentlab.llm.llm_utils import ParseError
-from open_apps.agent.action_parsers import REGISTRY, get_action_parser
+from open_apps.agent.action_parsers import REGISTRY, get_action_parser, rescale_xy
from open_apps.agent.action_parsers.qwen3vl import Qwen3VLActionParser
@@ -30,6 +30,54 @@ def test_get_action_parser_raises_on_unknown_name():
get_action_parser("bogus")
+def test_action_parser_families_carry_their_own_coord_space():
+ assert get_action_parser("uitars").coord_scale is None
+ assert get_action_parser("qwen3vl").coord_scale == 1000
+
+
+def test_coord_scale_argument_overrides_the_family_default():
+ assert get_action_parser("uitars", 1024).coord_scale == 1024
+ assert get_action_parser("qwen3vl", 1024).coord_scale == 1024
+ # None means "keep the family default", not "raw pixels".
+ assert get_action_parser("qwen3vl", None).coord_scale == 1000
+
+
+# ---------------------------------------------------------------------------
+# rescale_xy: the shared coordinate conversion
+# ---------------------------------------------------------------------------
+
+def test_rescale_xy_passes_raw_pixels_through_when_scale_is_none():
+ assert rescale_xy(612, 455, None, VIEWPORT) == (612, 455)
+
+
+def test_rescale_xy_rounds_raw_pixels_to_int():
+ assert rescale_xy(612.6, 455.4, None, VIEWPORT) == (613, 455)
+
+
+def test_rescale_xy_maps_normalized_1000_space_to_viewport():
+ assert rescale_xy(870, 940, 1000, VIEWPORT) == (1670, 1015)
+ assert rescale_xy(500, 500, 1000, VIEWPORT) == (960, 540)
+
+
+def test_rescale_xy_maps_normalized_1024_space_to_viewport():
+ # Gemma/PaliGemma-lineage bins are 0-1024.
+ assert rescale_xy(512, 512, 1024, (1280, 800)) == (640, 400)
+
+
+def test_rescale_xy_scales_each_axis_by_its_own_viewport_dimension():
+ # Same input coordinate, non-square viewport -> different pixel per axis.
+ assert rescale_xy(500, 500, 1000, (1280, 800)) == (640, 400)
+
+
+def test_rescale_xy_preserves_sign_for_negative_deltas():
+ assert rescale_xy(0, -500, 1000, VIEWPORT) == (0, -540)
+
+
+def test_rescale_xy_treats_zero_scale_as_raw_pixels():
+ # ``if coord_scale`` guards the division; 0 must not raise.
+ assert rescale_xy(100, 200, 0, VIEWPORT) == (100, 200)
+
+
# ---------------------------------------------------------------------------
# qwen3vl: prompts live in yaml, not the action_parser
# ---------------------------------------------------------------------------
@@ -186,3 +234,83 @@ def test_uitars_action_parser_parses_native_action_syntax():
assert out["action"] == "mouse_click(x=100, y=200)"
assert out["think"] == "Click the Submit button."
assert out["displayed_action"] == "click(point='100 200 ')"
+
+
+# ---------------------------------------------------------------------------
+# uitars grammar + a normalized coord space (the Gemma screenshot-only case)
+# ---------------------------------------------------------------------------
+
+def test_uitars_grammar_rescales_clicks_when_coord_scale_is_set():
+ a = get_action_parser("uitars", coord_scale=1000)
+ response = "t click(point='(500,500)') "
+ out = a.parse(response, viewport=(1280, 800))
+ assert out["action"] == "mouse_click(x=640, y=400)"
+ # The model-native text shown in history stays in the model's own space.
+ assert out["displayed_action"] == "click(point='(500,500)')"
+
+
+def test_uitars_grammar_rescales_right_click_when_coord_scale_is_set():
+ a = get_action_parser("uitars", coord_scale=1000)
+ response = "t right_single(point='(500,500)') "
+ out = a.parse(response, viewport=(1280, 800))
+ assert out["action"] == "mouse_click(x=640, y=400, button='right')"
+
+
+def test_uitars_grammar_rescales_scroll_magnitude_when_coord_scale_is_set():
+ # The magnitude is read off the point, so it lives in the same space.
+ a = get_action_parser("uitars", coord_scale=1000)
+ response = "t scroll(direction='down', point='(500,500)') "
+ assert a.parse(response, viewport=(1280, 800))["action"] == "scroll(0, 400)"
+
+
+def test_coord_scale_does_not_touch_bid_or_text_actions():
+ a = get_action_parser("uitars", coord_scale=1000)
+ for action in ['click("96")', 'fill("92", "hello")', "scroll(0, 400)"]:
+ response = f"t {action} "
+ assert a.parse(response, viewport=(1280, 800))["action"] == action
+
+
+def test_default_uitars_parser_still_passes_raw_pixels_through():
+ """Regression guard: the default path must be byte-identical to before."""
+ a = get_action_parser("uitars")
+ response = "t click(point='(500,500)') "
+ assert a.parse(response, viewport=(1280, 800))["action"] == "mouse_click(x=500, y=500)"
+
+
+# ---------------------------------------------------------------------------
+# Models prompted directly in browsergym syntax (mouse_click(x=, y=))
+# ---------------------------------------------------------------------------
+
+def test_browsergym_form_mouse_click_is_rescaled():
+ """These never hit the UI-TARS remaps, so they need their own conversion."""
+ a = get_action_parser("uitars", coord_scale=1000)
+ response = "t mouse_click(x=500, y=500) "
+ assert a.parse(response, viewport=(1280, 800))["action"] == "mouse_click(x=640, y=400)"
+
+
+def test_browsergym_form_rescale_preserves_trailing_kwargs():
+ a = get_action_parser("uitars", coord_scale=1000)
+ response = "t mouse_click(x=500, y=500, button='right') "
+ out = a.parse(response, viewport=(1280, 800))
+ assert out["action"] == "mouse_click(x=640, y=400, button='right')"
+
+
+def test_browsergym_form_rescale_covers_the_other_mouse_actions():
+ a = get_action_parser("uitars", coord_scale=1000)
+ for fn in ["mouse_dblclick", "mouse_move", "mouse_down", "mouse_up"]:
+ response = f"t {fn}(x=500, y=500) "
+ assert a.parse(response, viewport=(1280, 800))["action"] == f"{fn}(x=640, y=400)"
+
+
+def test_browsergym_form_rescale_accepts_float_coordinates():
+ a = get_action_parser("uitars", coord_scale=1000)
+ response = "t mouse_click(x=500.5, y=500.5) "
+ assert a.parse(response, viewport=(1280, 800))["action"] == "mouse_click(x=641, y=400)"
+
+
+def test_browsergym_form_is_untouched_without_coord_scale():
+ """Default path: floats must not even be rounded."""
+ a = get_action_parser("uitars")
+ response = "t mouse_click(x=500.5, y=455) "
+ out = a.parse(response, viewport=(1280, 800))
+ assert out["action"] == "mouse_click(x=500.5, y=455)"
diff --git a/tests/test_agent_config_coherence.py b/tests/test_agent_config_coherence.py
new file mode 100644
index 00000000..43b29b24
--- /dev/null
+++ b/tests/test_agent_config_coherence.py
@@ -0,0 +1,197 @@
+"""
+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.
+"""
+
+"""
+Coherence checks between an agent config's prompt and its actual action space.
+
+An agent yaml can advertise an action in ``prompt_txt.action_prompt`` that the
+runtime cannot execute: the action has to survive ``flexible_parser`` *and* be
+present in the ``HighLevelActionSet`` built from ``custom_actions``. When it
+isn't, the model burns a step per attempt and the episode silently scores 0.
+That is how ``wait()`` shipped in the UI-TARS-derived configs -- advertised in
+the prompt, absent from ``custom_actions``, and not remapped by the parser.
+"""
+
+import hydra
+import pytest
+from hydra import compose, initialize
+
+from open_apps.agent.utils import flexible_parser
+
+
+def agent_args(agent_name: str):
+ with initialize(version_base=None, config_path="../config/"):
+ config = compose(config_name="config", overrides=[f"agent={agent_name}"])
+ return hydra.utils.instantiate(config.agent)
+
+
+def executable_actions(args) -> set[str]:
+ """Action names the built HighLevelActionSet can actually run."""
+ action_set = args.make_flags().action.action_set.make_action_set()
+ return set(action_set.action_set.keys())
+
+
+def parsed_action(response: str) -> str:
+ """Run a model response through the agent's parsing path."""
+ return flexible_parser(response)["action"]
+
+
+GEMMA_SOM = "gemma-4-31B-computer-use"
+GEMMA_COORDS = "gemma-4-31B-coords"
+
+
+class TestGemmaSomConfig:
+ """The screenshot-only + set-of-marks Gemma config."""
+
+ @pytest.fixture(scope="class")
+ def args(self):
+ return agent_args(GEMMA_SOM)
+
+ def test_observation_is_screenshot_only_with_marks(self, args):
+ assert args.use_screenshot
+ assert args.save_som, "bid actions need the annotated screenshot"
+ assert not args.use_axtree
+ assert not args.use_html
+ # save_som is what actually reaches the prompt builder as use_som.
+ assert args.make_flags().obs.use_som
+
+ def test_action_space_is_bid_based(self, args):
+ assert executable_actions(args) == {
+ "click",
+ "fill",
+ "select_option",
+ "scroll",
+ "noop",
+ }
+
+ def test_no_coordinate_actions_are_reachable(self, args):
+ """Gemma is not prompted to ground in pixels, so don't offer it."""
+ assert not {"mouse_click", "mouse_dblclick"} & executable_actions(args)
+
+ def test_temperature_is_deterministic(self, args):
+ assert args.temperature == 0
+
+ @pytest.mark.parametrize(
+ "response,expected",
+ [
+ ('t click("96") ', 'click("96")'),
+ (
+ 't fill("92", "a\nb") ',
+ 'fill("92", "a\nb")',
+ ),
+ (
+ 't select_option("12", "python") ',
+ 'select_option("12", "python")',
+ ),
+ # scroll( is a prefix the UI-TARS remap also matches; it must fall
+ # through untouched when there is no direction= kwarg.
+ ("t scroll(0, 400) ", "scroll(0, 400)"),
+ (
+ "t noop(wait_ms=5000) ",
+ "noop(wait_ms=5000)",
+ ),
+ ],
+ )
+ def test_parser_passes_bid_actions_through_unmangled(self, response, expected):
+ assert parsed_action(response) == expected
+
+ def test_every_advertised_action_is_executable(self, args):
+ """The prompt must not name an action the action set cannot run."""
+ prompt = args.prompt_txt.action_prompt
+ runnable = executable_actions(args)
+ advertised = {
+ line.split("(")[0].strip()
+ for line in prompt.splitlines()
+ if "(" in line and not line.startswith(" ") and not line.startswith("-")
+ }
+ assert advertised, "could not extract any action names from action_prompt"
+ assert advertised <= runnable, (
+ f"action_prompt advertises {sorted(advertised - runnable)}, which "
+ f"the action set cannot execute (runnable: {sorted(runnable)})"
+ )
+
+ def test_wait_is_not_advertised(self, args):
+ """``wait()`` is neither remapped by the parser nor in action_map."""
+ assert "wait()" not in args.prompt_txt.action_prompt
+
+ def test_prompt_does_not_ask_for_coordinates(self, args):
+ """Mixed guidance (bids + coordinates) is what produced 0% before."""
+ texts = [
+ args.prompt_txt.action_prompt,
+ args.prompt_txt.action_abstract_example,
+ args.prompt_txt.action_concrete_example,
+ args.prompt_txt.think_abstract_example,
+ ]
+ for text in texts:
+ assert "coordinate" not in str(text).lower()
+
+
+class TestGemmaCoordsConfig:
+ """The screenshot-only + pixel-coordinate Gemma config.
+
+ Its whole premise is that the declared coordinate grid, the prompt, and
+ ``coord_scale`` agree. If they drift apart the clicks land off-target and
+ the episode scores 0 without raising, so pin all three together.
+ """
+
+ @pytest.fixture(scope="class")
+ def args(self):
+ return agent_args(GEMMA_COORDS)
+
+ def test_observation_is_screenshot_only_without_marks(self, args):
+ assert args.use_screenshot
+ assert not args.save_som, "this config measures raw pixel grounding"
+ assert not args.use_axtree
+ assert not args.use_html
+
+ def test_action_space_is_coordinate_based(self, args):
+ assert "mouse_click" in executable_actions(args)
+ assert "click" not in executable_actions(args), "bid actions would be unusable without marks"
+
+ def test_coord_scale_matches_the_grid_declared_in_the_prompt(self, args):
+ assert args.coord_scale == 1000
+ assert "1000x1000" in args.prompt_txt.system_prompt
+
+ def test_coord_scale_reaches_the_action_parser(self, args):
+ agent = args.make_agent()
+ assert agent.action_parser.coord_scale == 1000
+
+ def test_model_coordinates_are_converted_to_viewport_pixels(self, args):
+ """End-to-end: a grid coordinate must come out as a viewport pixel."""
+ parser = args.make_agent().action_parser
+ response = "t mouse_click(x=500, y=500) "
+ # 1280x800 is the screenshot env preset this config is paired with.
+ out = parser.parse(response, viewport=(1280, 800))
+ assert out["action"] == "mouse_click(x=640, y=400)"
+
+ def test_every_advertised_action_is_executable(self, args):
+ prompt = args.prompt_txt.action_prompt
+ runnable = executable_actions(args)
+ advertised = {
+ line.split("(")[0].strip()
+ for line in prompt.splitlines()
+ if "(" in line and not line.startswith(" ") and not line.startswith("-")
+ }
+ assert advertised, "could not extract any action names from action_prompt"
+ assert advertised <= runnable, (
+ f"action_prompt advertises {sorted(advertised - runnable)}, which "
+ f"the action set cannot execute (runnable: {sorted(runnable)})"
+ )
+
+ def test_temperature_is_deterministic(self, args):
+ assert args.temperature == 0
+
+
+class TestScreenshotEnvArgs:
+ def test_screenshot_preset_widens_the_step_budget(self):
+ with initialize(version_base=None, config_path="../config/"):
+ config = compose(
+ config_name="config", overrides=["browsergym_env_args=screenshot"]
+ )
+ env = config.browsergym_env_args
+ assert env.max_steps == 25
+ assert list(env.task_kwargs.screen_resolution) == [1280, 800]
diff --git a/tests/test_codeeditor_save.py b/tests/test_codeeditor_save.py
new file mode 100644
index 00000000..8565c1b2
--- /dev/null
+++ b/tests/test_codeeditor_save.py
@@ -0,0 +1,117 @@
+"""
+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.
+"""
+
+"""
+Tests that the Code Editor's Save button can actually reach disk.
+
+Regression coverage for the bug where the emitted editor binding was
+``var editor = (document.getElementById('editor'), {...});`` — the comma
+operator, which bound ``editor`` to the options object instead of an editor.
+``editor.getValue()`` in the Save handler then threw a TypeError, so the POST
+never fired and ``EditFileTask`` could never earn reward.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+from hydra import compose, initialize
+from starlette.testclient import TestClient
+
+from open_apps.apps.codeeditor_app import main as codeeditor_main
+from open_apps.apps.start_page.main import app, initialize_routes_and_configure_task
+from open_apps.tasks.tasks import EditFileTask
+
+
+@pytest.fixture(scope="module")
+def client(tmpdir_factory):
+ # The app and its databases are process-wide singletons, so running setup
+ # again on top of a module that already configured them re-populates the
+ # shared DBs and raises. Reuse whatever is already wired up in that case.
+ if codeeditor_main.current_dir is None:
+ logs_dir = str(tmpdir_factory.getbasetemp())
+ with initialize(version_base=None, config_path="../config/"):
+ config = compose(config_name="config", overrides=[f"logs_dir={logs_dir}"])
+ Path(config.logs_dir).mkdir(parents=True, exist_ok=True)
+ Path(config.databases_dir).mkdir(parents=True, exist_ok=True)
+ initialize_routes_and_configure_task(config.apps)
+ return TestClient(app)
+
+
+def editor_script(html: str) -> str:
+ """Return the inline ``", html, flags=re.DOTALL)
+ binding = [b for b in blocks if "var editor" in b]
+ assert binding, "no editor binding found on the page"
+ return binding[0]
+
+
+class TestEditorBinding:
+ """The page-global ``editor`` must expose the API the page calls."""
+
+ def test_binding_is_not_the_options_object(self, client):
+ """The comma-operator form must never be emitted again."""
+ html = client.get("/codeeditor/script.py").text
+ assert "var editor = (document.getElementById" not in html
+
+ def test_binding_exposes_getvalue_backed_by_the_textarea(self, client):
+ """Save reads ``editor.getValue()``; it must read the textarea."""
+ script = editor_script(client.get("/codeeditor/script.py").text)
+ assert "getValue" in script
+ assert "editorTextarea.value" in script
+ assert "document.getElementById('editor')" in script
+
+ def test_save_handler_calls_getvalue(self, client):
+ html = client.get("/codeeditor/script.py").text
+ assert "const content = editor.getValue();" in html
+ assert "fetch('/codeeditor/save/script.py'" in html
+
+ def test_highlight_mode_uses_codemirror(self, monkeypatch):
+ """With highlighting on the binding is a real CodeMirror instance."""
+ monkeypatch.setattr(
+ codeeditor_main.app.config.code_editor, "highlight", True, raising=False
+ )
+ binding = codeeditor_main.editor_binding("{mode: 'python'}")
+ assert binding.startswith(
+ "var editor = CodeMirror.fromTextArea(document.getElementById('editor')"
+ )
+
+ def test_folder_and_index_views_also_bind_an_editor(self, client):
+ """The read-only views drive ``editor.setOption`` from the selectors."""
+ for url in ("/codeeditor/", "/codeeditor/developing"):
+ script = editor_script(client.get(url).text)
+ assert "setOption" in script
+
+
+class TestSaveRoundTrip:
+ """A save must land on disk and be visible to the reward endpoint."""
+
+ def test_saved_content_reaches_codeeditor_all(self, client):
+ original = client.get("/codeeditor/script.py")
+ assert original.status_code == 200
+
+ initial_state = {"codeeditor": client.get("/codeeditor_all").json()}
+ initial_content = EditFileTask(
+ goal="", file_path="script.py", required_fragment="x"
+ )._find_file_content(initial_state["codeeditor"], "script.py")
+ assert initial_content is not None
+ assert "# Reviewed by Bob" not in initial_content
+
+ response = client.post(
+ "/codeeditor/save/script.py",
+ json={"content": initial_content + "# Reviewed by Bob\n"},
+ )
+ assert response.json() == {"success": True}
+
+ current_state = {"codeeditor": client.get("/codeeditor_all").json()}
+ task = EditFileTask(
+ goal="Open 'script.py' in the Code Editor, add the line "
+ "'# Reviewed by Bob' as a comment, and save the file.",
+ file_path="script.py",
+ required_fragment="# Reviewed by Bob",
+ )
+ assert task.check_if_task_is_complete(initial_state, current_state)
diff --git a/tests/test_codeeditor_tasks.py b/tests/test_codeeditor_tasks.py
new file mode 100644
index 00000000..31d1b67a
--- /dev/null
+++ b/tests/test_codeeditor_tasks.py
@@ -0,0 +1,213 @@
+"""
+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 the Code Editor EditFileTask reward logic.
+"""
+
+from open_apps.tasks.tasks import EditFileTask
+from open_apps.tasks import load_task
+
+
+def make_state(files: dict[str, str]) -> dict:
+ """Build a cross-app state whose ``codeeditor`` value mirrors the file
+ tree returned by the ``/codeeditor_all`` endpoint.
+
+ Args:
+ files: Mapping of file ``path`` (relative to the editor root) to
+ its text content.
+ """
+ children = []
+ for path, content in files.items():
+ children.append(
+ {
+ "type": "file",
+ "name": path.split("/")[-1],
+ "path": path,
+ "content": content,
+ }
+ )
+ return {
+ "codeeditor": {"type": "folder", "name": "codeeditor", "children": children}
+ }
+
+
+SCRIPT_INITIAL = (
+ "# Basic PyTorch tensor operation\n"
+ "import torch\n"
+ "\n"
+ "x = torch.randn(2, 3, 4)\n"
+)
+
+
+class TestEditFileTask:
+ def test_correct_file_and_contents(self):
+ """Editing the right file with the required fragment earns reward."""
+ initial_state = make_state({"script.py": SCRIPT_INITIAL})
+ current_state = make_state(
+ {"script.py": SCRIPT_INITIAL + "# Reviewed by Bob\n"}
+ )
+ task = EditFileTask(
+ goal="Add a review comment to script.py",
+ file_path="script.py",
+ required_fragment="# Reviewed by Bob",
+ )
+ assert task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_correct_contents_in_wrong_file(self):
+ """The required content in a different file does not earn reward."""
+ initial_state = make_state(
+ {"script.py": SCRIPT_INITIAL, "other.py": "x = 1\n"}
+ )
+ # The fragment ended up in other.py, not script.py.
+ current_state = make_state(
+ {
+ "script.py": SCRIPT_INITIAL,
+ "other.py": "x = 1\n# Reviewed by Bob\n",
+ }
+ )
+ task = EditFileTask(
+ goal="Add a review comment to script.py",
+ file_path="script.py",
+ required_fragment="# Reviewed by Bob",
+ )
+ assert not task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_unchanged_contents(self):
+ """A file whose contents did not change earns no reward, even if the
+ required fragment already happens to be present."""
+ already_has_fragment = SCRIPT_INITIAL + "# Reviewed by Bob\n"
+ initial_state = make_state({"script.py": already_has_fragment})
+ current_state = make_state({"script.py": already_has_fragment})
+ task = EditFileTask(
+ goal="Add a review comment to script.py",
+ file_path="script.py",
+ required_fragment="# Reviewed by Bob",
+ )
+ assert not task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_missing_fragment_fails(self):
+ """Saving the file without the required fragment earns no reward."""
+ initial_state = make_state({"script.py": SCRIPT_INITIAL})
+ current_state = make_state({"script.py": SCRIPT_INITIAL + "print(x)\n"})
+ task = EditFileTask(
+ goal="Add a review comment to script.py",
+ file_path="script.py",
+ required_fragment="# Reviewed by Bob",
+ )
+ assert not task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_expected_content_full_match(self):
+ """expected_content requires a whitespace-normalized full-file match."""
+ initial_state = make_state({"notes.txt": "old\n"})
+ current_state = make_state({"notes.txt": "TODO: refactor\n"})
+ task = EditFileTask(
+ goal="Set the contents of notes.txt",
+ file_path="notes.txt",
+ expected_content="TODO: refactor",
+ )
+ assert task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_expected_content_partial_does_not_match(self):
+ """A partial match must fail when expected_content is a full match."""
+ initial_state = make_state({"notes.txt": "old\n"})
+ current_state = make_state(
+ {"notes.txt": "TODO: refactor and more stuff\n"}
+ )
+ task = EditFileTask(
+ goal="Set the contents of notes.txt",
+ file_path="notes.txt",
+ expected_content="TODO: refactor",
+ )
+ assert not task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_create_new_file(self):
+ """Creating a new file that did not exist initially earns reward."""
+ initial_state = make_state({"script.py": SCRIPT_INITIAL})
+ current_state = make_state(
+ {"script.py": SCRIPT_INITIAL, "notes.txt": "TODO: refactor\n"}
+ )
+ task = EditFileTask(
+ goal="Create notes.txt",
+ file_path="notes.txt",
+ expected_content="TODO: refactor",
+ )
+ assert task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_target_file_absent(self):
+ """If the target file does not exist, the task is incomplete."""
+ initial_state = make_state({"script.py": SCRIPT_INITIAL})
+ current_state = make_state({"script.py": SCRIPT_INITIAL})
+ task = EditFileTask(
+ goal="Create notes.txt",
+ file_path="notes.txt",
+ expected_content="TODO: refactor",
+ )
+ assert not task.check_if_task_is_complete(initial_state, current_state)
+
+ def test_nested_file_path(self):
+ """Files nested inside folders are located by their relative path."""
+ nested = {
+ "codeeditor": {
+ "type": "folder",
+ "name": "codeeditor",
+ "children": [
+ {
+ "type": "folder",
+ "name": "developing",
+ "children": [
+ {
+ "type": "file",
+ "name": "simple_python.py",
+ "path": "developing/simple_python.py",
+ "content": '# beginner\nprint("Hello, World!")\n# done\n',
+ }
+ ],
+ }
+ ],
+ }
+ }
+ initial = {
+ "codeeditor": {
+ "type": "folder",
+ "name": "codeeditor",
+ "children": [
+ {
+ "type": "folder",
+ "name": "developing",
+ "children": [
+ {
+ "type": "file",
+ "name": "simple_python.py",
+ "path": "developing/simple_python.py",
+ "content": '# beginner\nprint("Hello, World!")\n',
+ }
+ ],
+ }
+ ],
+ }
+ }
+ task = EditFileTask(
+ goal="Add a comment to the nested file",
+ file_path="developing/simple_python.py",
+ required_fragment="# done",
+ )
+ assert task.check_if_task_is_complete(initial, nested)
+
+
+class TestEditFileTaskInstantiation:
+ def test_edit_task_instantiation(self):
+ task = load_task("edit_script_add_header_comment")
+ assert isinstance(task, EditFileTask)
+ assert task.file_path == "script.py"
+ assert task.required_fragment == "# Reviewed by Bob"
+
+ def test_create_task_instantiation(self):
+ task = load_task("create_notes_file_in_code_editor")
+ assert isinstance(task, EditFileTask)
+ assert task.file_path == "notes.txt"
+ assert task.expected_content == "TODO: refactor"
diff --git a/tests/test_no_egress.py b/tests/test_no_egress.py
new file mode 100644
index 00000000..ee3b002b
--- /dev/null
+++ b/tests/test_no_egress.py
@@ -0,0 +1,151 @@
+"""
+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.
+
+Guards against pages that only work when the host has internet.
+
+The eval nodes have no outbound network. Anything a page fetches from a CDN
+simply does not arrive there, and nothing reports it -- the page still returns
+200, the DOM still renders, and the run still produces a trajectory. The two
+failures that matter:
+
+* **htmx missing** makes every ``hx-*`` attribute inert. A checkbox still
+ toggles when clicked, because that is the browser's own behaviour, so a
+ screenshot shows the interaction landing while no request is sent and no
+ server state changes. Every task depending on that state scores zero, and
+ the trajectory looks like a model that clicked the right thing.
+* **Pico missing** renders every page unstyled, which changes the observation a
+ screenshot-scored agent is graded on.
+
+``test_interactive_routes_load_htmx_locally`` is the one that catches the first
+case. The rest keep the cleanup from regressing.
+"""
+
+from pathlib import Path
+
+import pytest
+import re
+from hydra import compose, initialize
+from starlette.testclient import TestClient
+
+from open_apps.apps.start_page.main import (
+ app,
+ initialize_routes_and_configure_task,
+)
+from open_apps.frontend import HTMX_URL, PICO_URL
+
+# Every route a browser (or an agent) actually lands on.
+ROUTES = ["/", "/todo", "/calendar", "/messages", "/codeeditor/", "/maps"]
+
+# External origins each route is still allowed to reference, by hostname.
+#
+# This is a ratchet, not a target: a new entry means new egress and should be
+# argued for, while entries disappear as apps move onto local assets. Empty
+# lists are the goal state and two routes are already there.
+#
+# Everything below is a styling or widget dependency. htmx and Pico are
+# deliberately absent -- they are covered by their own stricter test, because
+# they are the two whose absence changes behaviour rather than appearance.
+ALLOWED_EXTERNAL_HOSTS = {
+ "/": set(),
+ "/todo": set(),
+ "/calendar": {"cdn.jsdelivr.net", "unpkg.com"}, # highlight.js, phosphor-icons
+ "/messages": {"cdn.jsdelivr.net", "cdn.tailwindcss.com", "cdnjs.cloudflare.com"},
+ "/codeeditor/": {"cdn.jsdelivr.net", "cdn.tailwindcss.com"},
+ "/maps": {"cdnjs.cloudflare.com", "unpkg.com"}, # leaflet + awesome-markers
+}
+
+_URL_RE = re.compile(r'(?:src|href)="(https?://[^"]+)"')
+_HX_RE = re.compile(r"\bhx-(?:get|post|put|delete|patch)=")
+
+
+def external_urls(html: str) -> list[str]:
+ """Absolute URLs the page tells the browser to go fetch.
+
+ TestClient serves from ``http://testserver``, so its own origin is not
+ external and is filtered out.
+ """
+ return [u for u in _URL_RE.findall(html) if "testserver" not in u]
+
+
+@pytest.fixture(scope="module")
+def client(tmp_path_factory):
+ # A dedicated temp dir rather than the shared ``getbasetemp()``: the apps
+ # seed their tables with fixed primary keys at startup, so re-initializing
+ # over another module's database collides on insert.
+ logs_dir = tmp_path_factory.mktemp("no_egress")
+ with initialize(version_base=None, config_path="../config/"):
+ config = compose(config_name="config", overrides=[f"logs_dir={logs_dir}"])
+ Path(config.logs_dir).mkdir(parents=True, exist_ok=True)
+ Path(config.databases_dir).mkdir(parents=True, exist_ok=True)
+ initialize_routes_and_configure_task(config.apps)
+ return TestClient(app)
+
+
+@pytest.mark.parametrize("route", ROUTES)
+def test_htmx_and_pico_never_come_from_a_cdn(client, route):
+ """No page may fetch htmx or Pico from an external origin.
+
+ FastHTML's default headers do exactly this, so any app constructed without
+ ``default_hdrs=False`` reintroduces it. That is the regression this catches:
+ it passes on a laptop either way, and only diverges on an offline host.
+ """
+ offenders = [
+ u for u in external_urls(client.get(route).text)
+ if "htmx" in u.lower() or "pico" in u.lower()
+ ]
+ assert not offenders, (
+ f"{route} fetches htmx/Pico from a CDN: {offenders}. "
+ "Construct the app with default_hdrs=False and hdrs=local_hdrs() "
+ "(see src/open_apps/frontend.py)."
+ )
+
+
+@pytest.mark.parametrize("route", ROUTES)
+def test_interactive_routes_load_htmx_locally(client, route):
+ """A page with ``hx-*`` attributes must load htmx from local assets.
+
+ This is the pairing that actually broke: ``/todo`` carries 15 ``hx-put``
+ attributes and htmx came from jsdelivr, so offline every one of them was
+ dead while the page looked and behaved almost normally.
+
+ Routes with no ``hx-*`` attributes are skipped rather than required to load
+ htmx -- there is no reason to ship it to a page that does not use it.
+ """
+ html = client.get(route).text
+ hx_count = len(_HX_RE.findall(html))
+ if hx_count == 0:
+ pytest.skip(f"{route} has no hx-* attributes")
+ assert HTMX_URL in html, (
+ f"{route} has {hx_count} hx-* attributes but does not load {HTMX_URL}. "
+ "Offline, every one of those interactions silently does nothing."
+ )
+
+
+@pytest.mark.parametrize("route", ROUTES)
+def test_external_origins_match_allowlist(client, route):
+ """Fail on *new* egress; allow the known, still-to-be-cleaned dependencies."""
+ hosts = {u.split("/")[2] for u in external_urls(client.get(route).text)}
+ unexpected = hosts - ALLOWED_EXTERNAL_HOSTS[route]
+ assert not unexpected, (
+ f"{route} gained new external origins: {sorted(unexpected)}. "
+ "Vendor the asset under apps/assets/vendor, or add the host to "
+ "ALLOWED_EXTERNAL_HOSTS with a reason."
+ )
+
+
+@pytest.mark.parametrize("asset_url", [HTMX_URL, PICO_URL])
+def test_vendored_assets_are_served(client, asset_url):
+ """The vendored files exist and the static route actually serves them.
+
+ A broken path here fails exactly like the CDN did -- 404, no error, dead
+ interactions -- so it is worth asserting rather than assuming.
+ """
+ response = client.get(asset_url)
+ assert response.status_code == 200, f"{asset_url} -> {response.status_code}"
+ assert len(response.content) > 10_000, (
+ f"{asset_url} served only {len(response.content)} bytes; "
+ "the file is probably a truncated or failed download."
+ )
diff --git a/tests/test_todo_toggle.py b/tests/test_todo_toggle.py
new file mode 100644
index 00000000..9d763467
--- /dev/null
+++ b/tests/test_todo_toggle.py
@@ -0,0 +1,161 @@
+"""
+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.
+"""
+
+"""
+Regression tests for the todo checkbox -> reward chain.
+
+The endpoints (``/todo/toggle/{id}``, ``/todo_all``) and the reward comparison
+were each covered in isolation, but nothing exercised the link between them:
+whether *clicking the checkbox in a browser* actually reaches the database the
+reward reads. An agent can only score on this task by clicking that box, so a
+break anywhere along the chain (htmx not firing, the swap losing the handler,
+the id drifting) silently zeroes every ``MarkToDoDoneTask`` run while the page
+still looks correct in a screenshot.
+"""
+
+from pathlib import Path
+
+import pytest
+from hydra import compose, initialize
+from starlette.testclient import TestClient
+
+from open_apps.apps.start_page.main import (
+ app,
+ initialize_routes_and_configure_task,
+)
+from open_apps.state import get_current_state
+from open_apps.tasks.tasks import MarkToDoDoneTask
+
+TODO_TITLE = "Water plants"
+
+
+def find_todo(todos: list[dict], title: str = TODO_TITLE) -> dict:
+ matches = [t for t in todos if t["title"] == title]
+ assert matches, f"{title!r} missing from todo state: {todos}"
+ return matches[0]
+
+
+def open_any_browser(playwright, playwright_api, viewport):
+ """Open a page in chromium, or any other engine if chromium won't start.
+
+ Chromium is what BrowserGym drives, so it goes first. But it refuses to
+ start on some sandboxed/CI hosts (``mach_port_rendezvous`` on macOS, a
+ missing shared library on slim images), and skipping there would quietly
+ drop the only coverage of the click path. What's under test is the app's
+ htmx wiring, not an engine quirk, so a fallback still catches the
+ regression.
+
+ A broken install can pass ``launch()`` and still fail ``new_page()``, so
+ both run here and a half-working engine falls through to the next.
+ Skips only if none of them yields a usable page.
+ """
+ errors = []
+ for name in ("chromium", "firefox", "webkit"):
+ browser = None
+ try:
+ browser = getattr(playwright, name).launch()
+ return browser, browser.new_page(viewport=viewport)
+ except playwright_api.Error as exc:
+ errors.append(f"{name}: {str(exc).splitlines()[0]}")
+ if browser is not None:
+ try:
+ browser.close()
+ except playwright_api.Error:
+ pass
+ pytest.skip("no usable playwright browser — " + "; ".join(errors))
+
+
+@pytest.fixture(scope="module")
+def client(tmp_path_factory):
+ # A dedicated temp dir, not the shared ``getbasetemp()``: the todo app
+ # seeds its table with fixed primary keys at startup, so re-initializing
+ # over another module's database would collide on insert.
+ logs_dir = tmp_path_factory.mktemp("todo_toggle")
+ with initialize(version_base=None, config_path="../config/"):
+ config = compose(config_name="config", overrides=[f"logs_dir={logs_dir}"])
+ Path(config.logs_dir).mkdir(parents=True, exist_ok=True)
+ Path(config.databases_dir).mkdir(parents=True, exist_ok=True)
+ initialize_routes_and_configure_task(config.apps)
+ return TestClient(app)
+
+
+def test_toggle_endpoint_reaches_reward_endpoint(client):
+ """``/todo/toggle`` must be visible to ``/todo_all``, which reward reads."""
+ todo = find_todo(client.get("/todo_all").json())
+ assert not todo["done"], "fixture expects 'Water plants' to start undone"
+
+ assert client.put(f"/todo/toggle/{todo['id']}").status_code == 200
+ assert find_todo(client.get("/todo_all").json())["done"]
+
+ # Toggling back must also propagate — an agent that double-clicks the box
+ # has *not* completed the task, and the reward has to reflect that.
+ assert client.put(f"/todo/toggle/{todo['id']}").status_code == 200
+ assert not find_todo(client.get("/todo_all").json())["done"]
+
+
+@pytest.mark.slow
+def test_browser_checkbox_click_marks_task_complete(tmp_path_factory):
+ """Click the real checkbox in a real browser and score the real task.
+
+ This is the path an agent takes. Everything below the click — the htmx
+ ``hx_put``, the outerHTML swap, the sqlite write, ``/todo_all``, and the
+ DeepDiff in ``MarkToDoDoneTask`` — has to line up for the run to score.
+ """
+ playwright_api = pytest.importorskip("playwright.sync_api")
+
+ from tests.save_screenshots import launch_variation, stop_server
+
+ runtime_dir = tmp_path_factory.mktemp("todo_toggle_browser")
+ launcher, process = launch_variation("todo_toggle", runtime_dir, overrides=[])
+ try:
+ base_url = launcher.web_app_url
+ initial_state = get_current_state(base_url)
+ todo = find_todo(initial_state["todo"])
+ assert not todo["done"], "fixture expects 'Water plants' to start undone"
+
+ task = MarkToDoDoneTask(
+ goal=f"Mark '{TODO_TITLE}' as done in my todo list.",
+ todo_name=TODO_TITLE,
+ )
+ assert not task.check_if_task_is_complete(
+ initial_state, get_current_state(base_url)
+ ), "task scored as complete before anything was clicked"
+
+ checkbox = f"#todo-{todo['id']} input[type=checkbox]"
+ with playwright_api.sync_playwright() as p:
+ browser, page = open_any_browser(
+ p, playwright_api, viewport={"width": 1024, "height": 640}
+ )
+ try:
+ page.goto(f"{base_url}/todo")
+ page.wait_for_selector(checkbox)
+ assert not page.is_checked(checkbox)
+
+ # Wait on the htmx round-trip rather than a fixed sleep: the
+ # swap is async, and a bare timeout would make this flaky in
+ # exactly the way the bug it guards against looks.
+ with page.expect_response(
+ lambda r: r.request.method == "PUT"
+ and r.url.endswith(f"/todo/toggle/{todo['id']}")
+ ) as put:
+ page.click(checkbox)
+ assert put.value.status == 200
+ page.wait_for_function(
+ "sel => document.querySelector(sel)?.checked === true",
+ arg=checkbox,
+ )
+ finally:
+ browser.close()
+
+ final_state = get_current_state(base_url)
+ assert find_todo(final_state["todo"])["done"], (
+ "checkbox click did not reach /todo_all — the page can still render "
+ "as checked while every reward call returns 0"
+ )
+ assert task.check_if_task_is_complete(initial_state, final_state)
+ finally:
+ stop_server(launcher, process)