fix: zoom-aware getBoundingClientRect positioning for all dropdowns, menus, pickers, and overlays
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions

app.js: overflow plus-menu positionMenu() — divide r.left/r.top by zoom
cookbookRunning.js: hamburger dropdown — use window.innerWidth - (rect.right/z)
cookbookServe.js: 5 fixes — toolbar dropdown, launch-more, split-arrow, GPU popup, saved-menu
skills.js: kebab tool menu — divide rect bounds by zoom
colorPicker.js: picker position() — divide rect coords by zoom
emojiPicker.js: picker open — divide rect coords by zoom
theme.js: zone highlights — divide all rect values by zoom
This commit is contained in:
Lukas Parsons 2026-07-07 12:21:41 -04:00
parent 5f13f7b3c9
commit c93c14ece9
13 changed files with 331 additions and 83 deletions

221
.env.save Executable file
View file

@ -0,0 +1,221 @@
# Odysseus UI — Environment Configuration
# Copy this file to .env and fill in your values.
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
# ============================================================
# LLM Configuration
# ============================================================
# Primary LLM host (default: localhost)
LLM_HOST=localhost
# Additional LLM hosts, comma-separated (for model discovery)
# Use hostnames/IPs only; Odysseus scans common serve ports, including Ollama's 11434.
# LLM_HOSTS=llm-host.local,backup-llm.local
# Optional Ollama base URL. In Docker, host Ollama is usually reachable here
# when started with OLLAMA_HOST=0.0.0.0:11434.
# OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
# Optional LM Studio URL. In Docker, host LM Studio is reachable here
# when LM Studio is set to serve on all interfaces (0.0.0.0).
# LM_STUDIO_URL=http://host.docker.internal:1234
# OpenAI API key (only needed if using OpenAI models).
# Do not commit real keys. Keep this commented until needed.
# OPENAI_API_KEY=your_openai_api_key_here
# Research service LLM endpoint
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
# Extra CA bundle for LLM providers whose TLS chain isn't in the default
# trust store. Layered ON TOP of the system / certifi bundle — verification
# stays on for every host, the trust set just gets larger. Useful for:
# - GigaChat / Sber (Russian Trusted Root CA): without this the endpoint
# shows offline with CERTIFICATE_VERIFY_FAILED — self-signed certificate
# in certificate chain.
# - On-premise / corporate LLM gateways with an internal CA.
# Point at a PEM file containing the missing root(s).
# LLM_CA_BUNDLE=/etc/odysseus/ca/extra-roots.pem
# ============================================================
# Search & Web
# ============================================================
# SearXNG instance URL (self-hosted, for web search).
# Docker Compose overrides this to http://searxng:8080 for in-network access.
SEARXNG_INSTANCE=http://localhost:8080
# Optional SearXNG cookie/CSRF secret. If blank, Docker generates one on first boot
# and stores it in the searxng-data volume.
# SEARXNG_SECRET=
# ============================================================
# Database
# ============================================================
# SQLite database path (default: sqlite:///./data/app.db)
# DATABASE_URL=sqlite:///./data/app.db
# ============================================================
# Data directory
# ============================================================
# Move everything that lives under data/ - settings, sessions, database, auth,
# cache, uploads, etc. - to another path:
# ODYSSEUS_DATA_DIR=C:\path\to\dir
# ============================================================
# Auth & Security
# ============================================================
# Enable authentication (default: true)
# AUTH_ENABLED=true
# Host bind address and port for the Odysseus web UI in Docker Compose.
# Keep APP_BIND on loopback unless you intentionally want LAN/reverse-proxy access.
APP_BIND=0.0.0.0
# Change this if another local service already uses 7000 (macOS AirPlay often does).
# APP_PORT=7000
# Development-only auth bypass for loopback requests.
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
# LOCALHOST_BYPASS=false
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
# by a trusted reverse proxy or private access gateway.
# SECURE_COOKIES=true
# Optional: pre-seed the first admin password during setup.
# Do not commit a real password.
# ODYSSEUS_ADMIN_PASSWORD=change_me_before_first_boot
# CORS allowed origins (default: localhost-only; restrict to your public origin in production)
# ALLOWED_ORIGINS=http://localhost:7000,http://localhost:8000
# ============================================================
# ChromaDB (vector store)
# ============================================================
# ChromaDB service host.
# Manual host run: localhost:8100 when using `docker run -p 8100:8000 chromadb/chroma`.
# Docker Compose overrides these to chromadb:8000 for in-network access.
# CHROMADB_HOST=localhost
# CHROMADB_PORT=8100
# Docker Compose host-port bind addresses for bundled services.
# Defaults are loopback-only for safety. To expose ntfy only on Tailscale,
# set NTFY_BIND to your host's Tailscale IP and update NTFY_BASE_URL.
# CHROMADB_BIND=127.0.0.1
# NTFY_BIND=127.0.0.1
# NTFY_BASE_URL=http://localhost:8091
# Example:
# NTFY_BIND=100.x.y.z
# NTFY_BASE_URL=http://100.x.y.z:8091
# ============================================================
# RAG / Embeddings
# ============================================================
# Embedding API endpoint (OpenAI-compatible /v1/embeddings)
# Default: http://{LLM_HOST}:11434/v1/embeddings (ollama)
# EMBEDDING_URL=http://localhost:11434/v1/embeddings
# Embedding API key (if there's one)
# EMBEDDING_API_KEY=embedding_api_key_here
# Embedding model name (must be available at the endpoint above)
# EMBEDDING_MODEL=all-minilm:l6-v2
# Local fallback embedding model (used when no HTTP embedding API is available)
# Uses fastembed (ONNX) — downloads model on first run (~50MB)
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
# ============================================================
# Misc
# ============================================================
# Cleanup interval in hours (default: 24)
# CLEANUP_INTERVAL_HOURS=24
# In-process email pollers (default: on). Set to 0 if you're driving
# polling from cron / systemd via `scripts/odysseus-mail poll-scheduled`
# and `scripts/odysseus-mail poll-summary`, otherwise both schedulers
# race on the same SQLite.
# ODYSSEUS_INPROCESS_POLLERS=1
# In-process scheduled-task runner (default: on). Set to 0 to let an
# external driver fire scheduled tasks. Calendar reminders are
# frontend-driven (polling /api/notes from the browser) so no gate is
# needed there.
# ODYSSEUS_INPROCESS_TASKS=1
# Host used by the built-in "run_script" scheduled-task action.
# Empty/local/localhost runs scripts on the app host. Set to an SSH host alias
# if you intentionally want scheduled scripts to run remotely.
# ODYSSEUS_SCRIPT_HOST=localhost
# Chat / agent attachment size cap in bytes (default: 10 MB).
# Raise this for local installs that need larger PDFs or text documents.
# Example: 52428800 = 50 MB.
# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760
# Other per-feature upload size caps in bytes. All are validated and optional;
# defaults shown. An invalid value (non-integer or < 1) fails fast at startup.
# ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=104857600 # gallery image upload (100 MB)
# ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=26214400 # gallery transform input (25 MB)
# ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=10485760 # memory import file (10 MB)
# ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=26214400 # personal document upload (25 MB)
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ============================================================
# Host Docker access (explicit opt-in)
# ============================================================
# Default Docker Compose does not mount /var/run/docker.sock. Existing
# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it.
#
# Enable this only for intentional Cookbook/local Docker-daemon management.
# Raw socket access is high-trust and can grant broad control over the host
# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID.
# Put these values in .env, or export them before running docker compose.
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
# DOCKER_GID=963
# docker/host-docker.yml sets this inside the container. Keep it paired
# with the socket overlay; setting it alone is not sufficient.
# ODYSSEUS_ENABLE_HOST_DOCKER=true
#
# Host Docker access can be combined with one GPU overlay:
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
# ============================================================
# GPU support (Docker Compose)
# ============================================================
# Pass the host GPU into the odysseus container. Default (unset) = CPU.
# COMPOSE_FILE is a native `docker compose` feature: a colon-separated
# list of files merged left-to-right. Pick ONE GPU line below, or leave
# all commented for CPU.
#
# NVIDIA (requires nvidia-container-toolkit + `nvidia-ctk runtime
# configure --runtime=docker` on the host):
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
# COMPOSE_FILE=docker-compose.yml;docker/gpu.nvidia.yml #(Windows)
#
# AMD ROCm (requires ROCm drivers on the host and the GID of the render group):
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
# Find the render GID with: getent group render | cut -d: -f3
# RENDER_GID=989
#
# These overlays only expose the GPU devices. The slim Odysseus image
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
# llama-cpp-python, etc.) before models can actually serve on GPU.
# ============================================================
# Storage Paths (Docker Compose)
# ============================================================
# APP_DATA_DIR=./data
# APP_LOGS_DIR=./logs

