diff --git a/backend/druks/accounts/enums.py b/backend/druks/accounts/enums.py index ae28cb692..8e97d1c77 100644 --- a/backend/druks/accounts/enums.py +++ b/backend/druks/accounts/enums.py @@ -8,3 +8,9 @@ class AccountKind(StrEnum): OPERATOR = "operator" BOT = "bot" BOT_ADMIN = "bot_admin" + + +class Theme(StrEnum): + SYSTEM = "system" + LIGHT = "light" + DARK = "dark" diff --git a/backend/druks/accounts/models.py b/backend/druks/accounts/models.py index c504b13f5..7a8d14cd8 100644 --- a/backend/druks/accounts/models.py +++ b/backend/druks/accounts/models.py @@ -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 @@ -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 @@ -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") ), @@ -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 ) diff --git a/backend/druks/core/templates/page.html b/backend/druks/core/templates/page.html index 87623ba05..27d4518c6 100644 --- a/backend/druks/core/templates/page.html +++ b/backend/druks/core/templates/page.html @@ -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. #} diff --git a/backend/druks/migrations/versions/d8f6a2c91b47_add_account_theme_preference.py b/backend/druks/migrations/versions/d8f6a2c91b47_add_account_theme_preference.py new file mode 100644 index 000000000..1cedcf09a --- /dev/null +++ b/backend/druks/migrations/versions/d8f6a2c91b47_add_account_theme_preference.py @@ -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") diff --git a/backend/druks/user_settings/schemas.py b/backend/druks/user_settings/schemas.py index 951ff442e..b72bf19e1 100644 --- a/backend/druks/user_settings/schemas.py +++ b/backend/druks/user_settings/schemas.py @@ -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, @@ -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 @@ -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" diff --git a/backend/tests/test_personal_settings.py b/backend/tests/test_personal_settings.py index b643b7292..0ea0d7d9b 100644 --- a/backend/tests/test_personal_settings.py +++ b/backend/tests/test_personal_settings.py @@ -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") @@ -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, } @@ -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'", @@ -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 @@ -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 = ( @@ -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"} ) @@ -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): @@ -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( diff --git a/frontend/README.md b/frontend/README.md index 23a059583..f2675ea92 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -62,8 +62,8 @@ keeps streamed replies visible until the operator scrolls up. See the Settings use `/settings/
` 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. diff --git a/frontend/index.html b/frontend/index.html index 907072503..56d31ddb8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,11 +8,11 @@ - + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 05008df19..a731c2461 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1 @@ - + diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest index f47f16091..0b3fc1908 100644 --- a/frontend/public/site.webmanifest +++ b/frontend/public/site.webmanifest @@ -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" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a39a2d08..4b60573b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' @@ -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(null) const urlApp = appOwning(location, rosterQuery.data) @@ -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} diff --git a/frontend/src/activity.css b/frontend/src/activity.css index fb8819712..60c353ec1 100644 --- a/frontend/src/activity.css +++ b/frontend/src/activity.css @@ -1,88 +1,87 @@ .activity-page { font: 15px/1.5 var(--font-sans); } .activity-layout { display: flex; min-width: 0; min-height: 0; flex: 1; } -.activity-list { flex: 1; min-width: 0; overflow-y: auto; padding: 24px var(--page-gutter); scrollbar-color: var(--border-loud) transparent; } +.activity-list { flex: 1; min-width: 0; overflow-y: auto; padding: 24px var(--page-gutter); scrollbar-color: var(--border-soft) transparent; } .activity-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; } .activity-header h1 { margin: 0; font-size: 26px; line-height: 34px; font-weight: 600; } .activity-connection { display: flex; align-items: center; gap: 12px; font-size: 14px; } .activity-connection span { display: inline-flex; align-items: center; gap: 8px; } -.activity-live svg { color: var(--outcome-merged); fill: var(--outcome-merged); } -.activity-connection button, .activity-refresh { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--border-loud); border-radius: 5px; } -.activity-toolbar { display: flex; align-items: center; gap: 10px; padding-bottom: 16px; border-bottom: 1px solid var(--border-loud); } +.activity-live svg { color: var(--success); fill: var(--success); } +.activity-connection button, .activity-refresh { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 5px; } +.activity-toolbar { display: flex; align-items: center; gap: 10px; padding-bottom: 16px; border-bottom: 1px solid var(--border-soft); } .activity-search { flex: 1; display: flex; align-items: center; min-width: 100px; position: relative; } -.activity-search svg { position: absolute; left: 10px; color: var(--text-dim); pointer-events: none; } -.activity-search input { width: 100%; min-width: 0; height: 34px; padding: 6px 10px 6px 34px; border: 1px solid var(--border-loud); border-radius: 5px; background: var(--surface); color: var(--text); font: inherit; caret-color: var(--accent); } -.activity-search input::placeholder { color: var(--text-dim); opacity: 1; } -.activity-filter-trigger { display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex-shrink: 0; height: 34px; padding: 0 10px; border: 1px solid var(--border-loud); border-radius: 5px; } +.activity-search svg { position: absolute; left: 10px; color: var(--text-tertiary); pointer-events: none; } +.activity-search input { width: 100%; min-width: 0; height: 34px; padding: 6px 10px 6px 34px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--input); color: var(--text); font: inherit; caret-color: var(--accent); } +.activity-search input::placeholder { color: var(--text-tertiary); opacity: 1; } +.activity-filter-trigger { display: inline-flex; align-items: center; justify-content: center; gap: 8px; flex-shrink: 0; height: 34px; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 5px; } .activity-filter-trigger[aria-expanded="true"] { border-color: var(--accent); background: var(--surface-2); } .activity-filter-count { color: var(--accent); } .activity-refresh { flex-shrink: 0; } -.activity-active-filters { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; padding: 10px 0; color: var(--text-mid); font-size: 13px; overflow-wrap: anywhere; } +.activity-active-filters { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; padding: 10px 0; color: var(--text-secondary); font-size: 13px; overflow-wrap: anywhere; } .activity-active-filters button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; color: var(--accent); } -.set-menu.activity-control { width: 320px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 160px); padding: 16px; scrollbar-color: var(--border-loud) transparent; } +.set-menu.activity-control { width: 320px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 160px); padding: 16px; scrollbar-color: var(--border-soft) transparent; } .activity-control > div { display: flex; flex-direction: column; gap: 12px; } .activity-control header { display: flex; align-items: center; justify-content: space-between; } .activity-control header button { display: grid; place-items: center; width: 32px; height: 32px; margin: -8px -8px -8px 0; } -.activity-control label { display: flex; flex-direction: column; gap: 4px; min-width: 0; color: var(--text-mid); font-size: 13px; } -.activity-control :is(input, select) { width: 100%; min-width: 0; min-height: 36px; padding: 6px 8px; border: 1px solid var(--border-loud); border-radius: 5px; background: var(--surface); color: var(--text); font: 15px/1.4 var(--font-sans); color-scheme: dark; } +.activity-control label { display: flex; flex-direction: column; gap: 4px; min-width: 0; color: var(--text-secondary); font-size: 13px; } +.activity-control :is(input, select) { width: 100%; min-width: 0; min-height: 36px; padding: 6px 8px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--input); color: var(--text); font: 15px/1.4 var(--font-sans); } .activity-dates { display: flex; flex-direction: column; gap: 12px; } .activity-clear { align-self: flex-start; min-height: 32px; color: var(--accent); } -.activity-order { display: flex; justify-content: space-between; gap: 16px; color: var(--text-dim); font-size: 13px; padding: 14px 0; } +.activity-order { display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 13px; padding: 14px 0; } .activity-rows { list-style: none; padding: 0; margin: 0; } -.activity-day h2 { margin: 0; padding: 14px 0 10px; border-top: 1px solid var(--border-loud); font-size: 15px; line-height: 22px; font-weight: 500; } +.activity-day h2 { margin: 0; padding: 14px 0 10px; border-top: 1px solid var(--border-soft); font-size: 15px; line-height: 22px; font-weight: 500; } .activity-row { display: grid; align-items: center; grid-template-columns: 16px minmax(0, 1fr) auto 15px; gap: 14px; width: 100%; min-height: 68px; text-align: left; padding: 10px 8px; border-top: 1px solid var(--border); transition: background-color 140ms ease; } .activity-row:hover { background: var(--surface); } .activity-row[aria-current="true"] { background: color-mix(in oklch, var(--accent) 8%, var(--bg)); box-shadow: inset 1px 0 var(--accent); } -.activity-row-glyph { color: var(--text-dim); } -.activity-row.activity-tone-negative .activity-row-glyph { color: var(--outcome-failed); } -.activity-row.activity-tone-positive .activity-row-glyph { color: var(--outcome-merged); } +.activity-row-glyph { color: var(--text-tertiary); } +.activity-row.activity-tone-negative .activity-row-glyph { color: var(--danger); } +.activity-row.activity-tone-positive .activity-row-glyph { color: var(--success); } .activity-row.activity-tone-attention .activity-row-glyph { color: var(--accent); } .activity-row-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .activity-row-work, .activity-context { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.activity-work-key { color: var(--text-mid); } -.activity-context { color: var(--text-mid); } +.activity-work-key { color: var(--text-secondary); } +.activity-context { color: var(--text-secondary); } .activity-context strong { color: var(--text); font-weight: 500; } -.activity-row time { font: 12px/1.8 var(--font-mono); color: var(--text-mid); white-space: nowrap; } -.activity-row > svg:last-child { color: var(--text-dim); } +.activity-row time { font: 12px/1.8 var(--font-mono); color: var(--text-secondary); white-space: nowrap; } +.activity-row > svg:last-child { color: var(--text-tertiary); } .activity-new, .activity-older { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; min-height: 44px; margin-top: 12px; color: var(--text); } -.activity-new { position: sticky; top: 0; z-index: 1; border: 1px solid var(--border-loud); border-radius: 5px; background: var(--surface-2); color: var(--accent); } -.activity-detail { position: relative; flex: 0 0 360px; width: 360px; min-width: 0; overflow-y: auto; padding: 24px; border-left: 1px solid var(--border-loud); background: var(--surface); overflow-wrap: anywhere; scrollbar-color: var(--border-loud) transparent; } +.activity-new { position: sticky; top: 0; z-index: 1; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--surface-2); color: var(--accent); } +.activity-detail { position: relative; flex: 0 0 360px; width: 360px; min-width: 0; overflow-y: auto; padding: 24px; border-left: 1px solid var(--border); background: var(--surface); overflow-wrap: anywhere; scrollbar-color: var(--border-soft) transparent; } .activity-detail h2 { font-size: 21px; line-height: 1.4; font-weight: 600; margin: 0 28px 12px 0; } -.activity-close { position: absolute; right: 8px; top: 14px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--text-dim); } +.activity-close { position: absolute; right: 8px; top: 14px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--text-tertiary); } .activity-work-identity { padding-bottom: 18px; } .activity-work-identity p { margin: 8px 0 0; } -.activity-work-label { color: var(--text-mid); } +.activity-work-label { color: var(--text-secondary); } .activity-detail h3 { font-size: 16px; font-weight: 600; margin: 18px 0 10px; } -.activity-explanation, .activity-response, .activity-request, .activity-result { border-top: 1px solid var(--border-loud); padding: 18px 0; } +.activity-explanation, .activity-response, .activity-request, .activity-result { border-top: 1px solid var(--border-soft); padding: 18px 0; } .activity-explanation p { margin: 0; } -.activity-explanation p + p { margin-top: 8px; color: var(--text-mid); } +.activity-explanation p + p { margin-top: 8px; color: var(--text-secondary); } .activity-detail-context { font-weight: 500; white-space: pre-wrap; margin: 0 0 12px; } -.activity-explanation.activity-tone-negative .activity-detail-context { color: var(--outcome-failed); } +.activity-explanation.activity-tone-negative .activity-detail-context { color: var(--danger); } .activity-result .markdown-content { font: inherit; color: var(--text); } -.activity-result .markdown-content h2 { margin: 24px 0 10px; padding-top: 16px; border-top: 1px solid var(--border-loud); font-size: 16px; } +.activity-result .markdown-content h2 { margin: 24px 0 10px; padding-top: 16px; border-top: 1px solid var(--border-soft); font-size: 16px; } .activity-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.6 var(--font-mono); max-width: 100%; } -.activity-muted { color: var(--text-mid); font-size: 14px; } -.activity-metadata { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 16px; margin: 0; padding: 18px 0; border-top: 1px solid var(--border-loud); font-size: 13px; color: var(--text-mid); } +.activity-muted { color: var(--text-secondary); font-size: 14px; } +.activity-metadata { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 16px; margin: 0; padding: 18px 0; border-top: 1px solid var(--border-soft); font-size: 13px; color: var(--text-secondary); } .activity-metadata dd { margin: 0; } -.activity-detail details { border-top: 1px solid var(--border-loud); } +.activity-detail details { border-top: 1px solid var(--border-soft); } .activity-detail summary { display: list-item; min-height: 44px; padding: 12px 0; cursor: pointer; } .activity-technical dl { margin: 0 0 16px; font-size: 13px; } -.activity-technical dt { margin-top: 10px; color: var(--text-dim); } +.activity-technical dt { margin-top: 10px; color: var(--text-tertiary); } .activity-technical dd { margin: 4px 0 0; font-family: var(--font-mono); } .activity-reply-values { margin: 12px 0; white-space: pre-wrap; } -.activity-reply-values dt { color: var(--text-mid); text-transform: capitalize; font-size: 13px; } +.activity-reply-values dt { color: var(--text-secondary); text-transform: capitalize; font-size: 13px; } .activity-reply-values dd { margin: 4px 0 12px; } -.activity-destinations { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding-top: 20px; border-top: 1px solid var(--border-loud); } +.activity-destinations { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding-top: 20px; border-top: 1px solid var(--border-soft); } .activity-destinations a { display: inline-flex; min-height: 36px; gap: 8px; align-items: center; color: var(--accent); text-decoration: none; } .activity-destinations .activity-primary { border: 1px solid var(--accent); border-radius: 5px; padding: 6px 12px; color: var(--text); } .activity-destinations a:hover { text-decoration: underline; text-underline-offset: 4px; } -.activity-source { margin: 24px 0 0; color: var(--text-mid); font-size: 12px; } +.activity-source { margin: 24px 0 0; color: var(--text-secondary); font-size: 12px; } .activity-message { padding: 24px 0; } .activity-message h2 { font-size: 18px; } .activity-message button, .activity-detail p button { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } .activity-page :is(button, input, select, a, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } .activity-page button:disabled { opacity: .55; cursor: wait; } .activity-connection button:hover, .activity-refresh:hover, .activity-filter-trigger:hover { background-color: var(--surface-2); } -.activity-page ::selection { background: var(--border-loud); color: var(--text); } @media (max-width: 1199px) { .activity-selected .activity-list { display: none; } .activity-detail { flex: 1; width: 100%; border-left: 0; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 85a2ee6f0..a1e1b8a94 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -750,6 +750,7 @@ export type Billing = 'subscription' | 'api_key' export interface PersonalSettings { timezone: string + theme: 'system' | 'light' | 'dark' gateParkDestinationId: string | null isTagRequired: boolean } @@ -768,6 +769,7 @@ export interface InstallationSettings { export interface UpdatePersonalSettingsRequest { timezone?: string + theme?: PersonalSettings['theme'] gateParkDestinationId?: string | null isTagRequired?: boolean } diff --git a/frontend/src/apps/software_factory/WorkItemPage.tsx b/frontend/src/apps/software_factory/WorkItemPage.tsx index 712125d1e..7036ef21f 100644 --- a/frontend/src/apps/software_factory/WorkItemPage.tsx +++ b/frontend/src/apps/software_factory/WorkItemPage.tsx @@ -236,7 +236,7 @@ function InfoPanel({ pr {workItem.prNumber == null ? ( - not opened + not opened ) : workItem.links.pr ? ( #{workItem.prNumber} @@ -762,12 +762,12 @@ function TranscriptBody({ ) } return ( -
+
- + — - + no agent calls in this run
diff --git a/frontend/src/apps/software_factory/projects/projects.css b/frontend/src/apps/software_factory/projects/projects.css index fb373bd4d..d2a5be1f1 100644 --- a/frontend/src/apps/software_factory/projects/projects.css +++ b/frontend/src/apps/software_factory/projects/projects.css @@ -1,4 +1,4 @@ -.pj-refresh-error { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; color: var(--outcome-failed); } +.pj-refresh-error { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; color: var(--danger); } .pj-refresh-error p { margin: 0; } .page-projects .page-shell-body { padding: var(--page-padding); } @@ -11,69 +11,69 @@ .pj-project-head h2 { margin: 0; min-width: 0; } .pj-disclosure { display: flex; align-items: center; gap: 16px; min-width: 0; padding: 4px; text-align: left; font-size: 32px; font-weight: 500; letter-spacing: -0.025em; line-height: 1.25; } .pj-disclosure span { overflow-wrap: anywhere; } -.pj-disclosure svg { flex-shrink: 0; color: var(--text-dim); transition: transform 140ms ease-out; } +.pj-disclosure svg { flex-shrink: 0; color: var(--text-tertiary); transition: transform 140ms ease-out; } .pj-disclosure[aria-expanded="false"] svg { transform: rotate(-90deg); } -.pj-icon-button { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 4px; color: var(--text-dim); cursor: pointer; } +.pj-icon-button { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 4px; color: var(--text-tertiary); cursor: pointer; } .pj-icon-button:hover { background: var(--surface-2); color: var(--text); } .pj-actions { position: relative; flex-shrink: 0; } .pj-actions summary { list-style: none; } .pj-actions summary::-webkit-details-marker { display: none; } .pj-actions[open] summary { background: var(--surface-2); } -.pj-action-list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 10; width: 192px; padding: 6px; border: 1px solid var(--border-loud); border-radius: 6px; background: var(--surface-3); } -.pj-action-list button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px; border-radius: 3px; text-align: left; font-size: 14px; color: var(--text-mid); } +.pj-action-list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 10; width: 192px; padding: 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-3); } +.pj-action-list button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px; border-radius: 3px; text-align: left; font-size: 14px; color: var(--text-secondary); } .pj-action-list button:hover { color: var(--text); background: var(--surface-2); } -.pj-action-list .pj-danger { color: var(--outcome-failed); border-top: 1px solid var(--border); border-radius: 0; margin-top: 4px; } -.pj-repos { border-top: 1px solid var(--border-loud); } +.pj-action-list .pj-danger { color: var(--danger); border-top: 1px solid var(--border); border-radius: 0; margin-top: 4px; } +.pj-repos { border-top: 1px solid var(--border-soft); } .pj-repo { border-bottom: 1px solid var(--border-soft); } .pj-repo-row { display: grid; grid-template-columns: var(--pj-repo-column) minmax(0, 1fr) auto; gap: 28px; align-items: center; width: 100%; min-height: 56px; padding: 16px 4px; text-align: left; } .pj-repo-row:hover { background: var(--surface); } .pj-repo-row[aria-expanded="true"] .pj-repo-name { color: var(--accent); } .pj-repo-name { min-width: 0; font-family: var(--font-mono); font-size: 14px; overflow-wrap: anywhere; } -.pj-repo-org { color: var(--text-faint); } -.pj-repo-purpose { font-size: 15px; line-height: 1.5; color: var(--text-mid); overflow-wrap: anywhere; } -.pj-profile-status { display: inline-flex; align-items: center; gap: 8px; justify-self: end; color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } -.pj-profile-status::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--text-faint); } -.pj-profile-status[data-state="ready"]::before { background: var(--outcome-merged); } -.pj-profile-status[data-state="running"]::before { background: var(--bucket-run); } -.pj-profile-status[data-state="failed"] { color: var(--outcome-failed); } +.pj-repo-org { color: var(--text-tertiary); } +.pj-repo-purpose { font-size: 15px; line-height: 1.5; color: var(--text-secondary); overflow-wrap: anywhere; } +.pj-profile-status { display: inline-flex; align-items: center; gap: 8px; justify-self: end; color: var(--text-tertiary); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } +.pj-profile-status::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--text-tertiary); } +.pj-profile-status[data-state="ready"]::before { background: var(--success); } +.pj-profile-status[data-state="running"]::before { background: var(--info); } +.pj-profile-status[data-state="failed"] { color: var(--danger); } .pj-profile-status[data-state="failed"]::before { background: currentColor; } .pj-repo-details { display: grid; grid-template-columns: var(--pj-repo-column) minmax(0, 1fr); gap: 28px; padding: 16px 4px 36px; } .pj-repo-rail { display: flex; flex-direction: column; align-items: flex-start; gap: 20px; } .pj-profile-facts { display: grid; gap: 16px; margin: 0; } -.pj-profile-facts dt, .pj-profile-line h3 { font-family: var(--font-mono); font-size: 11px; font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-faint); } -.pj-profile-facts dd { margin: 4px 0 0; font-family: var(--font-mono); font-size: 13px; color: var(--text-mid); } +.pj-profile-facts dt, .pj-profile-line h3 { font-family: var(--font-mono); font-size: 11px; font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-tertiary); } +.pj-profile-facts dd { margin: 4px 0 0; font-family: var(--font-mono); font-size: 13px; color: var(--text-secondary); } .pj-repo-actions { display: flex; flex-wrap: wrap; gap: 8px; } .pj-profile-content { display: grid; align-content: start; gap: 28px; min-width: 0; } .pj-profile-content p { margin: 0; } -.pj-profile-summary { max-width: 72ch; font-size: 15px; line-height: 1.7; color: var(--text-mid); overflow-wrap: anywhere; } +.pj-profile-summary { max-width: 72ch; font-size: 15px; line-height: 1.7; color: var(--text-secondary); overflow-wrap: anywhere; } .pj-profile-line { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 20px; align-items: baseline; } .pj-profile-line h3 { margin: 0; } -.pj-profile-line p { font-size: 13px; line-height: 1.8; color: var(--text-mid); overflow-wrap: anywhere; } +.pj-profile-line p { font-size: 13px; line-height: 1.8; color: var(--text-secondary); overflow-wrap: anywhere; } .pj-profile-commands { display: grid; gap: 8px; list-style: none; margin: 0; padding: 0 0 0 16px; border-left: 1px solid var(--border); } .pj-profile-commands li { display: flex; gap: 12px; min-width: 0; } -.pj-profile-commands li::before { content: '$'; color: var(--text-faint); font-family: var(--font-mono); font-size: 13px; } -.pj-profile-commands code { font-family: var(--font-mono); font-size: 13px; color: var(--text-mid); overflow-wrap: anywhere; white-space: pre-wrap; } -.pj-text-button { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; margin-top: 4px; font-size: 13px; color: var(--text-dim); } +.pj-profile-commands li::before { content: '$'; color: var(--text-tertiary); font-family: var(--font-mono); font-size: 13px; } +.pj-profile-commands code { font-family: var(--font-mono); font-size: 13px; color: var(--text-secondary); overflow-wrap: anywhere; white-space: pre-wrap; } +.pj-text-button { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; margin-top: 4px; font-size: 13px; color: var(--text-tertiary); } .pj-text-button:hover { color: var(--text); } .pj-text-button[aria-expanded="true"] svg { transform: rotate(180deg); } -.pj-muted { color: var(--text-faint); } -.pj-error { color: var(--outcome-failed); font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; } -.pj-empty-project { border-top: 1px solid var(--border); padding: 24px 4px; color: var(--text-mid); } +.pj-muted { color: var(--text-tertiary); } +.pj-error { color: var(--danger); font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; } +.pj-empty-project { border-top: 1px solid var(--border); padding: 24px 4px; color: var(--text-secondary); } .pj-empty-project p { margin: 0 0 16px; } .pj-purpose-form { display: grid; gap: 12px; } .pj-form-actions { display: flex; flex-wrap: wrap; gap: 8px; } -.pj-field { display: grid; gap: 8px; color: var(--text-mid); font-size: 14px; } -.pj-dialog { width: min(520px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 0; margin: auto; border: 1px solid var(--border-loud); border-radius: 6px; background: var(--surface); color: var(--text); overflow: auto; } -.pj-dialog::backdrop { background: rgb(3 5 9 / 0.75); } +.pj-field { display: grid; gap: 8px; color: var(--text-secondary); font-size: 14px; } +.pj-dialog { width: min(520px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 0; margin: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); overflow: auto; } +.pj-dialog::backdrop { background: var(--scrim); } .pj-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--border); } .pj-dialog-head h2 { margin: 0; font-size: 18px; font-weight: 500; overflow-wrap: anywhere; } .pj-dialog-head .pj-icon-button { flex-shrink: 0; } .pj-dialog-body { display: grid; gap: 20px; padding: 24px; } -.pj-dialog-body p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text-mid); } -.pj-dialog-body .pj-error { color: var(--outcome-failed); } +.pj-dialog-body p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text-secondary); } +.pj-dialog-body .pj-error { color: var(--danger); } .pj-dialog-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 24px; background: var(--surface-2); border-top: 1px solid var(--border); } .pj-repo-choices { min-width: 0; max-height: 280px; overflow: auto; border: 0; padding: 0; margin: 0; } -.pj-repo-choices legend { color: var(--text-dim); font-size: 14px; margin-bottom: 12px; } +.pj-repo-choices legend { color: var(--text-tertiary); font-size: 14px; margin-bottom: 12px; } .pj-repo-choice { display: flex; align-items: center; gap: 12px; padding: 12px 8px; border-radius: 3px; cursor: pointer; } .pj-repo-choice:hover, .pj-repo-choice:has(:checked) { background: var(--surface-2); } .pj-repo-choice input { flex-shrink: 0; accent-color: var(--accent); } diff --git a/frontend/src/chat.css b/frontend/src/chat.css index 20c542998..a73a86319 100644 --- a/frontend/src/chat.css +++ b/frontend/src/chat.css @@ -1,5 +1,4 @@ .page-chat { - --chat-accent: #83d8b8; --chat-gutter: clamp(20px, 3vw, 40px); } @@ -14,61 +13,61 @@ .chat-list { background: var(--surface); border-right: 1px solid var(--border); } .chat-thread { grid-column: 2; } .chat-list-head { display: flex; align-items: center; gap: 8px; padding: 12px; flex-shrink: 0; } -.chat-search { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0 10px; border: 1px solid var(--border-loud); border-radius: 6px; color: var(--text-dim); } +.chat-search { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--input); color: var(--text-tertiary); } .chat-search svg { flex-shrink: 0; } .chat-search input { width: 100%; min-width: 0; height: 36px; border: 0; background: transparent; color: var(--text); font: inherit; font-size: 13px; } -.chat-search input::placeholder { color: var(--text-dim); } -.chat-search:focus-within { outline: 2px solid var(--chat-accent); outline-offset: 2px; } +.chat-search input::placeholder { color: var(--text-tertiary); } +.chat-search:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } .page-chat .chat-search input:focus-visible { outline: none; } .chat-list-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px; } -.chat-day { display: flex; align-items: center; gap: 12px; margin: 24px 10px 8px; color: var(--text-dim); font-size: 12px; font-weight: 500; } +.chat-day { display: flex; align-items: center; gap: 12px; margin: 24px 10px 8px; color: var(--text-tertiary); font-size: 12px; font-weight: 500; } .chat-day::after { content: ''; flex: 1; height: 1px; background: var(--border); } .chat-list-row { position: relative; border: 1px solid transparent; border-radius: 6px; } .chat-list-row:hover { background: var(--surface-2); } -.chat-list-row[data-selected="true"] { background: var(--surface-3); border-color: var(--border-loud); } -.chat-list-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 8px; padding: 10px 12px; color: var(--text-mid); text-decoration: none; border-radius: 6px; } +.chat-list-row[data-selected="true"] { background: var(--surface-3); border-color: var(--border); } +.chat-list-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 8px; padding: 10px 12px; color: var(--text-secondary); text-decoration: none; border-radius: 6px; } .chat-list-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } .chat-list-item[aria-current="page"] .chat-list-title { color: var(--text); } .chat-list-item time { font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; align-self: center; } -.chat-list-status { display: flex; align-items: center; gap: 8px; grid-column: 1 / -1; padding-right: 30px; color: var(--text-dim); font-size: 12px; } +.chat-list-status { display: flex; align-items: center; gap: 8px; grid-column: 1 / -1; padding-right: 30px; color: var(--text-tertiary); font-size: 12px; } .chat-list-row > .chat-pin { position: absolute; right: 2px; bottom: 0; width: 30px; min-height: 30px; opacity: 0; } .chat-list-row:hover > .chat-pin, .chat-list-row:focus-within > .chat-pin, .chat-list-row > .chat-pin[aria-pressed="true"] { opacity: 1; } -.chat-button, .chat-icon-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 36px; flex-shrink: 0; border-radius: 6px; color: var(--text-mid); text-decoration: none; font: inherit; line-height: 1.25; transition: background-color 150ms ease-out, color 150ms ease-out; } -.chat-button { padding: 7px 12px; border: 1px solid var(--border-loud); background: var(--surface); font-size: 13px; } +.chat-button, .chat-icon-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 36px; flex-shrink: 0; border-radius: 6px; color: var(--text-secondary); text-decoration: none; font: inherit; line-height: 1.25; transition: background-color 150ms ease-out, color 150ms ease-out; } +.chat-button { padding: 7px 12px; border: 1px solid var(--border-strong); background: var(--surface); font-size: 13px; } .chat-icon-button { width: 36px; padding: 8px; } .chat-button:hover, .chat-icon-button:hover { background: var(--surface-3); color: var(--text); } .chat-icon-button:active, .chat-button:active { background: var(--surface-2); } .chat-button:disabled, .chat-icon-button:disabled { opacity: .5; cursor: not-allowed; } -.chat-new, .chat-send:not(:disabled) { background: var(--chat-accent); color: var(--bg); } -.chat-new:hover, .chat-send:not(:disabled):hover { background: color-mix(in srgb, var(--chat-accent) 85%, white); color: var(--bg); } -.chat-send:disabled { background: var(--surface-3); color: var(--text-dim); } -.chat-pin[aria-pressed="true"] { color: var(--chat-accent); } -.page-chat :is(button, a, summary, textarea, input):focus-visible { outline: 2px solid var(--chat-accent); outline-offset: 2px; } -.page-chat ::selection { background: color-mix(in srgb, var(--chat-accent) 28%, var(--surface)); color: var(--text); } -.page-chat :is(textarea, input) { caret-color: var(--chat-accent); } -.page-chat :is(.chat-list-body, .chat-messages, pre, textarea) { scrollbar-width: thin; scrollbar-color: var(--border-loud) transparent; } +.chat-new, .chat-send:not(:disabled) { background: var(--accent); color: var(--on-accent); } +.chat-new:hover, .chat-send:not(:disabled):hover { background: color-mix(in oklch, var(--accent) 92%, var(--on-accent)); color: var(--on-accent); } + +.chat-send:disabled { background: var(--surface-3); color: var(--text-tertiary); } +.chat-pin[aria-pressed="true"] { color: var(--text-tertiary); } +.page-chat :is(button, a, summary, textarea, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.page-chat :is(textarea, input) { caret-color: var(--accent); } +.page-chat :is(.chat-list-body, .chat-messages, pre, textarea) { scrollbar-width: thin; scrollbar-color: var(--border-soft) transparent; } .chat-back { display: none; } .chat-thread-head { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px var(--chat-gutter); border-bottom: 1px solid var(--border); flex-shrink: 0; } .chat-thread-title { min-width: 0; flex: 1; } .chat-thread-title h1 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; line-height: 1.5; font-weight: 500; } -.chat-thread-title p { margin: 2px 0 0; color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; line-height: 1.5; } +.chat-thread-title p { margin: 2px 0 0; color: var(--text-tertiary); font-family: var(--font-mono); font-size: 11px; line-height: 1.5; } .chat-thread-title p > span { margin-inline: 5px; } -.chat-connection { color: var(--text-mid); font-size: 12px; } +.chat-connection { color: var(--text-secondary); font-size: 12px; } .chat-messages { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; } .chat-messages[hidden] { display: none; } .chat-messages-content { display: flex; flex-direction: column; justify-content: flex-end; min-height: 100%; padding: 24px var(--chat-gutter) 12px; } .chat-turn + .chat-turn { margin-top: 28px; } .chat-user { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; } -.chat-message-meta { display: flex; align-items: center; gap: 10px; color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; } +.chat-message-meta { display: flex; align-items: center; gap: 10px; color: var(--text-tertiary); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; } .chat-message-meta time { font-family: var(--font-mono); letter-spacing: 0; font-variant-numeric: tabular-nums; } -.chat-agent-meta { color: var(--chat-accent); } -.chat-agent-meta time { color: var(--text-dim); } -.chat-user-body { max-width: 75ch; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-mid); } +.chat-agent-meta { color: var(--text-tertiary); } +.chat-agent-meta time { color: var(--text-tertiary); } +.chat-user-body { max-width: 75ch; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-secondary); } .chat-user-transcript { font-style: italic; } -.chat-user.is-internal .chat-message-meta { color: var(--chat-accent); } -.chat-user > a { color: var(--chat-accent); text-underline-offset: 3px; overflow-wrap: anywhere; } -.chat-queued { font-size: 12px; color: var(--text-mid); border: 1px solid var(--border-loud); border-radius: 4px; padding: 3px 8px; } +.chat-user.is-internal .chat-message-meta { color: var(--text-tertiary); } +.chat-user > a { color: var(--accent); text-underline-offset: 3px; overflow-wrap: anywhere; } +.chat-queued { font-size: 12px; color: var(--text-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 3px 8px; } .chat-reply { display: flex; flex-direction: column; gap: 12px; margin-top: 26px; min-width: 0; line-height: 1.65; } .chat-reply:empty { display: none; } .chat-reply .markdown-content { max-width: 75ch; min-width: 0; overflow-wrap: anywhere; } @@ -77,13 +76,13 @@ .chat-reply .markdown-content pre { max-width: 100%; overflow: auto; } .chat-reply .markdown-content table { display: block; max-width: 100%; overflow: auto; overflow-wrap: normal; } .chat-reply .markdown-content img { max-width: 100%; } -.chat-reply .markdown-content a { color: var(--chat-accent); text-underline-offset: 3px; } -.chat-waiting { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 13px; color: var(--text-mid); } -.chat-activity-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--chat-accent); flex-shrink: 0; animation: chat-pulse 1.8s ease-in-out infinite; } +.chat-reply .markdown-content a { color: var(--accent); text-underline-offset: 3px; } +.chat-waiting { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 13px; color: var(--text-secondary); } +.chat-activity-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--info); flex-shrink: 0; animation: chat-pulse 1.8s ease-in-out infinite; } @keyframes chat-pulse { 50% { opacity: .35; } } -.chat-notice { color: var(--text-mid); padding: 16px 12px; font-size: 14px; } +.chat-notice { color: var(--text-secondary); padding: 16px 12px; font-size: 14px; } .chat-notice p { margin-top: 0; } -.chat-tool { border: 1px solid var(--border-loud); border-radius: 6px; overflow: hidden; } +.chat-tool { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } .chat-tool summary { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; list-style: none; background: var(--surface); } .chat-tool summary::-webkit-details-marker { display: none; } .chat-tool summary:hover { background: var(--surface-2); } @@ -91,44 +90,44 @@ .chat-tool-chevron { flex-shrink: 0; transition: transform 150ms ease-out; } .chat-tool[open] .chat-tool-chevron { transform: rotate(90deg); } .chat-tool-label { min-width: 0; overflow-wrap: anywhere; line-height: 1.4; } -.chat-tool-status { display: flex; align-items: center; gap: 5px; margin-left: auto; flex-shrink: 0; color: var(--text-mid); font-size: 13px; } -.chat-tool-status.is-completed { color: var(--outcome-merged); } -.chat-tool-status.is-failed { color: var(--outcome-failed); } +.chat-tool-status { display: flex; align-items: center; gap: 5px; margin-left: auto; flex-shrink: 0; color: var(--text-secondary); font-size: 13px; } +.chat-tool-status.is-completed { color: var(--success); } +.chat-tool-status.is-failed { color: var(--danger); } .chat-tool-details { padding: 14px 16px; border-top: 1px solid var(--border); } .chat-tool-details h3 { margin: 0 0 6px; font-size: 13px; font-weight: 500; } .chat-tool-details pre { margin: 0 0 14px; padding: 12px; background: var(--surface); border-radius: 4px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; } .chat-tool-details pre:last-child, .chat-tool-details p:last-child { margin-bottom: 0; } -.chat-tool-details p { color: var(--text-mid); font-size: 13px; } +.chat-tool-details p { color: var(--text-secondary); font-size: 13px; } .chat-plan { margin: 0; padding-left: 22px; } .chat-plan li { padding: 3px 0; } -.chat-plan-status { margin-left: 12px; font-size: 12px; color: var(--text-mid); } -.chat-plan li[data-state="completed"] .chat-plan-status { color: var(--outcome-merged); } +.chat-plan-status { margin-left: 12px; font-size: 12px; color: var(--text-secondary); } +.chat-plan li[data-state="completed"] .chat-plan-status { color: var(--success); } .chat-interrupted { font-size: 14px; } -.chat-interrupted strong { font-weight: 500; color: var(--accent-amber); } -.chat-interrupted p { margin: 4px 0 12px; color: var(--text-mid); } -.chat-interrupted > span { display: block; margin-top: 10px; color: var(--text-mid); font-size: 12px; } +.chat-interrupted strong { font-weight: 500; color: var(--warning); } +.chat-interrupted p { margin: 4px 0 12px; color: var(--text-secondary); } +.chat-interrupted > span { display: block; margin-top: 10px; color: var(--text-secondary); font-size: 12px; } .chat-jump { display: flex; justify-content: center; padding: 6px 0; } -.chat-error { margin: 0 var(--chat-gutter) 12px; color: var(--outcome-failed); font-size: 14px; } +.chat-error { margin: 0 var(--chat-gutter) 12px; color: var(--danger); font-size: 14px; } .chat-error p { margin: 0 0 8px; } -.chat-pin-error { display: flex; align-items: center; gap: 12px; grid-column: 1 / -1; grid-row: 1; padding: 10px 16px; color: var(--outcome-failed); border-bottom: 1px solid var(--border); font-size: 14px; } +.chat-pin-error { display: flex; align-items: center; gap: 12px; grid-column: 1 / -1; grid-row: 1; padding: 10px 16px; color: var(--danger); border-bottom: 1px solid var(--border); font-size: 14px; } .chat-composer { padding: 10px var(--chat-gutter) 18px; flex-shrink: 0; } -.chat-composer-controls { display: flex; align-items: flex-end; gap: 8px; padding: 8px; border: 1px solid var(--border-loud); border-radius: 10px; background: var(--surface); } -.chat-composer-controls:focus-within { border-color: var(--chat-accent); } +.chat-composer-controls { display: flex; align-items: flex-end; gap: 8px; padding: 8px; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--surface); } +.chat-composer-controls:focus-within { border-color: var(--accent); } .chat-composer textarea { flex: 1; width: 100%; min-width: 0; min-height: 36px; max-height: 180px; field-sizing: content; resize: vertical; padding: 6px 8px; border: 0; background: transparent; color: var(--text); font: inherit; line-height: 1.6; } -.chat-composer textarea::placeholder { color: var(--text-dim); } +.chat-composer textarea::placeholder { color: var(--text-tertiary); } .page-chat .chat-composer textarea:focus-visible { outline: none; } .chat-composer-actions { display: flex; align-items: center; gap: 8px; } -.chat-composer > p { margin: 7px 0 0; color: var(--text-dim); font-size: 11px; text-align: right; } +.chat-composer > p { margin: 7px 0 0; color: var(--text-tertiary); font-size: 11px; text-align: right; } .chat-thread.is-new { overflow-y: auto; } .chat-thread.is-new .chat-thread-head { border-bottom: 0; } .chat-intro { margin: clamp(16px, 5vh, 56px) var(--chat-gutter) 12px; max-width: 65ch; } .chat-intro h1 { margin: 0 0 12px; font-size: clamp(24px, 2.5vw, 30px); line-height: 1.2; font-weight: 500; letter-spacing: -.02em; text-wrap: balance; } -.chat-intro p { margin: 0 0 8px; color: var(--text-mid); font-size: 15px; line-height: 1.65; } +.chat-intro p { margin: 0 0 8px; color: var(--text-secondary); font-size: 15px; line-height: 1.65; } .chat-starters { margin: 0 var(--chat-gutter) 32px; } -.chat-starters > button { display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; min-height: 60px; padding: 16px 2px; border-top: 1px solid var(--border); text-align: left; color: var(--text-mid); font-size: 14px; line-height: 1.6; } -.chat-starters > button:hover { color: var(--chat-accent); } +.chat-starters > button { display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; min-height: 60px; padding: 16px 2px; border-top: 1px solid var(--border); text-align: left; color: var(--text-secondary); font-size: 14px; line-height: 1.6; } +.chat-starters > button:hover { color: var(--accent); } .chat-starters > button svg { flex-shrink: 0; } -.chat-apps { border-top: 1px solid var(--border); margin: 0; padding-top: 16px; color: var(--text-dim); font-size: 12px; line-height: 1.65; } +.chat-apps { border-top: 1px solid var(--border); margin: 0; padding-top: 16px; color: var(--text-tertiary); font-size: 12px; line-height: 1.65; } @media (max-width: 1100px) { .page-chat > .page-shell-body { grid-template-columns: 260px minmax(0, 1fr); } diff --git a/frontend/src/command-center.css b/frontend/src/command-center.css index f422bd35b..005026100 100644 --- a/frontend/src/command-center.css +++ b/frontend/src/command-center.css @@ -32,13 +32,13 @@ overflow-wrap: anywhere; } -.command-breadcrumb > span { color: var(--text-dim); } +.command-breadcrumb > span { color: var(--text-tertiary); } .command-breadcrumb strong { font-weight: 500; } -.command-app-settings { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px; margin-left: 4px; border-radius: 5px; color: var(--text-mid); text-decoration: none; } -.command-app-settings:visited { color: var(--text-mid); } +.command-app-settings { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px; margin-left: 4px; border-radius: 5px; color: var(--text-secondary); text-decoration: none; } +.command-app-settings:visited { color: var(--text-secondary); } .command-app-settings:hover, .command-app-settings[aria-current="page"] { color: var(--text); background: var(--surface-3); } .command-utilities { display: flex; align-items: center; gap: 20px; margin-left: auto; flex-shrink: 0; } -.command-timezone { color: var(--text-mid); } +.command-timezone { color: var(--text-secondary); } .app-main { display: flex; flex: 1; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } .sidebar { @@ -53,47 +53,46 @@ } .sidebar-desktop { position: fixed; inset: 0 auto 0 0; z-index: 15; } -.sidebar-brand { display: flex; align-items: center; gap: 10px; min-height: 44px; margin: 0 10px 24px; font-size: 23px; font-weight: 600; letter-spacing: -0.04em; } +.sidebar-brand { display: flex; align-items: center; min-height: 48px; margin: 0 4px 24px; } .sidebar-brand, .sidebar-brand:visited { color: var(--text); text-decoration: none; } -.sidebar-brand .brand-glyph { width: 24px; height: 24px; } .sidebar-work { display: flex; flex-direction: column; gap: 3px; } -.sidebar-link { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 9px 10px; border: 1px solid transparent; border-radius: 5px; color: var(--text-mid); text-decoration: none; font-size: 15px; text-align: left; } -.sidebar-link:visited { color: var(--text-mid); } +.sidebar-link { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 9px 10px; border: 1px solid transparent; border-radius: 5px; color: var(--text-secondary); text-decoration: none; font-size: 15px; text-align: left; } +.sidebar-link:visited { color: var(--text-secondary); } .sidebar-link:hover { background: var(--surface-2); color: var(--text); } -.sidebar-link[aria-current="page"] { color: var(--text); background: var(--surface-3); border-color: var(--border-loud); } +.sidebar-link[aria-current="page"] { color: var(--text); background: var(--surface-3); border-color: var(--border); } .sidebar-link[aria-current="page"] > svg { color: var(--text); } .sidebar-link > svg { flex-shrink: 0; } .sidebar-link > span { min-width: 0; overflow-wrap: anywhere; } .sidebar-apps { display: flex; flex: 1; flex-direction: column; min-height: 0; margin-top: 16px; } -.sidebar-group-title { display: flex; align-items: baseline; justify-content: space-between; padding: 0 10px 8px; color: var(--text-dim); font-size: 13px; } -.sidebar-search { display: flex; flex: 0 0 auto; align-items: center; min-height: 40px; gap: 8px; padding: 0 10px; margin-bottom: 8px; border: 1px solid var(--border-loud); border-radius: 5px; background: var(--bg); color: var(--text-dim); } -.sidebar-search:focus-within { outline: 2px solid var(--text-mid); outline-offset: 2px; } +.sidebar-group-title { display: flex; align-items: baseline; justify-content: space-between; padding: 0 10px 8px; color: var(--text-tertiary); font-size: 13px; } +.sidebar-search { display: flex; flex: 0 0 auto; align-items: center; min-height: 40px; gap: 8px; padding: 0 10px; margin-bottom: 8px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--input); color: var(--text-tertiary); } +.sidebar-search:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } .sidebar-search > svg { flex-shrink: 0; } .sidebar-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); font: inherit; padding: 8px 0; } -.sidebar-search input::placeholder { color: var(--text-dim); } -.sidebar-app-list { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-loud) transparent; } -.sidebar-message { margin: 12px 10px; color: var(--text-dim); font-size: 13px; overflow-wrap: anywhere; } +.sidebar-search input::placeholder { color: var(--text-tertiary); } +.sidebar-app-list { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-soft) transparent; } +.sidebar-message { margin: 12px 10px; color: var(--text-tertiary); font-size: 13px; overflow-wrap: anywhere; } .sidebar-retry { text-decoration: underline; min-height: 44px; color: var(--text); } .sidebar-footer { padding-top: 12px; margin-top: 12px; border-top: 1px solid var(--border); } -.sidebar-account { flex: 0 0 auto; padding: 16px 10px 0; font-size: 13px; color: var(--text-mid); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.navigation-toggle, .navigation-close { display: none; align-items: center; justify-content: center; width: 44px; height: 44px; flex-shrink: 0; color: var(--text-mid); border-radius: 5px; } +.sidebar-account { flex: 0 0 auto; padding: 16px 10px 0; font-size: 13px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.navigation-toggle, .navigation-close { display: none; align-items: center; justify-content: center; width: 44px; height: 44px; flex-shrink: 0; color: var(--text-secondary); border-radius: 5px; } .sidebar-drawer { inset: 0 auto 0 0; width: min(300px, calc(100vw - 44px)); height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0; background: var(--surface); } .sidebar-drawer .sidebar { width: 100%; } -.sidebar-drawer::backdrop { background: rgb(0 0 0 / 60%); } +.sidebar-drawer::backdrop { background: var(--scrim); } .command-app-navigation { display: flex; flex: 0 0 auto; gap: 20px; padding: 0 var(--page-gutter); border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: thin; } -.command-tab { display: inline-flex; align-items: center; min-height: 46px; padding: 8px 0; color: var(--text-dim); border-bottom: 2px solid transparent; text-decoration: none; text-transform: capitalize; white-space: nowrap; } +.command-tab { display: inline-flex; align-items: center; min-height: 46px; padding: 8px 0; color: var(--text-tertiary); border-bottom: 2px solid transparent; text-decoration: none; text-transform: capitalize; white-space: nowrap; } .command-tab:hover, .command-tab[aria-current="page"] { color: var(--text); } -.command-tab[aria-current="page"] { border-bottom-color: var(--text); } +.command-tab[aria-current="page"] { border-bottom-color: var(--accent); } .skip-navigation { position: fixed; top: 8px; left: 8px; z-index: 100; transform: translateY(-200%); padding: 12px; background: var(--surface-3); color: var(--text); } .skip-navigation:focus { transform: none; } -:is(.sidebar, .command-header, .command-app-navigation) :is(a, button, input):focus-visible { outline: 2px solid var(--text); outline-offset: 3px; } +:is(.sidebar, .command-header, .command-app-navigation) :is(a, button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } .sidebar-search input:focus-visible { outline: none; } .command-center :is(.review-question legend, .review-option, .review-note, .review-btn, .ins-needs-body) { font-family: var(--font-sans); font-size: 15px; } .command-center :is(.review-artifact-title, .review-helper, .review-error, .review-recommended) { font-family: var(--font-sans); font-size: 13px; } .command-center .review-option { min-height: 44px; } .command-center .subject-run { font-family: var(--font-sans); } -.command-center :is(.wic-op, .wic-call)[role="button"]:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; } +.command-center :is(.wic-op, .wic-call)[role="button"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } @media (max-width: 1024px) { :root { --page-gutter: 24px; } diff --git a/frontend/src/components/AuthedApp.tsx b/frontend/src/components/AuthedApp.tsx index 36ea93dda..41825cc0c 100644 --- a/frontend/src/components/AuthedApp.tsx +++ b/frontend/src/components/AuthedApp.tsx @@ -27,7 +27,7 @@ function QueryCacheMount({ account }: { account: Account }) { ) return ( - + diff --git a/frontend/src/components/ChatChannels.test.tsx b/frontend/src/components/ChatChannels.test.tsx index bbb1631bb..c8bee98ec 100644 --- a/frontend/src/components/ChatChannels.test.tsx +++ b/frontend/src/components/ChatChannels.test.tsx @@ -4,6 +4,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { Router } from 'wouter' +import { UserPreferencesProvider } from '../lib/preferences' import { SettingsPages } from './SettingsPages' const account = { id: 'operator', username: 'ana@example.com', isDefault: true } @@ -12,9 +13,9 @@ let channels = ['whatsapp'] function mount(path: string, appName?: string) { window.history.replaceState(null, '', path) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( + render( - ) + ) } beforeEach(() => { @@ -28,7 +29,7 @@ beforeEach(() => { defaultHarness: 'claude', defaultModel: '', defaultBilling: 'subscription', defaultEffort: '', defaultTimeout: 1800, fastMode: false, } - if (url === '/api/settings/personal') response = { timezone: 'UTC', gateParkDestinationId: null, isTagRequired: false } + if (url === '/api/settings/personal') response = { timezone: 'UTC', theme: 'system', gateParkDestinationId: null, isTagRequired: false } if (url === '/api/settings/apps') response = { allowedEfforts: [], channels, apps: [{ name: 'chat', description: 'Live agent conversations.', icon: 'messages-square', diff --git a/frontend/src/components/SettingsPages.test.tsx b/frontend/src/components/SettingsPages.test.tsx index 85a45b4e6..94e024209 100644 --- a/frontend/src/components/SettingsPages.test.tsx +++ b/frontend/src/components/SettingsPages.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { App } from '../App' import { api } from '../api/client' +import { UserPreferencesProvider } from '../lib/preferences' import { SETTINGS_FIELDS } from './settings' import type { AgentSetting, @@ -145,6 +146,7 @@ const providerDirectory = [ const patched: Record[] = [] const personalPatched: Record[] = [] +let rejectPersonalPatch = false const appSettings: AppsSettingsResponse = { allowedEfforts: ['low', 'medium', 'high'], @@ -342,12 +344,14 @@ function stubFetch( if (path === '/api/settings/personal') { const preferences = savedPersonal ?? { timezone: 'UTC', + theme: 'system', gateParkDestinationId: savedSettings.gateParkDestinationId, isTagRequired: false, } if (init?.method === 'PATCH') { const changes = JSON.parse(String(init.body)) personalPatched.push(changes) + if (rejectPersonalPatch) return new Response('{}', { status: 503 }) savedPersonal = { ...preferences, ...changes } } return new Response(JSON.stringify(savedPersonal ?? preferences), { status: 200 }) @@ -439,14 +443,17 @@ function stubFetch( function renderSettings(workPath?: string) { window.history.replaceState(null, '', workPath ?? '/settings/personal') - vi.stubGlobal( - 'matchMedia', - vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })), - ) + vi.stubGlobal('matchMedia', vi.fn(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( - + + + , ) } @@ -457,6 +464,8 @@ afterEach(() => { vi.restoreAllMocks() patched.length = 0 personalPatched.length = 0 + rejectPersonalPatch = false + window.localStorage.removeItem('druks:theme:operator') }) describe('SettingsPages app fields', () => { @@ -837,6 +846,42 @@ describe('SettingsPages agents', () => { }) describe('settings drafts and navigation', () => { + it('previews theme choices and persists them with Personal Settings', async () => { + stubFetch(false) + renderSettings('/settings/personal') + + const theme = await screen.findByRole('combobox', { name: 'Theme' }) + expect((theme as HTMLSelectElement).value).toBe('system') + expect(document.documentElement.dataset.theme).toBeUndefined() + + fireEvent.change(theme, { target: { value: 'light' } }) + await waitFor(() => expect(document.documentElement.dataset.theme).toBe('light')) + expect(document.documentElement.dataset.theme).toBe('light') + expect((theme as HTMLSelectElement).value).toBe('light') + expect(personalPatched).toEqual([]) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(personalPatched).toEqual([{ theme: 'light' }])) + + fireEvent.change(theme, { target: { value: 'dark' } }) + await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark')) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(personalPatched).toEqual([{ theme: 'light' }, { theme: 'dark' }])) + }) + + it('keeps a failed theme save as a draft', async () => { + stubFetch(false) + rejectPersonalPatch = true + renderSettings('/settings/personal') + + const theme = await screen.findByRole('combobox', { name: 'Theme' }) + fireEvent.change(theme, { target: { value: 'dark' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + await screen.findByRole('alert') + expect(document.documentElement.dataset.theme).toBe('dark') + expect((theme as HTMLSelectElement).value).toBe('dark') + }) + it('keeps shared drafts across pages and saves only the current page', async () => { stubFetch(false) renderSettings() @@ -989,7 +1034,7 @@ describe('settings resource and keyboard behavior', () => { expect(update).toHaveBeenCalledTimes(1) expect(update).toHaveBeenCalledWith({ timezone: 'Europe/Madrid' }) await act(async () => { - finish({ timezone: 'Europe/Madrid', gateParkDestinationId: null, isTagRequired: false } as PersonalSettings) + finish({ timezone: 'Europe/Madrid', theme: 'system', gateParkDestinationId: null, isTagRequired: false } as PersonalSettings) }) expect( (screen.getByRole('button', { name: 'Save changes' }) as HTMLButtonElement).disabled, @@ -1035,7 +1080,9 @@ it('restores the work return URL when a settings fragment route reloads', async - + + + , ) await screen.findByRole('combobox', { name: 'Timezone' }) @@ -1699,7 +1746,7 @@ it('focuses a personal field after preferences finish loading', async () => { renderSettings('/settings/personal?field=timezone') await screen.findByRole('heading', { name: 'Preferences' }) expect(screen.queryByRole('combobox', { name: 'Timezone' })).toBeNull() - await act(async () => { finish({ timezone: 'UTC', gateParkDestinationId: null, isTagRequired: false }) }) + await act(async () => { finish({ timezone: 'UTC', theme: 'system', gateParkDestinationId: null, isTagRequired: false }) }) await waitFor(() => expect(document.activeElement?.id).toBe('personal-timezone')) }) diff --git a/frontend/src/components/SettingsPages.tsx b/frontend/src/components/SettingsPages.tsx index a60e1ce5e..1aefa23d1 100644 --- a/frontend/src/components/SettingsPages.tsx +++ b/frontend/src/components/SettingsPages.tsx @@ -25,6 +25,7 @@ import { appLabel } from '../apps/registry' import { useTicker } from '../lib/useTicker' import { absTime } from '../lib/format' import { harnessColors } from '../lib/harnessColors' +import { useThemePreference } from '../lib/preferences' import { Page } from './Page' import { Sidebar } from './Sidebar' import { BrowserProfilesPane } from './BrowserProfilesPane' @@ -174,6 +175,8 @@ export function SettingsPages({ const errorNotice = useRef(null) const effectiveDefaults = defaults ?? savedDefaults const personalTimezone = personalEdits.timezone ?? personalQuery.data?.timezone ?? 'UTC' + const { theme, previewTheme } = useThemePreference() + const personalTheme = personalEdits.theme ?? theme const personalChanges = Object.fromEntries( Object.entries(personalEdits).filter(([field, value]) => value !== personalQuery.data?.[field as keyof UpdatePersonalSettingsRequest], @@ -284,7 +287,10 @@ export function SettingsPages({ }, [dirtyPages.length]) function discard(page: string) { - if (page === 'personal') setPersonalEdits({}) + if (page === 'personal') { + setPersonalEdits({}) + previewTheme(null) + } else if (page === 'agents') setDefaults(null) else setAppEdits((current) => { @@ -691,6 +697,11 @@ export function SettingsPages({ setPersonalEdits((current) => ({ ...current, timezone: value }))} + theme={personalTheme} + setTheme={(value) => { + setPersonalEdits((current) => ({ ...current, theme: value })) + previewTheme(value) + }} timezones={timezones} clock={clock} busy={saving} diff --git a/frontend/src/components/SettingsPanes.tsx b/frontend/src/components/SettingsPanes.tsx index 20e07aa6d..8f8c25af6 100644 --- a/frontend/src/components/SettingsPanes.tsx +++ b/frontend/src/components/SettingsPanes.tsx @@ -25,6 +25,7 @@ import { type Billing, type McpServer, type Pat, + type PersonalSettings, type Provider, type ProviderCatalog, type ProviderDirectoryEntry, @@ -428,12 +429,16 @@ function InheritCell({ export function PreferencesPane({ timezone, setTimezone, + theme, + setTheme, timezones, clock, busy, }: { timezone: string setTimezone: (timezone: string) => void + theme: PersonalSettings['theme'] + setTheme: (theme: PersonalSettings['theme']) => void timezones: string[] clock: string busy: boolean @@ -469,6 +474,24 @@ export function PreferencesPane({
+
+ +
+ +
+
) } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index c690d2b06..c42c8d57f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -32,9 +32,9 @@ export function Sidebar({ const content = ( <> - -