Skip to content
Open
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
4 changes: 3 additions & 1 deletion .github/scripts/detect-rebake-lanes.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@
".github/actions/",
".github/scripts/detect-rebake-lanes.py"]
PROTO_PATHS = ["crates/engram-protocol/proto/", "buf.gen.yaml"]
WEB_PATHS = ["web/", "orchestrator/packages/spec-document/"]
WEB_PATHS = ["web/", "orchestrator/packages/spec-document/",
"orchestrator/packages/user-preferences/"]
# The public docs + landing site (site/, Astro + Starlight). Its content is
# hand-written under site/; the API reference is generated at build time
# from the public app protos, so those gate the lane too. site-deploy.yml
Expand Down Expand Up @@ -639,6 +640,7 @@ def image_flag(name):
return bake_all_l or proto_l or any_path(cs, [
"web/",
"orchestrator/packages/spec-document/",
"orchestrator/packages/user-preferences/",
"docker/web.Dockerfile",
])
if name == "orchestrator":
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ jobs:
.github/scripts/detect-rebake-lanes.py --stdin <<< 'site/package.json'
grep -qxF 'test_site=true' /tmp/lanes-site.env
grep -qxF 'test_web=false' /tmp/lanes-site.env
# A shared preference contract must test and rebuild both consumers.
env GITHUB_OUTPUT=/tmp/lanes-preferences.env python3 \
.github/scripts/detect-rebake-lanes.py --stdin \
<<< 'orchestrator/packages/user-preferences/src/index.ts'
grep -qxF 'test_web=true' /tmp/lanes-preferences.env
grep -qxF 'test_orchestrator=true' /tmp/lanes-preferences.env
grep -qxF 'images_matrix=["web", "orchestrator"]' /tmp/lanes-preferences.env

# ADR 0122: the deploy templates are user-facing product surface once
# the repo is open source — a broken chart or module is a broken
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ change can affect run (orchestrator-only → only orchestrator; web-only → onl
a coordinator-crate change → Rust lanes incl. macOS/VZ + firecracker + e2e). `bake-images.yml`
bakes only the changed images. A CI-workflow or detector change re-runs everything.

**The only required status check is the aggregator `CI Gate`** — it always runs, `needs:`
EVERY lane (Linux, macOS, firecracker, AND the e2e stack), and passes iff each lane
succeeded-or-skipped. When you add a new lane, add it to the gate's `needs:` (and give it a
**Required status checks are `CI Gate` and `cla`.** The CLA workflow checks the
contributor signature. The aggregator `CI Gate` always runs and lists every lane
(Linux, macOS, firecracker, and the e2e stack) in `needs:`. It passes only when
each lane succeeds or is skipped. When you add a new lane, add it to the gate's `needs:` (and give it a
detector flag) — **never add an individual lane as a required check**, or a path-skipped lane
will wedge the merge queue. **Admin merges skip combined-state validation** —
each PR was green in isolation, not together. Land batches through the merge queue,
Expand Down
1 change: 1 addition & 0 deletions docker/orchestrator.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ WORKDIR /app/orchestrator
# runtime runs the .ts entry directly via Bun, no build/transpile step.
COPY orchestrator/package.json orchestrator/bun.lock ./
COPY orchestrator/packages/spec-document ./packages/spec-document
COPY orchestrator/packages/user-preferences ./packages/user-preferences
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile --production

Expand Down
1 change: 1 addition & 0 deletions docker/web.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ RUN npm install -g pnpm@9
# Lockfile first so the install layer stays warm across source edits.
COPY web/package.json web/pnpm-lock.yaml /src/web/
COPY orchestrator/packages/spec-document /src/orchestrator/packages/spec-document
COPY orchestrator/packages/user-preferences /src/orchestrator/packages/user-preferences
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile

Expand Down
85 changes: 85 additions & 0 deletions docs/adr/0124-user-appearance-preferences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ADR 0124: User appearance preferences

Status: Accepted (2026-10-06)

## Context