BIN
cuda-keyring_1.1-1_all.deb Normal file

Binary file not shown.

View file

@ -85,8 +85,8 @@ services:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
depends_on:
searxng:
condition: service_healthy
# searxng:
# condition: service_healthy
chromadb:
condition: service_started
restart: unless-stopped
@ -109,55 +109,55 @@ services:
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
entrypoint:
- /bin/sh
- -c
- |
set -eu
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
secret="$${SEARXNG_SECRET:-}"
if [ -z "$$secret" ]; then
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
timeout: 6s
retries: 20
start_period: 10s
restart: unless-stopped
# searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
# image: docker.io/searxng/searxng:2026.5.31-7159b8aed
# entrypoint:
# - /bin/sh
# - -c
# - |
# set -eu
# if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
# secret=\"$${SEARXNG_SECRET:-}\"
# if [ -z \"$$secret\" ]; then
# secret=\"$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\"
# fi
# sed \"s|__SEARXNG_SECRET__|$$secret|g\" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
# fi
# exec /usr/local/searxng/entrypoint.sh
# ports:
# - \"127.0.0.1:8080:8080\"
# volumes:
# - searxng-data:/etc/searxng
# - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
# environment:
# - SEARXNG_BASE_URL=http://localhost:8080/
# - SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
# cap_drop:
# - ALL
# cap_add:
# - CHOWN
# - SETGID
# - SETUID
# - DAC_OVERRIDE
# healthcheck:
# test: [\"CMD-SHELL\", \"python -c \\\"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\\\"\"]\"
# interval: 5s
# timeout: 6s
# retries: 20
# start_period: 10s
# restart: unless-stopped
ntfy:
image: docker.io/binwiederhier/ntfy
@ -171,6 +171,6 @@ services:
restart: unless-stopped
volumes:
searxng-data:
# searxng-data:
chromadb-data:
ntfy-cache:

