Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/druks/accounts/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ class AccountKind(StrEnum):
OPERATOR = "operator"
BOT = "bot"
BOT_ADMIN = "bot_admin"


class Theme(StrEnum):
SYSTEM = "system"
LIGHT = "light"
DARK = "dark"
8 changes: 6 additions & 2 deletions backend/druks/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import secrets
from datetime import datetime

from sqlalchemy import ForeignKey, Index, LargeBinary, String, select, text
from sqlalchemy import CheckConstraint, ForeignKey, Index, LargeBinary, String, select, text
from sqlalchemy.dialects.postgresql import CITEXT, JSONB, insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
Expand All @@ -19,7 +19,7 @@
PAT_SECRET_BYTES,
PAT_TOKEN_TAG,
)
from druks.accounts.enums import AccountKind
from druks.accounts.enums import AccountKind, Theme
from druks.accounts.exceptions import AuthConfigurationError, InvalidPatError
from druks.core.models import Uuid7Pk, uuid7_str
from druks.models import Base
Expand All @@ -33,6 +33,7 @@
class Account(Base, Uuid7Pk):
__tablename__ = "accounts"
__table_args__ = (
CheckConstraint("theme IN ('system', 'light', 'dark')", name="accounts_theme_allowed"),
Index(
"accounts_default_idx", "is_default", unique=True, postgresql_where=text("is_default")
),
Expand All @@ -47,6 +48,9 @@ class Account(Base, Uuid7Pk):
)
is_default: Mapped[bool] = mapped_column(default=False, server_default=text("false"))
timezone: Mapped[str] = mapped_column(String, default="UTC")
theme: Mapped[str] = mapped_column(
String, default=Theme.SYSTEM, server_default=text("'system'")
)
gate_park_destination_id: Mapped[str | None] = mapped_column(
ForeignKey("notification_destinations.id", ondelete="SET NULL"), default=None
)
Expand Down
46 changes: 37 additions & 9 deletions backend/druks/core/templates/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,49 @@
{# The dashboard's tokens, inlined: these pages are served by the API with no
SPA assets in reach, and a hashed stylesheet URL would rot. #}
<style>
:root {
color-scheme: light;
--bg: #f7f7f7;
--surface: #fcfcfc;
--input: #ffffff;
--text: #293638;
--text-secondary: #424e50;
--text-tertiary: #5b6668;
--border: #dee2de;
--border-strong: #71898b;
--accent: #00677b;
--on-accent: #fffcf6;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #0c0c0c;
--surface: #111111;
--input: #181818;
--text: #f0f2f7;
--text-secondary: #b4bccd;
--text-tertiary: #99a3b9;
--border: #2b2b2b;
--border-strong: #878787;
--accent: #4dd4f4;
--on-accent: #0c0c0c;
}
}
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #0a0c12; color: #f0f2f7;
background: var(--bg); color: var(--text);
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
main { background: #0e1119; border: 1px solid #1c2030; border-radius: 8px;
main { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 28px 32px; max-width: 30rem; }
.wordmark { color: #7e889d; font-size: 12px; letter-spacing: .14em;
.wordmark { color: var(--text-tertiary); font-size: 12px; letter-spacing: .14em;
text-transform: uppercase; margin-bottom: 14px; }
p { margin: 0 0 12px; color: #b4bccd; }
a { color: #a78bfa; }
b { color: #f0f2f7; }
label { display: block; color: #99a3b9; font-size: 13px; }
p { margin: 0 0 12px; color: var(--text-secondary); }
a { color: var(--accent); }
b { color: var(--text); }
label { display: block; color: var(--text-tertiary); font-size: 13px; }
input { width: 100%; box-sizing: border-box; margin-top: 4px; padding: 8px 10px;
background: #131825; color: #f0f2f7; border: 1px solid #1c2030;
background: var(--input); color: var(--text); border: 1px solid var(--border-strong);
border-radius: 5px; font: inherit; }
button { padding: 9px 16px; background: #a78bfa; color: #0a0c12; border: 0;
button { padding: 9px 16px; background: var(--accent); color: var(--on-accent); border: 0;
border-radius: 5px; font: inherit; font-weight: 600; cursor: pointer; }
</style>
</head>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Add an account-owned theme preference.

Revision ID: d8f6a2c91b47
Revises: 8c2f4e6a1d37
"""

import sqlalchemy as sa
from alembic import op

revision: str = "d8f6a2c91b47"
down_revision: str | None = "8c2f4e6a1d37"
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
op.add_column(
"accounts",
sa.Column("theme", sa.String(), server_default="system", nullable=False),
)
op.create_check_constraint(
"accounts_theme_allowed", "accounts", "theme IN ('system', 'light', 'dark')"
)


def downgrade() -> None:
op.drop_constraint("accounts_theme_allowed", "accounts", type_="check")
op.drop_column("accounts", "theme")
3 changes: 3 additions & 0 deletions backend/druks/user_settings/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, PositiveInt
from pydantic.fields import FieldInfo

from druks.accounts.enums import Theme
from druks.apps.settings import (
field_choices,
field_kind,
Expand Down Expand Up @@ -33,6 +34,7 @@ class PersonalSettingsResponse(Schema):
model_config = ConfigDict(from_attributes=True)

timezone: str
theme: Theme
gate_park_destination_id: str | None
is_tag_required: bool

Expand All @@ -55,6 +57,7 @@ class UpdatePersonalSettingsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

timezone: Annotated[str, AfterValidator(validate_timezone)] | None = None
theme: Theme | None = None
# Absent leaves the destination unchanged. Null turns notifications off.
gate_park_destination_id: str | None = Field(
default=None, validation_alias="gateParkDestinationId"
Expand Down
24 changes: 18 additions & 6 deletions backend/tests/test_personal_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async def test_account_creation_copies_defaults_once(tmp_path, druks_db, monkeyp
)
alice = await Account.get_or_create(druks_db, "alice@example.com")
assert (alice.timezone, alice.gate_park_destination_id) == ("Europe/Madrid", destination.id)
assert alice.theme == "system"
await alice.update_preferences(timezone="America/New_York", gate_park_destination_id=None)
await installation.update(default_effort="low")
existing = await Account.get_or_create(druks_db, "alice@example.com")
Expand All @@ -51,6 +52,7 @@ async def test_account_creation_copies_defaults_once(tmp_path, druks_db, monkeyp
"/api/settings/personal", headers={IDENTITY_HEADER: alice.username}
).json() == {
"timezone": "America/New_York",
"theme": "system",
"gateParkDestinationId": None,
"isTagRequired": False,
}
Expand Down Expand Up @@ -89,6 +91,7 @@ async def test_migrations_preserve_preferences_and_installation_execution(druks_
for statement in (
"DELETE FROM settings",
"ALTER TABLE accounts DROP COLUMN timezone",
"ALTER TABLE accounts DROP COLUMN theme",
"ALTER TABLE accounts DROP COLUMN gate_park_destination_id",
"ALTER TABLE settings DROP CONSTRAINT settings_singleton",
"ALTER TABLE settings ADD COLUMN timezone varchar NOT NULL DEFAULT 'UTC'",
Expand Down Expand Up @@ -128,6 +131,7 @@ def upgrade(connection):
"74b53981122c_add_account_preferences.py",
"7dc609a2d51a_move_preferences_to_accounts.py",
"b43924bf37db_enforce_installation_settings_singleton.py",
"d8f6a2c91b47_add_account_theme_preference.py",
):
path = (
Path(__file__).resolve().parent.parent
Expand All @@ -145,17 +149,17 @@ def upgrade(connection):
await (await druks_db.connection()).run_sync(upgrade)
preferences = (
await druks_db.execute(
text("SELECT id, timezone, gate_park_destination_id FROM accounts ORDER BY id")
text("SELECT id, timezone, theme, gate_park_destination_id FROM accounts ORDER BY id")
)
).all()
expected = (
[
(alice.id, "America/New_York", destination.id),
(bob.id, "Asia/Tokyo", None),
(charlie.id, "Europe/Madrid", destination.id),
(alice.id, "America/New_York", "system", destination.id),
(bob.id, "Asia/Tokyo", "system", None),
(charlie.id, "Europe/Madrid", "system", destination.id),
]
if has_settings
else [(account_id, "UTC", None) for account_id in account_ids]
else [(account_id, "UTC", "system", None) for account_id in account_ids]
)
assert preferences == sorted(expected)
execution = (
Expand Down Expand Up @@ -268,7 +272,13 @@ async def apply_schedules(druks_db):
alice = {IDENTITY_HEADER: "alice@example.com"}
bob = {IDENTITY_HEADER: "bob@example.com"}
inherited = client.get("/api/settings/personal", headers=alice).json()
assert set(inherited) == {"timezone", "gateParkDestinationId", "isTagRequired"}
assert set(inherited) == {"timezone", "theme", "gateParkDestinationId", "isTagRequired"}
assert inherited["theme"] == "system"
saved_theme = client.patch("/api/settings/personal", headers=alice, json={"theme": "dark"})
assert saved_theme.status_code == 200
assert saved_theme.json()["theme"] == "dark"
assert client.get("/api/settings/personal", headers=bob).json()["theme"] == "system"
assert (await Account.get_for_username(druks_db, "alice@example.com")).theme == "dark"
saved = client.patch(
"/api/settings/personal", headers=alice, json={"timezone": "America/New_York"}
)
Expand Down Expand Up @@ -296,6 +306,7 @@ async def apply_schedules(druks_db):
("fastMode", True),
("defaultTimeout", 600),
("timezone", "not-a-timezone"),
("theme", "sepia"),
],
)
async def test_invalid_edit_keeps_account_preferences(tmp_path, druks_db, field, value):
Expand All @@ -304,6 +315,7 @@ async def test_invalid_edit_keeps_account_preferences(tmp_path, druks_db, field,
assert response.status_code == 422
assert client.patch("/api/settings/personal", json={}).status_code == 200
assert (await Account.get_default(druks_db)).timezone == "UTC"
assert (await Account.get_default(druks_db)).theme == "system"


async def test_shared_agent_overrides_use_installation_settings_after_a_personal_edit(
Expand Down
4 changes: 2 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ keeps streamed replies visible until the operator scrolls up. See the
Settings use `/settings/<section>` routes. `/settings/personal` edits the current
account's preferences through `/api/settings/personal`. `/settings/agents` edits
shared execution defaults through `/api/settings`. The
preferences provider uses the personal endpoint for timestamp display. Preferences
contains only timezone and does not depend on execution catalogs. All accounts
preferences provider uses the personal endpoint for timestamp display and theme
selection. Preferences does not depend on execution catalogs. All accounts
use the shared execution defaults in Agents and the app agent overrides. Search
matches section names and app field labels. Preferences and Agents
retain separate drafts across settings pages. Save changes applies only the current page.
Expand Down
4 changes: 2 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#0a0a0d" />
<meta name="theme-color" content="#f7f7f7" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions frontend/public/site.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"short_name": "Druks",
"start_url": "/",
"display": "standalone",
"theme_color": "#0a0a0d",
"background_color": "#0a0a0d",
"theme_color": "#f7f7f7",
"background_color": "#f7f7f7",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { ChatPage } from './pages/ChatPage'
import { LoginWindowPage } from './pages/LoginWindowPage'
import { UsagePage } from './pages/UsagePage'
import { SchedulesPage } from './pages/SchedulesPage'
import { appAccent } from './lib/appColors'
import './apps'
import { registerInstalledApps } from './apps/installed'
import { appHome, appLabel, appOwning, getAppUI, registeredApps } from './apps/registry'
Expand Down Expand Up @@ -230,7 +229,6 @@ function AppShell({
.map((entry) => entry.name)
.filter((name) => installed.has(name))
}, [rosterQuery.data])
const accent = useMemo(() => appAccent(registered), [registered])
const defaultApp = registered[0] ?? null
const [lastApp, setLastApp] = useState<string | null>(null)
const urlApp = appOwning(location, rosterQuery.data)
Expand Down Expand Up @@ -429,7 +427,6 @@ function AppShell({
href={url}
className="command-tab"
aria-current={url === activeTab ? 'page' : undefined}
style={url === activeTab ? { borderBottomColor: accent[urlApp] } : undefined}
>
{name}
</Link>
Expand Down
Loading
Loading