Compare commits

...

11 Commits

Author SHA1 Message Date
Bas Nijholt
7caf006e07 feat(web): add Rich logging for better error debugging (#90)
Add structured logging with Rich tracebacks to web UI components:
- Configure RichHandler in app.py for formatted output
- Log SSH/file operation failures in API routes with full tracebacks
- Log WebSocket exec/shell errors for connection issues
- Add warning logs for failed container state queries

Errors now show detailed tracebacks in container logs instead of
just returning 500 status codes.
2025-12-20 20:47:34 -08:00
Bas Nijholt
45040b75f1 feat(web): add Pull All and Update All buttons to dashboard (#89)
- Add "Pull All" and "Update All" buttons to dashboard for bulk operations
- Switch from native `title` attribute to DaisyUI tooltips for instant, styled tooltips
- Add tooltips to save buttons clarifying what they save
- Add tooltip to container shell button
- Fix tooltip z-index so they appear above sidebar
- Fix tooltip clipping by removing `overflow-y-auto` from main content
- Position container shell tooltip to the left to avoid clipping
2025-12-20 20:41:26 -08:00
Bas Nijholt
fa1c5c1044 docs: update theme to indigo with system preference support (#88)
Switch from teal to indigo primary color to match Zensical docs theme.
Add system preference detection and orange accent for dark mode.
2025-12-20 20:18:28 -08:00
Bas Nijholt
67e832f687 docs: clarify config file locations and update install URL (#86) 2025-12-20 20:12:06 -08:00
Bas Nijholt
da986fab6a fix: improve command palette theme filtering (#87)
- Normalize spaces after colons so "theme:dark" matches "theme: dark"
- Also handles multiple spaces like "theme:  dark"
2025-12-20 20:03:16 -08:00
Bas Nijholt
5dd6e2ca05 fix: improve theme picker usability in command palette (#85) 2025-12-20 20:00:05 -08:00
Bas Nijholt
16435065de fix: video autoplay for Safari and Chrome with instant navigation (#84) 2025-12-20 19:49:05 -08:00
Bas Nijholt
5921b5e405 docs: update web-workflow demo recording (#83) 2025-12-20 18:09:24 -08:00
Bas Nijholt
f0cd85b5f5 fix: prevent terminal reconnection to wrong page after navigation (#81) 2025-12-20 16:41:28 -08:00
Bas Nijholt
fe95443733 fix: Safari video autoplay on first page load (#82) 2025-12-20 16:41:04 -08:00
Bas Nijholt
8df9288156 docs: add Quick Demo GIFs to README (#80)
* docs: add Quick Demo GIFs to README

Add the same CLI and Web UI demo GIFs that appear on the docs homepage.

* docs: add Table of Contents header
2025-12-20 16:20:42 -08:00
22 changed files with 296 additions and 32 deletions

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ compose-farm.yaml
coverage.xml
.env
homepage/
site/

View File

@@ -12,6 +12,18 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
> [!NOTE]
> Run `docker compose` commands across multiple hosts via SSH. One YAML maps stacks to hosts. Run `cf apply` and reality matches your config—stacks start, migrate, or stop as needed. No Kubernetes, no Swarm, no magic.
## Quick Demo
**CLI:**
![CLI Demo](docs/assets/quickstart.gif)
**Web UI:**
![Web UI Demo](docs/assets/web-workflow.gif)
## Table of Contents
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
@@ -143,7 +155,7 @@ If you need containers on different hosts to communicate seamlessly, you need Do
```bash
# One-liner (installs uv if needed)
curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
curl -fsSL https://compose-farm.nijho.lt/install | sh
# Or if you already have uv/pip
uv tool install compose-farm
@@ -225,7 +237,7 @@ The keys will persist across restarts.
## Configuration
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`). This keeps config near your stacks. Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config, or symlink from one to the other with `cf config symlink`.
### Single-host example

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:92ed41854fe852ae54aa5f05de8ceaf35c3ad8ef82b3034e67edf758d1acdf50
size 13593713
oid sha256:ff2e3ca5a46397efcd5f3a595e7d3c179266cc4f3f5f528b428f5ef2a423028e
size 12649149

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8507c61df25981dbe7e5bd2f9ed16c9a0befbca218947cad29f6679c77a695a7
size 12451891
oid sha256:2d739c5f77ddd9d90b609e31df620b35988081b7341fe225eb717d71a87caa88
size 12284953

View File

@@ -63,7 +63,7 @@ def test_demo_themes(recording_page: Page, server_url: str) -> None:
pause(page, 400)
# Type to filter to a light theme (theme button pre-populates "theme:")
slow_type(page, "#cmd-input", " cup", delay=100)
slow_type(page, "#cmd-input", "cup", delay=100)
pause(page, 500)
page.keyboard.press("Enter")
pause(page, 1000)
@@ -75,7 +75,7 @@ def test_demo_themes(recording_page: Page, server_url: str) -> None:
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 300)
slow_type(page, "#cmd-input", " dark", delay=100)
slow_type(page, "#cmd-input", "dark", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 800)

View File

@@ -24,7 +24,7 @@ Before you begin, ensure you have:
### One-liner (recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
curl -fsSL https://compose-farm.nijho.lt/install | sh
```
This installs [uv](https://docs.astral.sh/uv/) if needed, then installs compose-farm.
@@ -123,7 +123,21 @@ nas:/volume1/compose /opt/compose nfs defaults 0 0
### Create Config File
Create `~/.config/compose-farm/compose-farm.yaml`:
Create `compose-farm.yaml` in the directory where you'll run commands. For example, if your stacks are in `/opt/stacks`, place the config there too:
```bash
cd /opt/stacks
cf config init
```
Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config. You can also symlink a working directory config to the global location:
```bash
# Create config in your stacks directory, symlink to ~/.config
cf config symlink /opt/stacks/compose-farm.yaml
```
This way, `cf` commands work from anywhere while the config lives with your stacks.
#### Single host example

View File

@@ -96,7 +96,7 @@ pip install compose-farm
### Configuration
Create `~/.config/compose-farm/compose-farm.yaml`:
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`), or in `~/.config/compose-farm/`:
```yaml
compose_dir: /opt/compose
@@ -114,6 +114,8 @@ stacks:
radarr: hp
```
See [Configuration](configuration.md) for all options and the full search order.
### Usage
```bash

2
bootstrap.sh → docs/install Executable file → Normal file
View File

@@ -1,6 +1,6 @@
#!/bin/sh
# Compose Farm bootstrap script
# Usage: curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
# Usage: curl -fsSL https://compose-farm.nijho.lt/install | sh
#
# This script installs uv (if needed) and then installs compose-farm as a uv tool.

View File

@@ -0,0 +1,21 @@
// Fix Safari video autoplay issues
(function() {
function initVideos() {
document.querySelectorAll('video[autoplay]').forEach(function(video) {
video.load();
video.play().catch(function() {});
});
}
// For initial page load (needed for Chrome)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initVideos);
} else {
initVideos();
}
// For MkDocs instant navigation (needed for Safari)
if (typeof document$ !== 'undefined') {
document$.subscribe(initVideos);
}
})();

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import logging
import sys
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
@@ -10,11 +11,22 @@ from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from rich.logging import RichHandler
from compose_farm.web.deps import STATIC_DIR, get_config
from compose_farm.web.routes import actions, api, pages
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
# Configure logging with Rich handler for compose_farm.web modules
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)
# Set our web modules to INFO level (uvicorn handles its own logging)
logging.getLogger("compose_farm.web").setLevel(logging.INFO)
if TYPE_CHECKING:
from collections.abc import AsyncGenerator

View File

@@ -64,3 +64,19 @@ async def refresh_state() -> dict[str, Any]:
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["refresh"], tid))
return {"task_id": task_id, "command": "refresh"}
@router.post("/pull-all")
async def pull_all() -> dict[str, Any]:
"""Pull latest images for all stacks."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["pull", "--all"], tid))
return {"task_id": task_id, "command": "pull --all"}
@router.post("/update-all")
async def update_all() -> dict[str, Any]:
"""Update all stacks (pull + build + down + up)."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
return {"task_id": task_id, "command": "update --all"}

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import shlex
from datetime import UTC, datetime
from pathlib import Path
@@ -23,6 +24,8 @@ from compose_farm.paths import find_config_path
from compose_farm.state import load_state
from compose_farm.web.deps import get_config, get_templates
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"])
@@ -144,6 +147,12 @@ async def _get_container_states(
config, stack, host_name, "ps -a --format json", stream=False
)
if not result.success:
logger.warning(
"Failed to get container states for %s on %s: %s",
stack,
host_name,
result.stderr or result.stdout,
)
return containers
# Build state map: name -> (state, exit_code)
@@ -350,6 +359,7 @@ async def read_console_file(
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
except Exception as e:
logger.exception("Failed to read file %s from host %s", path, host)
raise HTTPException(status_code=500, detail=str(e)) from e
@@ -373,4 +383,5 @@ async def write_console_file(
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
except Exception as e:
logger.exception("Failed to write file %s to host %s", path, host)
raise HTTPException(status_code=500, detail=str(e)) from e

View File

@@ -1,3 +1,9 @@
/* Tooltips - ensure they appear above sidebar and other elements */
.tooltip::before,
.tooltip::after {
z-index: 1000;
}
/* Sidebar inputs - remove focus outline (DaisyUI 5 uses outline + outline-offset) */
#sidebar .input:focus,
#sidebar .input:focus-within,

View File

@@ -601,7 +601,9 @@ function playFabIntro() {
}
function filter() {
const q = input.value.toLowerCase();
// Normalize: collapse spaces and ensure space after colon for matching
// This allows "theme:dark", "theme: dark", "theme: dark" to all match "theme: dark"
const q = input.value.toLowerCase().replace(/\s+/g, ' ').replace(/:(\S)/g, ': $1');
filtered = commands.filter(c => c.name.toLowerCase().includes(q));
selected = Math.max(0, Math.min(selected, filtered.length - 1));
}
@@ -634,7 +636,7 @@ function playFabIntro() {
input.value = initialFilter;
filter();
// If opening theme picker, select current theme
if (initialFilter === 'theme:') {
if (initialFilter.startsWith('theme:')) {
const currentIdx = filtered.findIndex(c => c.themeId === originalTheme);
if (currentIdx >= 0) selected = currentIdx;
}
@@ -759,9 +761,14 @@ function initPage() {
/**
* Attempt to reconnect to an active task from localStorage
* @param {string} [path] - Optional path to use for task key lookup.
* If not provided, uses current window.location.pathname.
* This is important for HTMX navigation where pushState
* hasn't happened yet when htmx:afterSwap fires.
*/
function tryReconnectToTask() {
const taskId = localStorage.getItem(getTaskKey());
function tryReconnectToTask(path) {
const taskKey = TASK_KEY_PREFIX + (path || window.location.pathname);
const taskId = localStorage.getItem(taskKey);
if (!taskId) return;
whenXtermReady(() => {
@@ -784,8 +791,12 @@ document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'main-content') {
initPage();
// Try to reconnect when navigating back to dashboard
tryReconnectToTask();
// Try to reconnect to task for the TARGET page, not current URL.
// When using command palette navigation (htmx.ajax + manual pushState),
// window.location.pathname still reflects the OLD page at this point.
// Use pathInfo.requestPath to get the correct target path.
const targetPath = evt.detail.pathInfo?.requestPath?.split('?')[0] || window.location.pathname;
tryReconnectToTask(targetPath);
}
});

View File

@@ -39,7 +39,7 @@
<span class="font-semibold rainbow-hover">Compose Farm</span>
</header>
<main id="main-content" class="flex-1 p-6 overflow-y-auto">
<main id="main-content" class="flex-1 p-6">
{% block content %}{% endblock %}
</main>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% from "partials/components.html" import page_header, collapse, stat_card, table, action_btn %}
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database %}
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database, cloud_download, rotate_cw %}
{% block title %}Dashboard - Compose Farm{% endblock %}
{% block content %}
@@ -17,7 +17,9 @@
<div class="flex flex-wrap gap-2 mb-6">
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config", check()) }}
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality", refresh_cw()) }}
<button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button>
{{ action_btn("Pull All", "/api/pull-all", "outline", "Pull latest images for all stacks", cloud_download()) }}
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks (pull + build + down + up)", rotate_cw()) }}
<div class="tooltip" data-tip="Save compose-farm.yaml config file"><button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button></div>
</div>
{% include "partials/terminal.html" %}

View File

@@ -25,12 +25,13 @@
{# Action button with htmx #}
{% macro action_btn(label, url, style="outline", title=None, icon=None) %}
{% if title %}<div class="tooltip" data-tip="{{ title }}">{% endif %}
<button hx-post="{{ url }}"
hx-swap="none"
class="btn btn-{{ style }}"
{% if title %}title="{{ title }}"{% endif %}>
class="btn btn-{{ style }}">
{% if icon %}{{ icon }}{% endif %}{{ label }}
</button>
{% if title %}</div>{% endif %}
{% endmacro %}
{# Stat card for dashboard #}

View File

@@ -18,10 +18,12 @@
<span class="badge badge-warning">{{ container.State }}</span>
{% endif %}
<code class="text-sm flex-1">{{ container.Name }}</code>
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
<div class="tooltip tooltip-left" data-tip="Open shell in container">
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
</div>
</div>
{% endmacro %}

View File

@@ -30,7 +30,7 @@
<!-- Other -->
{{ action_btn("Pull", "/api/stack/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
{{ action_btn("Logs", "/api/stack/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
<button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button>
<div class="tooltip" data-tip="Save compose and .env files"><button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button></div>
</div>
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}

View File

@@ -6,6 +6,7 @@ import asyncio
import contextlib
import fcntl
import json
import logging
import os
import pty
import shlex
@@ -21,6 +22,8 @@ from compose_farm.executor import is_local, ssh_connect_kwargs
from compose_farm.web.deps import get_config
from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, tasks
logger = logging.getLogger(__name__)
# Shell command to prefer bash over sh
SHELL_FALLBACK = "command -v bash >/dev/null && exec bash || exec sh"
@@ -214,6 +217,7 @@ async def exec_websocket(
except WebSocketDisconnect:
pass
except Exception as e:
logger.exception("WebSocket exec error for %s on %s", container, host)
with contextlib.suppress(Exception):
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
finally:
@@ -258,6 +262,7 @@ async def shell_websocket(
except WebSocketDisconnect:
pass
except Exception as e:
logger.exception("WebSocket shell error for host %s", host)
with contextlib.suppress(Exception):
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
finally:

View File

@@ -1879,3 +1879,138 @@ class TestThemeSwitcher:
# Theme should still be dark
final = page.locator("html").get_attribute("data-theme")
assert final == "dark", f"Expected dark after executing Dashboard, got {final}"
class TestTerminalNavigationIsolation:
"""Test that terminal connections are properly isolated per page.
Regression tests for a bug where navigating away from a stack page via
command palette would cause the new page to reconnect to the old page's
terminal task because history.pushState hadn't updated the URL yet when
tryReconnectToTask() ran.
"""
def test_stack_terminal_not_reconnected_on_dashboard(self, page: Page, server_url: str) -> None:
"""Terminal started on stack page should NOT reconnect when navigating to dashboard.
Bug scenario:
1. On /stack/plex, click Update → terminal connects, task stored at cf_task:/stack/plex
2. Navigate to dashboard via command palette
3. Dashboard loads, htmx:afterSwap fires
4. tryReconnectToTask() runs but window.location.pathname is still /stack/plex
(because pushState hasn't run yet)
5. Bug: Dashboard reconnects to plex's terminal task
Expected: Dashboard should NOT have any task_id in its localStorage key (cf_task:/)
"""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=5000)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=5000)
# Clear any existing task state
page.evaluate("localStorage.clear()")
# Track WebSocket connections to see which terminals are opened
ws_urls: list[str] = []
def handle_ws(ws: WebSocket) -> None:
ws_urls.append(ws.url)
page.on("websocket", handle_ws)
# Mock Update API to return a task ID
page.route(
"**/api/stack/plex/update",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "plex-update-task-123", "stack": "plex", "command": "update"}',
),
)
# Wait for xterm to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=5000)
# Click Update button on plex stack page
page.locator("button", has_text="Update").click()
# Wait for terminal to connect
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
page.wait_for_timeout(500)
# Verify task was stored for /stack/plex
plex_task = page.evaluate("localStorage.getItem('cf_task:/stack/plex')")
assert plex_task == "plex-update-task-123", (
f"Expected task stored at /stack/plex, got {plex_task}"
)
# Dashboard should have NO task yet
dashboard_task_before = page.evaluate("localStorage.getItem('cf_task:/')")
assert dashboard_task_before is None, (
f"Dashboard should have no task before navigation, got {dashboard_task_before}"
)
# Count current WebSocket connections
ws_count_before = len(ws_urls)
# Navigate to dashboard via command palette (this triggers the bug)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
page.locator("#cmd-input").fill("Dashboard")
page.keyboard.press("Enter")
# Wait for navigation to complete
page.wait_for_url(server_url, timeout=5000)
page.wait_for_selector("#stats-cards", timeout=5000)
# Give time for any erroneous reconnection attempts
page.wait_for_timeout(1000)
# CRITICAL ASSERTION: Dashboard should NOT have a task in localStorage
dashboard_task_after = page.evaluate("localStorage.getItem('cf_task:/')")
assert dashboard_task_after is None, (
f"Bug detected: Dashboard incorrectly has task '{dashboard_task_after}' in localStorage. "
"This means tryReconnectToTask() ran before pushState updated the URL."
)
# CRITICAL ASSERTION: No new WebSocket should have been opened for the plex task
# after navigating to dashboard
new_ws_urls = ws_urls[ws_count_before:]
plex_reconnect_attempts = [url for url in new_ws_urls if "plex-update-task-123" in url]
assert len(plex_reconnect_attempts) == 0, (
f"Bug detected: Dashboard attempted to reconnect to plex task. "
f"New WebSocket URLs after navigation: {new_ws_urls}"
)
def test_dashboard_terminal_not_shown_after_stack_navigation(
self, page: Page, server_url: str
) -> None:
"""Dashboard's terminal should remain collapsed when navigating away and back.
This tests that navigating from dashboard → stack → dashboard doesn't
cause the terminal to expand unexpectedly.
"""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=5000)
# Terminal should be collapsed on dashboard
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should be collapsed initially"
# Navigate to a stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=5000)
# Navigate back to dashboard via command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
page.locator("#cmd-input").fill("Dashboard")
page.keyboard.press("Enter")
page.wait_for_url(server_url, timeout=5000)
# Terminal should still be collapsed (no task to reconnect to)
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should remain collapsed after navigation"

View File

@@ -11,6 +11,7 @@ copyright = "Copyright &copy; 2025 Bas Nijholt"
repo_url = "https://github.com/basnijholt/compose-farm"
repo_name = "GitHub"
edit_uri = "edit/main/docs"
extra_javascript = ["javascripts/video-fix.js"]
nav = [
{ "Home" = "index.md" },
@@ -48,16 +49,25 @@ features = [
]
[[project.theme.palette]]
media = "(prefers-color-scheme)"
toggle.icon = "lucide/sun-moon"
toggle.name = "Switch to light mode"
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "teal"
primary = "indigo"
accent = "indigo"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "teal"
toggle.icon = "lucide/moon"
toggle.name = "Switch to light mode"
primary = "indigo"
accent = "orange"
toggle.icon = "lucide/moon-star"
toggle.name = "Switch to system preference"
[project.theme.font]
text = "Inter"
@@ -67,6 +77,9 @@ code = "JetBrains Mono"
logo = "lucide/server"
repo = "lucide/github"
[project.extra]
generator = false
[[project.extra.social]]
icon = "fontawesome/brands/github"
link = "https://github.com/basnijholt/compose-farm"