Appearance settings currently use one local-storage key per browser origin. The
key has no user identity. Two accounts in one browser share colors and fonts,
and a second device cannot read the saved settings. The orchestrator already
owns account data through Better Auth and Postgres (ADR 0051).

## Decision

Store one versioned preference document per authenticated user in the
orchestrator database. The first document contains appearance settings only:
theme mode, selected scheme, custom schemes, and font IDs. Store the system
theme mode itself; each device resolves its current light or dark theme.

Use a separate `user_preferences` table with a cascading foreign key to the
auth user, a JSONB document, a revision, and a save timestamp. Add a new Drizzle
migration. The deployment migration job applies it through `drizzle-orm`.

Expose authenticated GET and PATCH `/api/v1/me/preferences` routes. The session
supplies the user ID. GET returns defaults at revision zero for a missing row.
PATCH replaces the appearance group and requires the expected revision. An
atomic insert or conditional update advances the revision. A stale write
returns HTTP 409 with the current document. GET does not create a row.

Put types, defaults, and validation in `@engrams/user-preferences`, a shared
TypeScript package. Limit request bodies to 64 KiB and custom schemes to 50.
Keep DOM, color-token generation, and font loading in the web app.

Resolve identity before reading a user cache. Cache confirmed preferences and
pending edits under a user-specific key. Apply edits immediately and use one
save queue per mounted account. Retain failed edits and show a retry action.
Refresh on startup, window focus, and reconnect. Never replace pending edits
with a remote update without an explicit user choice. On account change, reset
appearance, cancel requests, and ignore responses from the previous account.

The existing global browser value has no owner. Offer an explicit import only
when the server has no saved document. Remove that value after a successful
import or an explicit dismissal. Do not assign it to an account automatically.

## Consequences

Settings survive browser-data removal and follow the user to other devices.
Local storage remains optional: failure to access it does not prevent server
saves. Revision checks prevent silent lost updates between tabs and devices.
Unsaved edits can require a conflict choice after another device changes the
server document. Anonymous pages use default appearance without account data.

Both web and orchestrator images must include the shared package. Changes to
it must select both test lanes and both image builds. Tests must cover real
Postgres concurrency, account isolation, retry, migration, and browser reloads.

## Implementation record

The commit chain starts with `bed755c7` (Proposed decision), followed by
`b55d235f95f9` (shared contract, migration, API, web controls, and tests). This
record accepts the decision after those checks.

Migration `0095_user_preferences` follows the current migration journal. It
adds the account document and revision constraint without changing applied
migrations. Both Docker images include the shared package, and path detection
selects both consumers.

The initial implementation needed two corrections during review. A clean tab
could clear another tab's cached draft. Cache writes now preserve foreign
pending edits and their base revision. Terminal selection text now has an
explicit foreground color with at least 4.5:1 contrast; reference ANSI colors
remain unchanged.

The appearance controls include custom color schemes, independent fonts, and
Dracula, Catppuccin, Nord, Solarized, and Gruvbox presets. Optional Inter and
Fira Code fonts use local Fontsource assets with their complete license notices.

Validation passed: 1,046 web tests in 151 files; 20 shared-contract, API,
real-Postgres, and migration-journal tests; both browser suites under the
production CSP; web formatting, lint, and build; and orchestrator type checking.
A fresh Postgres 18 database accepted all migrations. A repeat migration made
no changes. Workflow YAML parses, the shared-package path selects both test
lanes and images, and actionlint has no findings beyond those on the base branch.

Rust checks were not needed for this web and orchestrator change. The
current-head CI Gate supplies the final repository result before merge.
3 changes: 3 additions & 0 deletions orchestrator/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions orchestrator/drizzle/0095_user_preferences.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE "user_preferences" (
"user_id" text PRIMARY KEY NOT NULL,
"document" jsonb NOT NULL,
"revision" integer NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
CONSTRAINT "user_preferences_revision_positive" CHECK ("user_preferences"."revision" > 0)
);
--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
Loading
Loading