View file

@ -21,6 +21,7 @@ services:
# Ollama at http://host.docker.internal:11434.
- "host.docker.internal:host-gateway"
environment:
- LD_LIBRARY_PATH=/app/.local/bin
- LLM_HOST=${LLM_HOST:-localhost}
- LLM_HOSTS=${LLM_HOSTS:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
@ -76,6 +77,9 @@ services:
chromadb:
condition: service_started
restart: unless-stopped
networks:
- default
- searxng-net
chromadb:
image: docker.io/chromadb/chroma:latest
@ -152,3 +156,8 @@ volumes:
# searxng-data:
chromadb-data:
ntfy-cache:
networks:
searxng-net:
external: true
name: searxng_default

View file

@ -4,14 +4,17 @@ Last updated: 2026-07-07
## Priority: HIGH
- [ ] **AUTO_START_LOCAL: persist & auto-restore last running model on restart**
New `AUTO_START_LOCAL` env var (bool, default false). On graceful shutdown/restart, persist the currently running model + all launch parameters (engine, quantization, context length, GPU layers, etc.) to a file in `/app/data/`. On startup, if the file exists and AUTO_START_LOCAL is true, automate the full launch pipeline: resolve the saved preset, spin up the engine, and register the endpoint — skipping the manual cookbook → Launch → pick model → verify params → launch → wait → refresh cycle the user currently endures on every restart.
- [ ] **Floating windows overflow viewport after zoom/font changes — unreachable controls**
`:root.ui-scale-125 { zoom: 1.25 }` renders all content 25% larger, but `window.innerHeight` doesn't change with `zoom` (CSS rendering only, not DOM). The `windowResize.js` saved-size restore at line 224 clamps to `window.innerHeight`, which after zoom makes windows 1.25× the visible viewport — pushing headers, close buttons, and top resize handles off-screen. The user literally can't close or move them. Fix: (a) divide restored height by zoom factor in the restore path; (b) add a general safety net — any `.modal-content` on open should clamp to `calc(100dvh / var(--ui-zoom, 1) - 40px)`. Also `windowResize.js` line 134 `if (top + height > vh)` needs zoom-aware `vh`. Current zoom compensation rules (line 181-196) only cover `.modal-content`, `.cal-modal-content`, `.settings-modal-content`, `#theme-popup`, `#cookbook-modal`, and `.pdf-export-overlay` — many windows lack the divisor.
- [ ] **Fix manage_documents tool schema — missing "read" action**
The `manage_documents` function-calling schema in `tool_schemas.py` has `"enum": ["list", "delete", "tidy"]` but the handler in `document_tools.py` supports `"read", "view", "open", "get"`. The model can't read documents through function calling. Fix: add read/view/open/get to enum, add `document_id`/`offset`/`limit` params, update description to mention reading.
- [x] **Fix manage_documents tool schema — missing "read" action**
The `manage_documents` function-calling schema in `tool_schemas.py` had `"enum": ["list", "delete", "tidy"]` but the handler in `document_tools.py` supports `"read", "view", "open", "get"`. The model couldn't read documents through function calling. Fixed: added read/view/open/get to enum, added `document_id`/`offset`/`limit` params with read-specific descriptions, updated description with "DOCUMENT PANEL (not disk)" prefix. Also added disambiguation prefix to `create_document` and `edit_document` schemas.
- [ ] **Add "Send to Chat" button to document editor**
Button left of the Save button in `#doc-actions-footer`. Sends a document reference (title + doc_id + snippet) to the active chat, similar to how Cursor injects file references. Must NOT paste full content — just a reference the agent can resolve. Backend needs an endpoint or the existing `open_panel: documents` UI action may be enough. Requires: `POST /api/document/<id>/reference` or use existing `ui_control` with a new action. Frontend: insert button in `document.js` `_buildPaneHTML()` at line ~4598, wire JS handler.
- [x] **Add "Send to Chat" button to document editor**
Button left of the Save button in `#doc-actions-footer`. Sends a document reference (title + doc_id + snippet) to the active chat, similar to how Cursor injects file references. Must NOT paste full content — just a reference the agent can resolve. Done: "Chat" button injects `[Title](#document-<id>) — <120-char snippet>` into the #message textarea.
## Priority: MEDIUM
@ -27,11 +30,14 @@ Last updated: 2026-07-07
- Document references in chat that are clickable and auto-expand
- The "Send to Chat" button (HIGH priority) is step 1; step 2 is making the agent proactively aware of library contents
- [ ] **Improve `create_document` schema description**
Current description is verbose and overlaps with `edit_file`/`write_file` disambiguation. Models sometimes use `create_document` for disk files. Add a clear leading signal: "DOCUMENT PANEL (not disk)" prefix like the existing "FILESYSTEM TOOL (not memory)" pattern.
- [x] **Improve `create_document` schema description**
Current description is verbose and overlaps with `edit_file`/`write_file` disambiguation. Models sometimes use `create_document` for disk files. Fixed: added "DOCUMENT PANEL (not disk)" prefix, explicit "For files on disk use write_file" at end. Also applied to `edit_document`.
## Priority: LOW
- [ ] **Cookbook sliders: active highlight misaligned with zoom**
The rounded highlight on CPU/GPU/RAM etc. toggle sliders doesn't scale with `ui-scale-125`. The active indicator (e.g. "GPU") is partially outside the highlight pill. Low priority — cosmetic, obvious which option is selected.
- [ ] **Document library: batch operations**
Multi-select, bulk delete, bulk export. Currently only single-document operations.

View file

@ -2696,6 +2696,9 @@ def setup_cookbook_routes() -> APIRouter:
elif disk_env.get("servers") and not inc_env.get("servers"):
inc_env["servers"] = disk_env["servers"]
logger.warning("cookbook state POST: incoming env.servers empty; preserved on-disk servers (anti-wipe guard)")
if disk_env.get("hfToken") and not inc_env.get("hfToken"):
inc_env["hfToken"] = disk_env["hfToken"]
logger.warning("cookbook state POST: incoming env.hfToken empty; preserved on-disk hfToken (anti-wipe guard)")
disk_tasks = on_disk.get("tasks") or [] if isinstance(on_disk, dict) else []
incoming_tasks = data.get("tasks") if isinstance(data.get("tasks"), list) else []

View file

@ -1909,19 +1909,20 @@ function initializeEventListeners() {
// genuinely taller than the room above the button.
function positionMenu() {
const r = plusBtn.getBoundingClientRect();
menu.style.left = r.left + 'px';
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.left = (r.left / z) + 'px';
menu.style.right = 'auto';
menu.style.bottom = 'auto';
menu.style.maxHeight = ''; // reset so we can measure the natural height
menu.style.overflowY = '';
const avail = r.top - 16; // room above the chevron
const avail = (r.top / z) - 16; // room above the chevron (viewport-relative)
const natural = menu.scrollHeight;
const h = Math.min(natural, avail);
if (natural > avail) { // only cap + scroll when it doesn't fit
menu.style.maxHeight = avail + 'px';
menu.style.overflowY = 'auto';
}
menu.style.top = (r.top - 8 - h) + 'px';
menu.style.top = ((r.top / z) - 8 - h) + 'px';
}
// Tapping the chevron must NOT steal focus from the message box, or the
// mobile keyboard collapses. preventDefault on pointerdown keeps the

View file

@ -293,10 +293,11 @@ function commitCurrent() {
function position(p, anchor) {
const rect = anchor.getBoundingClientRect();
const pRect = p.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 6;
const zc = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
let left = rect.left / zc;
let top = (rect.bottom / zc) + 6;
if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8;
if (top + pRect.height > window.innerHeight - 8) top = rect.top - pRect.height - 6;
if (top + pRect.height > window.innerHeight - 8) top = (rect.top / zc) - pRect.height - 6;
if (left < 8) left = 8;
if (top < 8) top = 8;
p.style.left = left + 'px';

View file

@ -2530,8 +2530,8 @@ export function _renderRunningTab() {
const rect = menuBtn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
dropdown.style.position = 'fixed';
dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.right = ((window.innerWidth / z) - rect.right) + 'px';
dropdown.style.top = (rect.bottom / z) + 2 + 'px';
dropdown.style.right = (window.innerWidth - (rect.right / z)) + 'px';
document.body.appendChild(dropdown);
// Clamp into the *visible* area. On mobile (esp. Firefox) window.innerHeight
// includes the strip hidden under the dynamic toolbar, so a menu that "fits"

View file

@ -1078,7 +1078,7 @@ function _rerenderCachedModels() {
dropdown.appendChild(cancelDiv);
const rect = btn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${(window.innerWidth / z) - rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth - (rect.right / z)}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
document.body.appendChild(dropdown);
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
// on mobile under the dynamic toolbar). Flip above the button if there's no
@ -2328,10 +2328,11 @@ function _rerenderCachedModels() {
// Clamp into the viewport using the menu's real size (both axes); flip
// above the toggle if there isn't room below. Right-align to the anchor.
const w = dropdown.offsetWidth, h = dropdown.offsetHeight;
let left = Math.min(rect.right - w, window.innerWidth - w - 8);
const za = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
let left = Math.min((rect.right / za) - w, window.innerWidth - w - 8);
left = Math.max(8, left);
let top = rect.bottom + 6;
if (top + h > window.innerHeight - 8) top = Math.max(8, rect.top - 6 - h);
let top = (rect.bottom / za) + 6;
if (top + h > window.innerHeight - 8) top = Math.max(8, (rect.top / za) - 6 - h);
dropdown.style.left = `${left}px`;
dropdown.style.top = `${top}px`;
dropdown.style.visibility = '';
@ -2433,8 +2434,9 @@ function _rerenderCachedModels() {
menu.appendChild(mk('Clear Server', 'cookbook-dropdown-danger', () => _clearBtn?.click()));
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
const r = _launchMoreBtn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.position = 'fixed';
menu.style.right = (window.innerWidth - r.right) + 'px';
menu.style.right = (window.innerWidth - (r.right / z)) + 'px';
document.body.appendChild(menu);
{
const vv = window.visualViewport;
@ -2479,8 +2481,9 @@ function _rerenderCachedModels() {
menu.appendChild(mk('Probe GPUs', '', () => _probeBtn?.click()));
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
const r = _splitArrow.getBoundingClientRect();
const z2 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.position = 'fixed';
menu.style.right = (window.innerWidth - r.right) + 'px';
menu.style.right = (window.innerWidth - (r.right / z2)) + 'px';
document.body.appendChild(menu);
// Default open BELOW, but if there's no room (esp. on mobile where
// the arrow sits near the bottom of the modal) flip ABOVE so the
@ -2587,12 +2590,13 @@ function _rerenderCachedModels() {
// popup stays fully visible — GPU buttons near the right edge
// of the modal previously anchored the popup mostly off-screen.
const r = anchorBtn.getBoundingClientRect();
const z3 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
const vw = window.innerWidth || document.documentElement.clientWidth;
const vh = window.innerHeight || document.documentElement.clientHeight;
const pw = popup.offsetWidth || 320;
const ph = popup.offsetHeight || 200;
let left = r.left;
let top = r.bottom + 4;
let left = r.left / z3;
let top = (r.bottom / z3) + 4;
// Push left so the popup doesn't overflow the right edge.
if (left + pw > vw - 8) left = Math.max(8, vw - pw - 8);
// If there isn't room below, render above the button instead.

View file

@ -157,9 +157,10 @@ function togglePicker(anchor, target) {
document.body.appendChild(_pickerEl);
const rect = anchor.getBoundingClientRect();
const ze = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
_pickerEl.style.position = 'fixed';
_pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px';
_pickerEl.style.top = ((rect.bottom / ze) + 4) + 'px';
_pickerEl.style.left = (rect.left / ze) + 'px';
_pickerEl.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => {

View file

@ -443,13 +443,14 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
// bring-to-front counter climbs past the static value (#4720).
menu.style.zIndex = String(topPortalZ());
const r = btn.getBoundingClientRect();
menu.style.top = (r.bottom + 4) + 'px';
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
const zs = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.top = ((r.bottom / zs) + 4) + 'px';
menu.style.right = Math.max(6, window.innerWidth - (r.right / zs)) + 'px';
// Keep it on-screen (mobile): flip above the button if it would overflow the
// bottom, clamp the left edge, and cap the height as a last resort.
const mr = menu.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 6) {
menu.style.top = Math.max(6, r.top - mr.height - 4) + 'px';
menu.style.top = Math.max(6, (r.top / zs) - mr.height - 4) + 'px';
}
if (mr.left < 6) {
menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px';

View file

@ -1397,12 +1397,13 @@ function _showThemeZoneHighlight(selector) {
if (el.closest && el.closest('#theme-modal')) return;
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return;
const zt = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
const overlay = document.createElement('div');
overlay.className = 'theme-zone-highlight';
overlay.style.top = (r.top - 2) + 'px';
overlay.style.left = (r.left - 2) + 'px';
overlay.style.width = (r.width + 4) + 'px';
overlay.style.height = (r.height + 4) + 'px';
overlay.style.top = ((r.top / zt) - 2) + 'px';
overlay.style.left = ((r.left / zt) - 2) + 'px';
overlay.style.width = ((r.width / zt) + 4) + 'px';
overlay.style.height = ((r.height / zt) + 4) + 'px';
document.body.appendChild(overlay);
});
}