Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f600d34d | ||
|
|
cbedfed2ba | ||
|
|
f9a3699603 | ||
|
|
75affec091 | ||
|
|
de9d229cfa | ||
|
|
cabc4825ef | ||
|
|
87ff6ed326 | ||
|
|
6d6e8a841e | ||
|
|
b7112f6e5d | ||
|
|
ee9106ef54 | ||
|
|
e4f1b75f9b | ||
|
|
4a206086af | ||
|
|
29d998b6c3 | ||
|
|
228f2f4638 | ||
|
|
68d60e409b | ||
|
|
b1dfa3f7bb | ||
|
|
f586d748d6 | ||
|
|
bcdc769083 | ||
|
|
430f058a6d | ||
|
|
9b459b79dd | ||
|
|
ab77c66f04 | ||
|
|
dd6135cac1 | ||
|
|
bd15a186b5 | ||
|
|
a15fadbddc | ||
|
|
7e054ddf11 | ||
|
|
0c2ff88c88 | ||
|
|
c982353381 | ||
|
|
78db9c8c93 | ||
|
|
ff133f88ff | ||
|
|
8e181ddf3c | ||
|
|
02df4ac61c | ||
|
|
a5f387929d | ||
|
|
e4117441b0 | ||
|
|
7671368faf | ||
|
|
6b07aafc67 | ||
|
|
f9dfd05676 | ||
|
|
cbcd8d65df | ||
|
|
861fc4a58b | ||
|
|
a58f4d2b32 | ||
|
|
a01ae73a1f | ||
|
|
78f9331b89 |
@@ -194,7 +194,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
|||||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||||
never grantable at any level). 27 entries as of 2026-08-13.
|
never grantable at any level). **18 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 3 execution, 4 admin. It said 27 for nine days.
|
||||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||||
and `confined` stripped for an account with no `osUser`.
|
and `confined` stripped for an account with no `osUser`.
|
||||||
@@ -224,9 +224,20 @@ bunx tsgo # typecheck (not tsc)
|
|||||||
bun test # tests
|
bun test # tests
|
||||||
bun format # prettier over every dirty file — see the note below before running it
|
bun format # prettier over every dirty file — see the note below before running it
|
||||||
bun db:push # apply the schema to Postgres
|
bun db:push # apply the schema to Postgres
|
||||||
|
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||||
|
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||||
|
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||||
|
place to drift, and it drifts in the same edit that misses the doc.
|
||||||
|
|
||||||
|
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||||
|
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||||
|
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||||
|
written had been wrong for over a week.
|
||||||
|
|
||||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
**84 open, 22 closed**, across 28 themes.
|
**88 open, 25 closed**, across 29 themes.
|
||||||
|
|
||||||
| Theme | Open | Closed |
|
| Theme | Open | Closed |
|
||||||
| --- | --- | --- |
|
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||||
@@ -35,22 +35,23 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
||||||
| [Known bugs](todo/known-bugs/) | 4 | 1 |
|
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||||
| [Multi-user](todo/multi-user/) | 17 | 3 |
|
| [Multi-user](todo/multi-user/) | 16 | 5 |
|
||||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||||
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
|
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||||
|
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
|
||||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||||
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
|
||||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 2 | 0 |
|
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||||
|
|
||||||
## Records — no items, kept for the reasoning
|
## Records — no items, kept for the reasoning
|
||||||
@@ -62,13 +63,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
## Newest ten
|
## Newest ten
|
||||||
|
|
||||||
|
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
|
||||||
|
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
|
||||||
|
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
|
||||||
|
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
|
||||||
|
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
|
||||||
|
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
|
||||||
|
- `2026-08-22` **Review officer-secret-store, built overnight and untested by the owner** — [waiting-on-owner/review-the-officer-secret-store-built-last](todo/waiting-on-owner/review-the-officer-secret-store-built-last.md)
|
||||||
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
|
||||||
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
|
||||||
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
|
||||||
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
|
|
||||||
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
|
|
||||||
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
|
|
||||||
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
|
|
||||||
- `2026-08-21` **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
|
|
||||||
- `2026-08-21` **'A deploy verifies PM2, not that the app still works'** — [fleet/a-deploy-report-verifies-pm2-not-that-the-app](todo/fleet/a-deploy-report-verifies-pm2-not-that-the-app.md)
|
|
||||||
- `2026-08-21` **'The deploy commander cannot finish its own host'** — [fleet/the-deploy-commander-cannot-finish-its-own-host](todo/fleet/the-deploy-commander-cannot-finish-its-own-host.md)
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 2026.08.21.04 — deploy report
|
||||||
|
|
||||||
|
**6 of 6 hosts in scope succeeded.** `officer-dev` was skipped entirely — it is the host this agent runs
|
||||||
|
on. Seven platforms exist; six were deployable.
|
||||||
|
|
||||||
|
Target commit `98177c3b`, tag `deploy/2026.08.21.04`, `package.json` version `2026.08.21.04` — tag and
|
||||||
|
version agree.
|
||||||
|
|
||||||
|
Every host ran the same four steps from `deploy/2026.08.21.04.md`: `git pull --ff-only`, `bun db:push`
|
||||||
|
(the schema change), `bun run plugins:update`, `pm2 restart officer`. No host ran `bun install` — the plan
|
||||||
|
said not to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `alpha` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3cab16ca` → `98177c3b`. Tree clean, no untracked files.
|
||||||
|
- The pull moved only `deploy/2026.08.21.04.md` and `package.json`: the code was already there from the
|
||||||
|
hand deploy the plan describes. Expected, and the remaining steps ran anyway.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified present by direct query.
|
||||||
|
- `plugins:update` — 12/12 updated in 44s, exit 0. **Twelve sidecars restarted by that step**: atelier,
|
||||||
|
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission,
|
||||||
|
vault. That is the script's own behaviour, not something the plan asked for.
|
||||||
|
- `pm2 restart officer` — `restart_time` 63 → 64, `online`.
|
||||||
|
|
||||||
|
### `fofos` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. No tracked changes.
|
||||||
|
- **Untracked file present: `scripts/chk.ts`.** Left alone — a fast-forward cannot touch it, and it is
|
||||||
|
not a reason to skip a host.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted: `officer-gitea`.
|
||||||
|
- `pm2 restart officer` — 17 → 18, `online`.
|
||||||
|
- The capabilities ownership fault recorded in `docs/servers.md` did not recur: `capabilities: pulled`.
|
||||||
|
|
||||||
|
### `macbookpro` — succeeded
|
||||||
|
|
||||||
|
- HEAD `b10a49c0` → `98177c3b`. Tree clean.
|
||||||
|
- Awake and reachable; no skip needed.
|
||||||
|
- `bun` reached with `export PATH=$PATH:/opt/homebrew/bin` appended for the `bun` commands only, `pm2`
|
||||||
|
run as a separate command, per the plan and `docs/servers.md`.
|
||||||
|
- `db:push` — changes applied.
|
||||||
|
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
|
||||||
|
- `pm2 restart officer` — 4 → 5, `online`.
|
||||||
|
- **The one thing not verified here:** `psql` is not on this machine's PATH under any prefix tried, so
|
||||||
|
`anthropic_accounts` was confirmed only by `db:push` reporting "Changes applied", not by querying the
|
||||||
|
table. Every other host was checked directly.
|
||||||
|
- Cosmetic noise from the interactive shell: `.zshrc:123: command not found: starship`. Harmless, and
|
||||||
|
unrelated to the deploy.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. Tree clean.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — 2/2 (gitea, offscale), exit 0. Two sidecars restarted.
|
||||||
|
- `pm2 restart officer` — 6 → 7, `online`.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. Tree clean.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — 1/1 (gitea), exit 0. One sidecar restarted.
|
||||||
|
- `pm2 restart officer` — 8 → 9, `online`.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — succeeded
|
||||||
|
|
||||||
|
- Logged in as `br`, checkout `/home/br/officerdev/platform`.
|
||||||
|
- HEAD `3343b21a` → `98177c3b`. Tree clean.
|
||||||
|
- `db:push` — changes applied. `anthropic_accounts` verified by direct query.
|
||||||
|
- `plugins:update` — "no plugins installed — nothing to update", exit 0. No sidecar restarted.
|
||||||
|
- `pm2 restart officer` — 2 → 3, `online`.
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, deliberately
|
||||||
|
|
||||||
|
Not pulled, no schema push, no plugin update, nothing restarted. This agent is a session inside
|
||||||
|
`officer-claude-code` on this machine, and restarting `officer` here ends the rollout mid-report. The
|
||||||
|
host is therefore one release behind the other six until the owner deploys it by hand — checkout
|
||||||
|
`~/officer-dev/platform`, and its working tree is already at `98177c3b` plus this report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was checked, and how
|
||||||
|
|
||||||
|
- Every checkout path came from PM2 (`officer`'s `pm_cwd`), not from the table — so `alpha`'s second,
|
||||||
|
stale checkout was never a candidate.
|
||||||
|
- `pm2 jlist` after each restart: `officer` `online` with `restart_time` moved, on all six.
|
||||||
|
- A full sweep for anything not `online` on all six hosts afterwards: **none**. No process came back
|
||||||
|
`errored`, including the sixteen sidecars that `plugins:update` bounced across four hosts.
|
||||||
|
- `anthropic_accounts` queried directly with `to_regclass` on five hosts; on `macbookpro` only the
|
||||||
|
`db:push` output stands behind it.
|
||||||
|
|
||||||
|
## What was not done
|
||||||
|
|
||||||
|
- `officer-dev` — the whole host, as above.
|
||||||
|
- No host was skipped for a dirty tree; none had tracked changes.
|
||||||
|
- No process other than `officer` was restarted by this agent. The sidecars that moved were restarted by
|
||||||
|
`plugins:update` itself.
|
||||||
|
- `bun install` was not run anywhere, per the plan.
|
||||||
|
|
||||||
|
Local record: `data/deploys/2026.08.21.04.done`, written before this report.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 2026.08.22.01
|
||||||
|
|
||||||
|
349 community themes, Spotlight made usable, prompt history that stops eating drafts, and the email
|
||||||
|
plugin's multi-account backend.
|
||||||
|
|
||||||
|
**No schema change and no dependency change.** Verified against `deploy/2026.08.21.04..HEAD`: nothing
|
||||||
|
touches a `databases/*/schema.ts`, `package.json` dependencies or `bun.lock`. So **no `bun db:push` and no
|
||||||
|
`bun install`** — a host that runs either has run something this release did not ask for. Note that the
|
||||||
|
previous release DID need a push; that was `anthropic_accounts` and it is already applied everywhere.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **The theme collection.** 147 ports from omarchythemes.com, then 44 more rescued by supporting
|
||||||
|
`colors.toml` and `0x`-prefixed hex, then a fix to accent/secondary foreground derivation across 202 of
|
||||||
|
them. This is the bulk of the diff: 229 files, ~22.5k lines, almost all data.
|
||||||
|
- **Spotlight.** Opens on **alt-space** and nothing else, from anywhere including a focused text field.
|
||||||
|
The eight-item cap is gone — core items filled it, so every plugin tile was unreachable on a populated
|
||||||
|
server. Rows draw the dock's own tiles, and the settings list went from nine hand-written entries to
|
||||||
|
twenty read off the screens that declare them.
|
||||||
|
- **Prompt history.** Up/Down only recall from an empty composer, and any other keystroke ends a recall —
|
||||||
|
so a half-written thought is never swapped for one from last week.
|
||||||
|
- **The email plugin's multi-account backend**, which arrives via `plugins:update` rather than through this
|
||||||
|
repository. `?account=<id>` names a mailbox, `?account=all` spans them. **Absent is unchanged**, so
|
||||||
|
nothing that calls those routes today behaves differently — including the mobile app and the agent's
|
||||||
|
`email_db` tool. Only hosts with the email plugin installed are affected at all.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only` — larger than usual, ~22.5k lines of theme data
|
||||||
|
2. `bun run plugins:update`
|
||||||
|
3. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 2 restarts each plugin's own sidecar, which is that script's behaviour and worth
|
||||||
|
recording in the report rather than omitting.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands and run `pm2` separately. See
|
||||||
|
`docs/servers.md` — replacing the PATH is what broke `pm2` on an earlier deploy.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it, and do NOT report it as behind
|
||||||
|
|
||||||
|
The commander runs there and skips its own host, as always. But this time the host is **already on this
|
||||||
|
code**: `officer` was restarted there many times during the night as the work landed, so it is running the
|
||||||
|
tree this tag names rather than lagging. Report it as skipped and current, not skipped and stale.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing `errored`, including the sidecars step
|
||||||
|
2 bounced.
|
||||||
|
|
||||||
|
A theme picker still lists themes and one loads — the diff is data, so a bad parse would show as a missing
|
||||||
|
or unreadable theme rather than as a failed boot.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.22.01` says `done`.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 2026.08.22.01 — deploy report
|
||||||
|
|
||||||
|
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
|
||||||
|
No host failed, no process came back `errored`.
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
|
||||||
|
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
|
||||||
|
|
||||||
|
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `alpha` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
|
||||||
|
`~/dockers/officer.dev/platform` was not touched).
|
||||||
|
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
|
||||||
|
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
|
||||||
|
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||||
|
photos, soulseek, transmission, vault.
|
||||||
|
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
|
||||||
|
- All 19 processes `online` afterwards.
|
||||||
|
|
||||||
|
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
|
||||||
|
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
|
||||||
|
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
|
||||||
|
captured an explicit `0`.
|
||||||
|
|
||||||
|
### `fofos` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
|
||||||
|
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
|
||||||
|
touch it, and it was left alone. No tracked change.
|
||||||
|
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
|
||||||
|
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
|
||||||
|
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
|
||||||
|
fix of 2026-08-21 is holding.
|
||||||
|
|
||||||
|
### `macbookpro` — succeeded
|
||||||
|
|
||||||
|
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
|
||||||
|
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
|
||||||
|
interactive shell, exactly as the plan and `docs/servers.md` require.
|
||||||
|
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
|
||||||
|
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
|
||||||
|
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — succeeded
|
||||||
|
|
||||||
|
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
|
||||||
|
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
|
||||||
|
and the command still exited 0 — recorded so the next reader does not chase it.
|
||||||
|
- `pm2 restart officer`: 9 → 10, `online`.
|
||||||
|
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
|
||||||
|
All `online`, none touched.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — succeeded
|
||||||
|
|
||||||
|
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||||
|
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||||
|
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
|
||||||
|
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
|
||||||
|
the others'. Not an error.
|
||||||
|
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, and current
|
||||||
|
|
||||||
|
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
|
||||||
|
|
||||||
|
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
|
||||||
|
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
|
||||||
|
another agent working in this same tree during the rollout. Its work was left alone; only this report was
|
||||||
|
staged, by path.
|
||||||
|
|
||||||
|
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
|
||||||
|
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
|
||||||
|
at `78db9c8c`, the tag — but the next release will include them.
|
||||||
|
|
||||||
|
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
|
||||||
|
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
|
||||||
|
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
|
||||||
|
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
|
||||||
|
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
|
||||||
|
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
|
||||||
|
|
||||||
|
## What was not done
|
||||||
|
|
||||||
|
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
|
||||||
|
happy with the rest. It needs no pull.
|
||||||
|
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
|
||||||
|
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
|
||||||
|
files arrived, not that each parses.
|
||||||
|
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 2026.08.22.02
|
||||||
|
|
||||||
|
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
|
||||||
|
authorization resolver, and the email plugin's multi-account backend.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
|
||||||
|
dropdown fails on a missing relation the first time it reads a starred list.
|
||||||
|
|
||||||
|
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
|
||||||
|
`bun.lock`. Do not run `bun install`.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
|
||||||
|
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
|
||||||
|
sorting it to the top, so neighbours survive.
|
||||||
|
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
|
||||||
|
the owner's `vimazing`.
|
||||||
|
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
|
||||||
|
Brave claims it at the browser level and the key never reaches the page.
|
||||||
|
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
|
||||||
|
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
|
||||||
|
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
|
||||||
|
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
|
||||||
|
from the database calls. No behaviour change.
|
||||||
|
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
|
||||||
|
declaration, instead of a parallel list of handlers.
|
||||||
|
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
|
||||||
|
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git pull --ff-only`
|
||||||
|
2. **`bun db:push`** — new this release
|
||||||
|
3. `bun run plugins:update`
|
||||||
|
4. `pm2 restart officer`
|
||||||
|
|
||||||
|
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
|
||||||
|
report rather than being omitted.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
|
||||||
|
`docs/servers.md`.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it, and it is already current
|
||||||
|
|
||||||
|
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
|
||||||
|
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
|
||||||
|
|
||||||
|
## Then check
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
|
||||||
|
|
||||||
|
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
|
||||||
|
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
|
||||||
|
say where it is not.
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 2026.08.22.02 — deploy report
|
||||||
|
|
||||||
|
**6 of 6 target hosts succeeded.** `officer-dev` skipped (the commander runs there) and verified already
|
||||||
|
current. No host failed, no step was skipped for a fault, nothing came back `errored`.
|
||||||
|
|
||||||
|
Deployed 2026-08-22 from `officer-dev`. Tag `deploy/2026.08.22.02` → `68d60e40`, matching
|
||||||
|
`package.json` `2026.08.22.02`.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Host | HEAD before | HEAD after | Steps | `officer` restart | `theme_favorites` |
|
||||||
|
| ---------------------------- | ----------- | ---------- | ---------- | ----------------- | ----------------- |
|
||||||
|
| `officer-dev` | `68d60e40` | — | skipped | not touched (192) | present |
|
||||||
|
| `alpha` | `78db9c8c` | `68d60e40` | all 4 | 65 → 66, online | present |
|
||||||
|
| `fofos` | `78db9c8c` | `68d60e40` | all 4 | 19 → 20, online | present |
|
||||||
|
| `macbookpro` | `9b459b79` | `68d60e40` | all 4 | 6 → 7, online | present |
|
||||||
|
| `edge-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 8 → 9, online | present |
|
||||||
|
| `production-pertento-bridge` | `78db9c8c` | `68d60e40` | all 4 | 10 → 11, online | present |
|
||||||
|
| `deadpool-rezio-bridge` | `78db9c8c` | `68d60e40` | all 4 | 4 → 5, online | present |
|
||||||
|
|
||||||
|
Checkout paths were taken from each `officer` process's `pm_cwd`, not from a hard-coded list.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
### `officer-dev` — SKIPPED, and current
|
||||||
|
|
||||||
|
The commander session runs inside `officer-claude-code` on this machine, so the whole host was skipped as
|
||||||
|
the plan and `prompts/deploy-commander.md` both require: no pull, no `db:push`, no `plugins:update`, no
|
||||||
|
restart. It was already at `68d60e40` before the rollout started and `theme_favorites` is present, so it is
|
||||||
|
running this release rather than lagging. `officer` shows 192 restarts, untouched by this deploy.
|
||||||
|
|
||||||
|
**The owner restarts this host deliberately, once happy with the rest.** Strictly nothing is pending on it —
|
||||||
|
the tree, the schema and the running process are all already on this release.
|
||||||
|
|
||||||
|
### `alpha` — done
|
||||||
|
|
||||||
|
Tree clean (tracked and untracked). Pulled `78db9c8c` → `68d60e40`. `bun db:push` applied changes;
|
||||||
|
`backfill-session-models` reported 0 seeded, 32 already recorded. `bun run plugins:update` returned
|
||||||
|
**12/12 updated in 46s**, exit 0 — and it restarted all twelve plugin sidecars itself (atelier,
|
||||||
|
bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault),
|
||||||
|
which is the script's normal behaviour and not something the plan asked for. `pm2 restart officer`: 65 → 66,
|
||||||
|
`online`. All 19 processes `online`. `to_regclass('theme_favorites')` returned the table.
|
||||||
|
|
||||||
|
### `fofos` — done
|
||||||
|
|
||||||
|
Tracked tree clean. **One untracked file, `scripts/chk.ts`** — noted, not a reason to skip, and untouched by
|
||||||
|
the fast-forward. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes (3 transcripts already recorded).
|
||||||
|
`plugins:update`: **1/1 (gitea)**, exit 0, its sidecar restarted by the script. `officer` 19 → 20, `online`;
|
||||||
|
all 8 processes `online`. `theme_favorites` present.
|
||||||
|
|
||||||
|
Note for anyone scripting against this host: its `.env` names the connection `OFFICERDEV_POSTGRES_URL`, not
|
||||||
|
`POSTGRES_URL`, and a naive `grep POSTGRES_URL` matches a comment line first. Same on `macbookpro`,
|
||||||
|
`edge-pertento-bridge`, `production-pertento-bridge` and `deadpool-rezio-bridge`; `alpha` and `officer-dev`
|
||||||
|
use `POSTGRES_URL`.
|
||||||
|
|
||||||
|
### `macbookpro` — done, awake
|
||||||
|
|
||||||
|
Reachable — not asleep this time. Tree clean. **It was two releases behind**: `9b459b79`, not `78db9c8c`
|
||||||
|
like the rest. Pulled straight to `68d60e40`.
|
||||||
|
|
||||||
|
`PATH` was appended with `/opt/homebrew/bin` for the `bun` commands and `pm2` run separately, per
|
||||||
|
`docs/servers.md`. `db:push` applied changes, exit 0. `plugins:update` exit 0 with **no plugins installed —
|
||||||
|
nothing to update**, so no plugin sidecars moved here. `officer` 6 → 7, `online`; all 6 processes `online`.
|
||||||
|
|
||||||
|
**`psql` is not installed on this host** — the plan's direct schema check could not be run the way it is
|
||||||
|
written. Verified instead through the platform's own `postgres` client (`bun --env-file=.env` against
|
||||||
|
`OFFICERDEV_POSTGRES_URL`), which returned `theme_favorites`. Same fact, different tool.
|
||||||
|
|
||||||
|
`$SHELL -ic` also prints `command not found: starship` from `.zshrc:123` on every invocation. Cosmetic, and
|
||||||
|
unrelated to this release, but it appears in the raw output above every command.
|
||||||
|
|
||||||
|
### `edge-pertento-bridge` — done
|
||||||
|
|
||||||
|
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **2/2
|
||||||
|
(gitea, offscale)**, exit 0, both sidecars restarted by the script. `officer` 8 → 9, `online`; all 8
|
||||||
|
processes `online`. `theme_favorites` present.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — done
|
||||||
|
|
||||||
|
Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied changes, exit 0. `plugins:update`: **1/1
|
||||||
|
(gitea)**, exit 0, sidecar restarted by the script. `officer` 10 → 11, `online`; all 15 processes `online`.
|
||||||
|
`theme_favorites` present.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — done
|
||||||
|
|
||||||
|
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `78db9c8c` → `68d60e40`. `db:push` applied
|
||||||
|
changes, exit 0. `plugins:update`: **2/2 (email, offscale)**, exit 0, both sidecars restarted by the script.
|
||||||
|
`officer` 4 → 5, `online`; all 9 processes `online`. `theme_favorites` present.
|
||||||
|
|
||||||
|
## What did not happen
|
||||||
|
|
||||||
|
- **No host was skipped for a fault.** The only skip is `officer-dev`, by design.
|
||||||
|
- **`bun install` was not run anywhere.** The plan says this release changes no dependency, and the plan is
|
||||||
|
what this agent executes.
|
||||||
|
- **Nothing but `officer` was restarted deliberately.** The plugin sidecars that bounced were restarted by
|
||||||
|
`plugins:update` itself — eighteen processes across five hosts, listed per host above. Recording them
|
||||||
|
here rather than reporting only `officer` is the plan's explicit instruction.
|
||||||
|
- **The one verification that had to change shape** is `macbookpro`'s schema check, for want of `psql`.
|
||||||
|
Everything else was verified exactly as written.
|
||||||
|
|
||||||
|
## Claim record
|
||||||
|
|
||||||
|
`scripts/deploy-claim.sh state 2026.08.22.02` → `done`. The local record
|
||||||
|
`data/deploys/2026.08.22.02.done` was written before this report was committed, so a push failure here
|
||||||
|
cannot cause a second rollout.
|
||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.21.04",
|
"version": "2026.08.22.02",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -34,7 +34,9 @@
|
|||||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||||
"setup": "bash scripts/install.sh",
|
"setup": "bash scripts/install.sh",
|
||||||
"check:theme": "bun scripts/check-theme-tokens.ts"
|
"check:theme": "bun scripts/check-theme-tokens.ts",
|
||||||
|
"docs:check": "bun run scripts/docs-check.ts",
|
||||||
|
"skills:platform": "bun run scripts/gen-platform-skill.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ there is a release to deploy that has not been deployed.
|
|||||||
git ls-remote --tags origin 'refs/tags/deploy/*'
|
git ls-remote --tags origin 'refs/tags/deploy/*'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
|
||||||
|
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
|
||||||
|
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
|
||||||
|
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
|
||||||
|
long stretches.
|
||||||
|
|
||||||
|
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
|
||||||
|
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
|
||||||
|
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
|
||||||
|
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
|
||||||
|
|
||||||
|
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
|
||||||
|
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
|
||||||
|
already trusted.
|
||||||
|
|
||||||
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
|
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
|
||||||
a commit message, not a branch, not a push.
|
a commit message, not a branch, not a push.
|
||||||
|
|
||||||
@@ -81,6 +96,21 @@ load-bearing. A missing report is a missing record; a missing lock is a second d
|
|||||||
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
|
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
|
||||||
clone does not arrive believing it has deployed nothing.
|
clone does not arrive believing it has deployed nothing.
|
||||||
|
|
||||||
|
## Before a release is tagged — not your job, but know it happened
|
||||||
|
|
||||||
|
Whoever cuts a release runs `bun run docs:check` first and leaves it green. It compares documented claims
|
||||||
|
against values derived at run time — how many permissions the registry holds, how many PM2 entries Officer
|
||||||
|
is, whether every settings route Spotlight offers actually exists — and exits non-zero on drift.
|
||||||
|
|
||||||
|
It exists because "update the docs before a deploy" is a step that gets skipped at two in the morning and
|
||||||
|
whose absence is invisible. On 2026-08-22 it found, immediately: `platform/CLAUDE.md` claiming 27 permission
|
||||||
|
entries against a registry holding 18, and the workspace `CLAUDE.md` claiming twenty PM2 entries against
|
||||||
|
thirteen, listing eleven processes that do not run here while omitting six that do.
|
||||||
|
|
||||||
|
**You do not run it and you do not fix documentation.** You execute a plan and report. It is here so that a
|
||||||
|
report saying "the docs are stale" has somewhere to point, and so nobody adds doc-fixing to a rollout, where
|
||||||
|
it would put a prose edit between two hosts.
|
||||||
|
|
||||||
## The release
|
## The release
|
||||||
|
|
||||||
The tag names the version. The commit it points at carries the rest:
|
The tag names the version. The commit it points at carries the rest:
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { Glob } from 'bun';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Which settings sections actually exist, read from the screens that declare them.
|
||||||
|
|
||||||
|
Separated from `docs-check.ts` because it is the only check that parses source rather than importing it —
|
||||||
|
the sections are JSX-adjacent object literals inside components, so importing them would drag React and a
|
||||||
|
browser environment into a CLI. A regex over `BASE_PATH` and `key:` is the honest tool for that, and it is
|
||||||
|
narrow enough to be obvious when it breaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dir, '..');
|
||||||
|
const SETTINGS_DIR = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Settings');
|
||||||
|
|
||||||
|
/** `/settings/profile` + `change-password` → `/settings/profile/change-password`. */
|
||||||
|
export async function declaredSettingsPaths(): Promise<Set<string>> {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const glob = new Glob('**/*.tsx');
|
||||||
|
for await (const file of glob.scan({ cwd: SETTINGS_DIR, absolute: true })) {
|
||||||
|
const body = readFileSync(file, 'utf-8');
|
||||||
|
const base = /BASE_PATH\s*=\s*'([^']+)'/.exec(body)?.[1];
|
||||||
|
if (!base) continue;
|
||||||
|
paths.add(base);
|
||||||
|
for (const m of body.matchAll(/key:\s*'([^']+)'/g)) paths.add(`${base}/${m[1]}`);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings routes Spotlight offers, and which of them nothing declares.
|
||||||
|
*
|
||||||
|
* Parsed rather than imported for the same reason: `useSpotlightItems` is a hook and pulls in the dock,
|
||||||
|
* lucide and React.
|
||||||
|
*/
|
||||||
|
export async function SPOTLIGHT_SETTINGS_PATHS(): Promise<{ offered: string[]; missing: string[] }> {
|
||||||
|
const file = join(ROOT, 'src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/useSpotlightItems.ts');
|
||||||
|
const body = readFileSync(file, 'utf-8');
|
||||||
|
const offered = [...body.matchAll(/to:\s*'(\/settings\/[^']+)'/g)].map((m) => m[1]!);
|
||||||
|
const declared = await declaredSettingsPaths();
|
||||||
|
return { offered, missing: offered.filter((p) => !declared.has(p)) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
Does the documentation still match the code?
|
||||||
|
|
||||||
|
── Why a script and not a habit ──
|
||||||
|
|
||||||
|
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is
|
||||||
|
invisible. Every piece of drift found on 2026-08-22 was MECHANICAL — a count, a route name, a process
|
||||||
|
list — so it is checkable, and a check that exits non-zero cannot be quietly not done.
|
||||||
|
|
||||||
|
What it will not do is read prose and judge it. It looks only at claims with a machine-checkable
|
||||||
|
counterpart, which is a small fraction of the documentation and the fraction that rots fastest, because a
|
||||||
|
number is exactly the thing nobody re-derives while editing a sentence around it.
|
||||||
|
|
||||||
|
Real examples this was written from, both live at the time:
|
||||||
|
|
||||||
|
platform/CLAUDE.md "27 entries as of 2026-08-13" → the registry held 18
|
||||||
|
officer-dev/CLAUDE.md "Officer is twenty PM2 entries" → there were 13
|
||||||
|
Spotlight's settings list 9 rows against 21 sections, and one route that does not exist
|
||||||
|
|
||||||
|
── The rule for adding a check ──
|
||||||
|
|
||||||
|
A check must compare a documented claim against a value DERIVED AT RUN TIME, never against a second
|
||||||
|
hard-coded copy. A check with the answer written into it is one more place to drift, and it will drift in
|
||||||
|
the same edit that misses the doc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
type Finding = { file: string; claim: string; documented: string; actual: string };
|
||||||
|
type CheckResult = { findings: Finding[]; skipped?: string };
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dir, '..');
|
||||||
|
const WORKSPACE = resolve(ROOT, '..');
|
||||||
|
|
||||||
|
const read = (path: string): string | null => (existsSync(path) ? readFileSync(path, 'utf-8') : null);
|
||||||
|
|
||||||
|
/** Spelled-out numbers appear in prose more often than digits, and both need catching. */
|
||||||
|
const WORDS: Record<string, number> = {
|
||||||
|
ten: 10,
|
||||||
|
eleven: 11,
|
||||||
|
twelve: 12,
|
||||||
|
thirteen: 13,
|
||||||
|
fourteen: 14,
|
||||||
|
fifteen: 15,
|
||||||
|
sixteen: 16,
|
||||||
|
seventeen: 17,
|
||||||
|
eighteen: 18,
|
||||||
|
nineteen: 19,
|
||||||
|
twenty: 20,
|
||||||
|
'twenty-one': 21,
|
||||||
|
'twenty-two': 22,
|
||||||
|
'twenty-three': 23,
|
||||||
|
'twenty-four': 24,
|
||||||
|
'twenty-five': 25,
|
||||||
|
};
|
||||||
|
const asNumber = (raw: string): number | null => {
|
||||||
|
const digits = Number(raw);
|
||||||
|
if (Number.isFinite(digits)) return digits;
|
||||||
|
return WORDS[raw.toLowerCase()] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A documented count, found by a pattern with one capture group, compared to the live value.
|
||||||
|
*
|
||||||
|
* Returns a finding per MISMATCH, not per occurrence: a doc stating the right number twice is fine.
|
||||||
|
*/
|
||||||
|
function checkCount(file: string, pattern: RegExp, actual: number, claim: string): CheckResult {
|
||||||
|
const body = read(file);
|
||||||
|
if (!body) return { findings: [], skipped: `${file} not found` };
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
for (const match of body.matchAll(pattern)) {
|
||||||
|
const stated = asNumber(match[1] ?? '');
|
||||||
|
if (stated === null || stated === actual) continue;
|
||||||
|
findings.push({ file, claim, documented: String(stated), actual: String(actual) });
|
||||||
|
}
|
||||||
|
return { findings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const all: Finding[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const record = (r: CheckResult) => {
|
||||||
|
all.push(...r.findings);
|
||||||
|
if (r.skipped) skipped.push(r.skipped);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. How many permissions the registry holds. The number appears in prose and is never recomputed.
|
||||||
|
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||||
|
record(
|
||||||
|
checkCount(
|
||||||
|
join(ROOT, 'CLAUDE.md'),
|
||||||
|
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||||
|
PERMISSIONS.length,
|
||||||
|
'permission registry entries',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. How many PM2 entries Officer is. Documented as going stale "every time a sidecar lands", which is an
|
||||||
|
// admission that it needs a check rather than a caveat.
|
||||||
|
const pm2 = Bun.spawnSync([process.env.SHELL || '/bin/sh', '-ic', 'pm2 jlist'], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
if (pm2.exitCode === 0) {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(pm2.stdout.toString()) as Array<{ name: string }>;
|
||||||
|
const officer = list.filter((p) => p.name === 'officer' || p.name.startsWith('officer-')).length;
|
||||||
|
record(
|
||||||
|
checkCount(
|
||||||
|
join(WORKSPACE, 'CLAUDE.md'),
|
||||||
|
/Officer is ([a-z-]+|[0-9]+) PM2 entries/gi,
|
||||||
|
officer,
|
||||||
|
'officer PM2 entries',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
skipped.push('pm2 jlist did not parse');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Honest rather than silent: a machine with no pm2 cannot check this, and saying so is the point.
|
||||||
|
skipped.push('pm2 unavailable — PM2 entry count unchecked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Every settings path Spotlight offers must exist as a declared section. This is the drift that shipped
|
||||||
|
// a dead link to /settings/users, and it is the class the other two cannot catch: a name, not a count.
|
||||||
|
const { SPOTLIGHT_SETTINGS_PATHS } = await import('./docs-check-settings');
|
||||||
|
const declared = await SPOTLIGHT_SETTINGS_PATHS();
|
||||||
|
for (const bad of declared.missing) {
|
||||||
|
all.push({
|
||||||
|
file: 'Spotlight/useSpotlightItems.ts',
|
||||||
|
claim: 'settings route offered by Spotlight',
|
||||||
|
documented: bad,
|
||||||
|
actual: 'no screen declares that section',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The generated platform skill must match its generator. A stale SKILL.md is worse than a stale doc:
|
||||||
|
// an agent quotes it to a user with no way to tell it is out of date.
|
||||||
|
const { renderPlatformSkill, SKILL_PATH } = await import('./gen-platform-skill');
|
||||||
|
const expected = await renderPlatformSkill();
|
||||||
|
const onDisk = read(SKILL_PATH);
|
||||||
|
if (onDisk === null) {
|
||||||
|
all.push({
|
||||||
|
file: 'capabilities/skills/platform/SKILL.md',
|
||||||
|
claim: 'generated platform skill',
|
||||||
|
documented: 'missing',
|
||||||
|
actual: 'run `bun run skills:platform`',
|
||||||
|
});
|
||||||
|
} else if (onDisk !== expected) {
|
||||||
|
all.push({
|
||||||
|
file: 'capabilities/skills/platform/SKILL.md',
|
||||||
|
claim: 'generated platform skill is stale',
|
||||||
|
documented: `${onDisk.split('\n').length} lines on disk`,
|
||||||
|
actual: `${expected.split('\n').length} lines from the generator — run \`bun run skills:platform\``,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const note of skipped) console.log(` skipped: ${note}`);
|
||||||
|
if (all.length === 0) {
|
||||||
|
console.log('docs:check — every checkable claim matches the code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`\ndocs:check — ${all.length} claim(s) no longer match the code:\n`);
|
||||||
|
for (const f of all) {
|
||||||
|
console.log(` ${f.file}`);
|
||||||
|
console.log(` ${f.claim}: says ${f.documented}, actually ${f.actual}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
|
||||||
|
|
||||||
|
── Why generated ──
|
||||||
|
|
||||||
|
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
|
||||||
|
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
|
||||||
|
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
|
||||||
|
|
||||||
|
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
|
||||||
|
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
|
||||||
|
and the prose is limited to what no registry can express.
|
||||||
|
|
||||||
|
── The split, and where the line is ──
|
||||||
|
|
||||||
|
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
|
||||||
|
keyboard shortcuts
|
||||||
|
PROSE what the platform IS, what a permission KIND means for a user, where to look next
|
||||||
|
|
||||||
|
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
|
||||||
|
differs, so a stale skill is a failed check rather than a confident wrong answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dir, '..');
|
||||||
|
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
|
||||||
|
|
||||||
|
const KIND_MEANING: Record<string, string> = {
|
||||||
|
core: 'every account has it and it cannot be denied — the caller acting on themselves',
|
||||||
|
app: 'grantable per role, at read or write',
|
||||||
|
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
|
||||||
|
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
|
||||||
|
admin: 'the server owner only',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function renderPlatformSkill(): Promise<string> {
|
||||||
|
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||||
|
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||||||
|
|
||||||
|
const byKind = new Map<string, typeof PERMISSIONS>();
|
||||||
|
for (const p of PERMISSIONS) {
|
||||||
|
const list = byKind.get(p.kind) ?? [];
|
||||||
|
list.push(p);
|
||||||
|
byKind.set(p.kind, list as typeof PERMISSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const out = (s = '') => lines.push(s);
|
||||||
|
|
||||||
|
out('---');
|
||||||
|
out('name: Officer platform');
|
||||||
|
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||||||
|
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||||||
|
out('description: >-');
|
||||||
|
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
|
||||||
|
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
|
||||||
|
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
|
||||||
|
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
|
||||||
|
out('version: 1');
|
||||||
|
out('---');
|
||||||
|
out();
|
||||||
|
out('<!--');
|
||||||
|
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
|
||||||
|
out(' differs from what the generator produces, and an edit here is lost on the next run.');
|
||||||
|
out();
|
||||||
|
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
|
||||||
|
out('-->');
|
||||||
|
out();
|
||||||
|
out('# What Officer is');
|
||||||
|
out();
|
||||||
|
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
|
||||||
|
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
|
||||||
|
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
|
||||||
|
out('user, and **absence denies** — there is no row meaning "no".');
|
||||||
|
out();
|
||||||
|
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
|
||||||
|
out('no, and *not shareable at all*.');
|
||||||
|
out();
|
||||||
|
out('# Permissions');
|
||||||
|
out();
|
||||||
|
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
|
||||||
|
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
|
||||||
|
out('diligence.');
|
||||||
|
out();
|
||||||
|
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
|
||||||
|
const entries = byKind.get(kind) ?? [];
|
||||||
|
if (!entries.length) continue;
|
||||||
|
out(`## \`${kind}\` — ${meaning}`);
|
||||||
|
out();
|
||||||
|
for (const p of entries) {
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
|
||||||
|
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
|
||||||
|
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
|
||||||
|
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
|
||||||
|
if (bits.length) out(` - ${bits.join(' · ')}`);
|
||||||
|
if (p.selfService?.length) {
|
||||||
|
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
|
||||||
|
}
|
||||||
|
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
|
||||||
|
if (p.readOnlyWrites?.length) {
|
||||||
|
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out();
|
||||||
|
}
|
||||||
|
|
||||||
|
out('# Keyboard shortcuts');
|
||||||
|
out();
|
||||||
|
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||||||
|
out('list rather than remembering one.');
|
||||||
|
out();
|
||||||
|
for (const section of SHORTCUT_SECTIONS) {
|
||||||
|
out(`## ${section.title}`);
|
||||||
|
if (section.note) {
|
||||||
|
out();
|
||||||
|
out(section.note);
|
||||||
|
}
|
||||||
|
out();
|
||||||
|
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${s.description}`);
|
||||||
|
out();
|
||||||
|
}
|
||||||
|
|
||||||
|
out('# What is NOT in here');
|
||||||
|
out();
|
||||||
|
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
|
||||||
|
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
|
||||||
|
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
|
||||||
|
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
|
||||||
|
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
|
||||||
|
out(' commander is, how one is started, and how to help somebody shape one.');
|
||||||
|
out();
|
||||||
|
out('# When you are about to guess');
|
||||||
|
out();
|
||||||
|
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
|
||||||
|
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
|
||||||
|
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
|
||||||
|
out('certain as one quoting a true one.');
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const body = await renderPlatformSkill();
|
||||||
|
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
|
||||||
|
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
|
||||||
|
if (existing === body) {
|
||||||
|
console.log('skills:platform — already current');
|
||||||
|
} else {
|
||||||
|
writeFileSync(SKILL_PATH, body);
|
||||||
|
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mapPalette } from './themes/map';
|
||||||
|
import type { Palette } from './themes/palette';
|
||||||
|
import { paletteFromRecord, parseTomlText } from './themes/palette';
|
||||||
|
import { deriveOmarchyPalette } from './themes/from-ansi';
|
||||||
|
import { parseAlacrittyToml, parseColor16Toml, parseGhosttyConf } from './themes/ansi-source';
|
||||||
|
import { contrast, ensureContrast, TEXT, UI } from './themes/contrast';
|
||||||
|
|
||||||
|
// Port community Omarchy themes from omarchythemes.com into the same 84-token shape the official
|
||||||
|
// pipeline produces — see `generate-themes.ts` for that one. This is its sibling for themes that have no
|
||||||
|
// `colors.toml`: a single-theme repo instead ships per-app configs, so `from-ansi.ts` reconstructs the
|
||||||
|
// same `Palette` from `alacritty.toml` or `ghostty.conf`, and everything downstream (mapping, contrast
|
||||||
|
// checking) is identical. What's NOT identical: these are unofficial, one author's repo can vanish or
|
||||||
|
// change, and the reconstruction is a heuristic, not Omarchy's own curated palette — every generated file
|
||||||
|
// says so in its header.
|
||||||
|
//
|
||||||
|
// bun scripts/port-community-themes.ts [--limit=N] [--slug=<one-slug>] [--redo]
|
||||||
|
//
|
||||||
|
// State lives in `scripts/themes/community-manifest.json` — one entry per theme successfully ported, so a
|
||||||
|
// re-run only fetches what's missing (or everything, with --redo) and the registry/index blocks are
|
||||||
|
// always rewritten fresh from the whole manifest, never hand-edited.
|
||||||
|
|
||||||
|
const CSS_THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
|
||||||
|
const INDEX_CSS = join(CSS_THEME_DIR, '..', 'index.css');
|
||||||
|
const REGISTRY = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
|
||||||
|
const MANIFEST_PATH = join(import.meta.dir, 'themes', 'community-manifest.json');
|
||||||
|
const SITE = 'https://omarchythemes.com';
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
|
||||||
|
const REDO = process.argv.includes('--redo');
|
||||||
|
const ONLY_SLUG = process.argv.find((a) => a.startsWith('--slug='))?.split('=')[1];
|
||||||
|
const LIMIT = Number(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] ?? Infinity);
|
||||||
|
|
||||||
|
type ManifestEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
repo: string;
|
||||||
|
/** Omitted means GitHub, which is where all but a couple of these live. */
|
||||||
|
host?: 'gitlab';
|
||||||
|
kind: string;
|
||||||
|
dark: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Repo = { host: 'github' | 'gitlab'; path: string };
|
||||||
|
|
||||||
|
const repoUrl = (r: Repo): string => `https://${r.host === 'gitlab' ? 'gitlab.com' : 'github.com'}/${r.path}`;
|
||||||
|
|
||||||
|
// Same pairs `generate-themes.ts` checks, kept in sync by hand since this is the only other place that
|
||||||
|
// needs them and duplicating a 20-line array beats a shared module for one caller each.
|
||||||
|
const PAIRS: [string, string, number][] = [
|
||||||
|
['--foreground', '--background', TEXT],
|
||||||
|
['--card-foreground', '--card', TEXT],
|
||||||
|
['--popover-foreground', '--popover', TEXT],
|
||||||
|
['--muted-foreground', '--muted', TEXT],
|
||||||
|
['--accent-foreground', '--accent', TEXT],
|
||||||
|
['--primary-foreground', '--primary', TEXT],
|
||||||
|
['--secondary-foreground', '--secondary', TEXT],
|
||||||
|
['--cta-foreground', '--cta', TEXT],
|
||||||
|
['--success-foreground', '--success', TEXT],
|
||||||
|
['--warning-foreground', '--warning', TEXT],
|
||||||
|
['--destructive-foreground', '--destructive', TEXT],
|
||||||
|
['--info-foreground', '--info', TEXT],
|
||||||
|
['--busy-foreground', '--busy', TEXT],
|
||||||
|
['--busy-ink', '--background', TEXT],
|
||||||
|
['--input', '--background', 1.35],
|
||||||
|
['--ring', '--background', UI],
|
||||||
|
];
|
||||||
|
|
||||||
|
const title = (id: string): string =>
|
||||||
|
id
|
||||||
|
.split('-')
|
||||||
|
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
function loadManifest(): ManifestEntry[] {
|
||||||
|
if (!existsSync(MANIFEST_PATH)) return [];
|
||||||
|
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveManifest(entries: ManifestEntry[]): void {
|
||||||
|
writeFileSync(MANIFEST_PATH, `${JSON.stringify(entries, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghApiDefaultBranch(repo: string): string {
|
||||||
|
try {
|
||||||
|
const out = execSync(`gh api repos/${repo} --jq .default_branch`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
});
|
||||||
|
return out.trim() || 'main';
|
||||||
|
} catch {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitlabDefaultBranch(path: string): Promise<string> {
|
||||||
|
const json = await fetchText(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`);
|
||||||
|
if (!json) return 'main';
|
||||||
|
try {
|
||||||
|
return (JSON.parse(json).default_branch as string) || 'main';
|
||||||
|
} catch {
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
return await res.text();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverRepo(slug: string): Promise<Repo | undefined> {
|
||||||
|
const html = await fetchText(`${SITE}/themes/${slug}`);
|
||||||
|
if (!html) return undefined;
|
||||||
|
const gh = html.match(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/);
|
||||||
|
if (gh) return { host: 'github', path: `${gh[1]}/${gh[2]}` };
|
||||||
|
// A couple of authors host on GitLab instead. Its group paths can nest, hence the greedier match.
|
||||||
|
const gl = html.match(/https:\/\/gitlab\.com\/([\w.-]+(?:\/[\w.-]+)+?)(?=["'\s<]|$)/);
|
||||||
|
if (gl) return { host: 'gitlab', path: gl[1]!.replace(/\.git$/, '') };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceFor(
|
||||||
|
repo: Repo,
|
||||||
|
slug: string,
|
||||||
|
): Promise<{ palette: Palette; kind: string; derived: boolean } | undefined> {
|
||||||
|
const branch = repo.host === 'gitlab' ? await gitlabDefaultBranch(repo.path) : ghApiDefaultBranch(repo.path);
|
||||||
|
const raw = (p: string) =>
|
||||||
|
repo.host === 'gitlab'
|
||||||
|
? `https://gitlab.com/${repo.path}/-/raw/${branch}/${p}`
|
||||||
|
: `https://raw.githubusercontent.com/${repo.path}/${branch}/${p}`;
|
||||||
|
|
||||||
|
// Most repos hold one theme at the root. A few are packs that nest one directory per theme, so also
|
||||||
|
// look under the directory named after THIS slug — an exact match is the only safe pick, since a pack
|
||||||
|
// with variant names of its own (`abyss-dark`, `lagoon-light`, …) has no one theme that is "the" one.
|
||||||
|
const prefixes = ['', `themes/${slug}/`, `omarchy/themes/${slug}/`, `${slug}/`];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
// `colors.toml` is ambiguous across community repos: it might genuinely be Omarchy 4's own semantic
|
||||||
|
// schema (in which case no derivation is needed at all — same quality as the official 22), or it might
|
||||||
|
// reuse the filename for an unrelated flat `color0..color15` ANSI scheme. Try the real schema first;
|
||||||
|
// only fall through to the ANSI reading if the required keys aren't there.
|
||||||
|
const tomlText = await fetchText(raw(`${prefix}colors.toml`));
|
||||||
|
if (tomlText) {
|
||||||
|
const kv = parseTomlText(tomlText);
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
palette: paletteFromRecord(kv, `${repo.path}/${prefix}colors.toml`),
|
||||||
|
kind: 'colors.toml (Omarchy schema)',
|
||||||
|
derived: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const ansi = parseColor16Toml(kv);
|
||||||
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'colors.toml (16-colour ANSI)', derived: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'alacritty.toml',
|
||||||
|
'.config/alacritty.toml',
|
||||||
|
'alacritty/alacritty.toml',
|
||||||
|
'config/alacritty.toml',
|
||||||
|
]) {
|
||||||
|
const text = await fetchText(raw(`${prefix}${path}`));
|
||||||
|
if (!text) continue;
|
||||||
|
const ansi = parseAlacrittyToml(text);
|
||||||
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'alacritty.toml', derived: true };
|
||||||
|
}
|
||||||
|
for (const path of ['ghostty.conf', 'ghostty-theme', '.config/ghostty/config']) {
|
||||||
|
const text = await fetchText(raw(`${prefix}${path}`));
|
||||||
|
if (!text) continue;
|
||||||
|
const ansi = parseGhosttyConf(text);
|
||||||
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'ghostty.conf', derived: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function portOne(slug: string): Promise<{ entry: ManifestEntry; css: string } | { skip: string }> {
|
||||||
|
const repo = await discoverRepo(slug);
|
||||||
|
if (!repo) return { skip: 'no github/gitlab repo link found on its omarchythemes.com page' };
|
||||||
|
|
||||||
|
const source = await sourceFor(repo, slug);
|
||||||
|
if (!source) return { skip: `no parseable colors.toml/alacritty.toml/ghostty.conf in ${repo.path}` };
|
||||||
|
|
||||||
|
const palette = source.palette;
|
||||||
|
const tokens = mapPalette(palette);
|
||||||
|
|
||||||
|
// `--input` carries a real accessibility requirement (a field boundary you can't see) where `--border`
|
||||||
|
// is decoration and isn't held to one — see the README. `map.ts` derives both from the same mix for
|
||||||
|
// every theme, official ones included, and it normally clears 1.35:1 on its own; a few derived palettes
|
||||||
|
// land just under it, so nudge only this one rather than loosening the check.
|
||||||
|
tokens['--input'] = ensureContrast(tokens['--input']!, tokens['--background']!, 1.35);
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const [fgKey, bgKey, min] of PAIRS) {
|
||||||
|
const fg = tokens[fgKey]!;
|
||||||
|
const bg = tokens[bgKey]!;
|
||||||
|
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
|
||||||
|
const ratio = contrast(fg, bg);
|
||||||
|
if (ratio < min) failures.push(`${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
|
||||||
|
}
|
||||||
|
if (failures.length > 0) return { skip: `contrast failed after derivation: ${failures.join('; ')}` };
|
||||||
|
|
||||||
|
const name = title(slug);
|
||||||
|
const body = Object.entries(tokens)
|
||||||
|
.map(([k, v]) => ` ${k}: ${v};`)
|
||||||
|
.join('\n');
|
||||||
|
const modeLine = source.kind.startsWith('colors.toml (Omarchy')
|
||||||
|
? `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}) — the source repo's own colors.toml says so directly.`
|
||||||
|
: `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}), by luminance of its own background — the source repo carries no mode flag of its own to read.`;
|
||||||
|
const provenanceLine = source.derived
|
||||||
|
? `went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4\n colors.toml — some tokens here are reconstructed, not authored.`
|
||||||
|
: `came straight from the repo's own colors.toml — the same schema and reader the official 22 themes\n use, so nothing here is reconstructed.`;
|
||||||
|
const css = `/*
|
||||||
|
${name} — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
${modeLine}
|
||||||
|
|
||||||
|
Source: ${repoUrl(repo)} (${source.kind}), read by scripts/port-community-themes.ts. Values
|
||||||
|
${provenanceLine} Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='${slug}'] {
|
||||||
|
${body}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry: {
|
||||||
|
id: slug,
|
||||||
|
name,
|
||||||
|
repo: repo.path,
|
||||||
|
...(repo.host === 'gitlab' ? { host: 'gitlab' as const } : {}),
|
||||||
|
kind: source.kind,
|
||||||
|
dark: palette.mode === 'dark',
|
||||||
|
},
|
||||||
|
css,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteIndexCss(entries: ManifestEntry[]): void {
|
||||||
|
const index = readFileSync(INDEX_CSS, 'utf8');
|
||||||
|
const marker = '/* Community themes';
|
||||||
|
const startIdx = index.indexOf(marker);
|
||||||
|
const head = startIdx >= 0 ? index.slice(0, startIdx) : index.replace(/\n@import '\.\/chrome\.css';\n$/, '\n');
|
||||||
|
const imports = entries.map((e) => `@import './themes/${e.id}.css';`).join('\n');
|
||||||
|
writeFileSync(
|
||||||
|
INDEX_CSS,
|
||||||
|
`${head}/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteRegistry(entries: ManifestEntry[]): void {
|
||||||
|
const registry = readFileSync(REGISTRY, 'utf8');
|
||||||
|
const START = ' /* COMMUNITY THEMES — start */';
|
||||||
|
const END = ' /* COMMUNITY THEMES — end */';
|
||||||
|
const rows = entries
|
||||||
|
.map((e) => ` { id: '${e.id}', name: '${e.name}', family: '${e.name}', applyDarkClass: ${e.dark} },`)
|
||||||
|
.join('\n');
|
||||||
|
const block = `${START}\n${rows}\n${END}`;
|
||||||
|
|
||||||
|
if (registry.includes(START) && registry.includes(END)) {
|
||||||
|
// Snap to the start of the marker's own line — `indexOf` on a string with leading spaces can match
|
||||||
|
// partway into stray indentation left by an earlier write, which would otherwise accumulate.
|
||||||
|
const startLineBegin = registry.lastIndexOf('\n', registry.indexOf(START)) + 1;
|
||||||
|
const before = registry.slice(0, startLineBegin);
|
||||||
|
const after = registry.slice(registry.indexOf(END) + END.length);
|
||||||
|
writeFileSync(REGISTRY, `${before}${block}${after}`);
|
||||||
|
} else {
|
||||||
|
const anchor = '] as const;';
|
||||||
|
const idx = registry.indexOf(anchor);
|
||||||
|
writeFileSync(REGISTRY, `${registry.slice(0, idx)}${block}\n${registry.slice(idx)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slugsFile = process.argv.find((a) => a.startsWith('--slugs='))?.split('=')[1];
|
||||||
|
const allSlugs: string[] = slugsFile ? readFileSync(slugsFile, 'utf8').trim().split('\n').filter(Boolean) : [];
|
||||||
|
|
||||||
|
const manifest = loadManifest();
|
||||||
|
const known = new Set(manifest.map((e) => e.id));
|
||||||
|
// Never take a slug the OFFICIAL/hand-authored part of the registry already owns — that's everything
|
||||||
|
// before this script's own block, which it is free to redo.
|
||||||
|
const registryHead = readFileSync(REGISTRY, 'utf8').split('/* COMMUNITY THEMES — start */')[0]!;
|
||||||
|
const registryIds = new Set(Array.from(registryHead.matchAll(/id: '([\w-]+)'/g)).map((m) => m[1]!));
|
||||||
|
|
||||||
|
let todo = ONLY_SLUG ? [ONLY_SLUG] : allSlugs.filter((s) => !registryIds.has(s) && (REDO || !known.has(s)));
|
||||||
|
todo = todo.slice(0, LIMIT);
|
||||||
|
|
||||||
|
console.log(`community theme port: ${todo.length} to try, ${manifest.length} already in the manifest`);
|
||||||
|
|
||||||
|
const results: ManifestEntry[] = [...manifest.filter((e) => !todo.includes(e.id))];
|
||||||
|
let done = 0;
|
||||||
|
let ok = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (todo.length > 0) {
|
||||||
|
const slug = todo.shift();
|
||||||
|
if (!slug) break;
|
||||||
|
try {
|
||||||
|
const result = await portOne(slug);
|
||||||
|
done++;
|
||||||
|
if ('skip' in result) {
|
||||||
|
skipped++;
|
||||||
|
console.log(`[${done}] SKIP ${slug} — ${result.skip}`);
|
||||||
|
} else {
|
||||||
|
writeFileSync(join(CSS_THEME_DIR, `${slug}.css`), result.css);
|
||||||
|
results.push(result.entry);
|
||||||
|
ok++;
|
||||||
|
console.log(`[${done}] OK ${slug} — from ${result.entry.repo} (${result.entry.kind})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
done++;
|
||||||
|
skipped++;
|
||||||
|
console.log(`[${done}] ERROR ${slug} — ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
||||||
|
|
||||||
|
results.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
saveManifest(results);
|
||||||
|
rewriteIndexCss(results);
|
||||||
|
rewriteRegistry(results);
|
||||||
|
|
||||||
|
console.log(`\ndone: ${ok} ported, ${skipped} skipped, ${results.length} total in manifest`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Reading a community theme's actual shipped format.
|
||||||
|
//
|
||||||
|
// Omarchy's own 22 themes carry a `colors.toml` in the semantic shape `palette.ts` expects — three
|
||||||
|
// background depths, four foreground weights, an accent, a selection, a muted. A community theme on
|
||||||
|
// omarchythemes.com ships none of that: it's per-app config files in the classic 16-ANSI-slot style,
|
||||||
|
// because that's what predates Omarchy 4's own format and what most single-theme repos still use.
|
||||||
|
//
|
||||||
|
// This file reads the two shapes that cover almost every repo: `alacritty.toml` (structured, typed,
|
||||||
|
// preferred) and `ghostty.conf`/`ghostty-theme` (flat `key = value` lines, fallback). Either becomes an
|
||||||
|
// `AnsiSource` — the 16 ANSI slots plus primary background/foreground — which `from-ansi.ts` turns into
|
||||||
|
// the same `Palette` shape the official pipeline consumes, so every downstream guarantee (contrast
|
||||||
|
// checking, the 84-token mapping) applies unchanged.
|
||||||
|
|
||||||
|
export type AnsiSource = {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
black: string;
|
||||||
|
red: string;
|
||||||
|
green: string;
|
||||||
|
yellow: string;
|
||||||
|
blue: string;
|
||||||
|
magenta: string;
|
||||||
|
cyan: string;
|
||||||
|
white: string;
|
||||||
|
brightBlack: string;
|
||||||
|
brightRed: string;
|
||||||
|
brightGreen: string;
|
||||||
|
brightYellow: string;
|
||||||
|
brightBlue: string;
|
||||||
|
brightMagenta: string;
|
||||||
|
brightCyan: string;
|
||||||
|
brightWhite: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `#rrggbb` is standard; `0xrrggbb` shows up in a handful of community alacritty.toml files (their
|
||||||
|
// authors apparently ported from a scheme that used C-style hex literals).
|
||||||
|
const HEX = /#[0-9a-fA-F]{3,8}|0x[0-9a-fA-F]{3,8}/;
|
||||||
|
|
||||||
|
function normalizeHex(v: string): string | undefined {
|
||||||
|
const m = v.match(HEX);
|
||||||
|
if (!m) return undefined;
|
||||||
|
let h = m[0].replace(/^(#|0x)/, '').toLowerCase();
|
||||||
|
if (h.length === 3)
|
||||||
|
h = h
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('');
|
||||||
|
if (h.length >= 6) h = h.slice(0, 6);
|
||||||
|
return `#${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliberately small TOML reader: flat `key = "value"` lines plus `[section]` headers. */
|
||||||
|
export function parseAlacrittyToml(text: string): AnsiSource | undefined {
|
||||||
|
let section = '';
|
||||||
|
const rows: Record<string, Record<string, string>> = {};
|
||||||
|
for (const rawLine of text.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
const sectionMatch = line.match(/^\[([\w.]+)]$/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
section = sectionMatch[1]!;
|
||||||
|
rows[section] ??= {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Quoted specifically, not "anything but a comment" — a hex value starts with `#`, the same
|
||||||
|
// character that opens a trailing comment, so the two can only be told apart by the quotes.
|
||||||
|
const kv = line.match(/^([\w-]+)\s*=\s*(['"])(.*?)\2/);
|
||||||
|
if (kv && section) {
|
||||||
|
const hex = normalizeHex(kv[3]!);
|
||||||
|
if (hex) rows[section]![kv[1]!] = hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = rows['colors.primary'];
|
||||||
|
const normal = rows['colors.normal'];
|
||||||
|
const bright = rows['colors.bright'];
|
||||||
|
if (!primary?.background || !primary?.foreground || !normal || !bright) return undefined;
|
||||||
|
|
||||||
|
const need = (r: Record<string, string>, k: string): string | undefined => r[k];
|
||||||
|
const n = (k: string) => need(normal, k);
|
||||||
|
const b = (k: string) => need(bright, k);
|
||||||
|
if (
|
||||||
|
!n('black') ||
|
||||||
|
!n('red') ||
|
||||||
|
!n('green') ||
|
||||||
|
!n('yellow') ||
|
||||||
|
!n('blue') ||
|
||||||
|
!n('magenta') ||
|
||||||
|
!n('cyan') ||
|
||||||
|
!n('white')
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
if (
|
||||||
|
!b('black') ||
|
||||||
|
!b('red') ||
|
||||||
|
!b('green') ||
|
||||||
|
!b('yellow') ||
|
||||||
|
!b('blue') ||
|
||||||
|
!b('magenta') ||
|
||||||
|
!b('cyan') ||
|
||||||
|
!b('white')
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: primary.background,
|
||||||
|
foreground: primary.foreground,
|
||||||
|
black: n('black')!,
|
||||||
|
red: n('red')!,
|
||||||
|
green: n('green')!,
|
||||||
|
yellow: n('yellow')!,
|
||||||
|
blue: n('blue')!,
|
||||||
|
magenta: n('magenta')!,
|
||||||
|
cyan: n('cyan')!,
|
||||||
|
white: n('white')!,
|
||||||
|
brightBlack: b('black')!,
|
||||||
|
brightRed: b('red')!,
|
||||||
|
brightGreen: b('green')!,
|
||||||
|
brightYellow: b('yellow')!,
|
||||||
|
brightBlue: b('blue')!,
|
||||||
|
brightMagenta: b('magenta')!,
|
||||||
|
brightCyan: b('cyan')!,
|
||||||
|
brightWhite: b('white')!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ghostty's config: flat `key = value`, ANSI palette as sixteen `palette = N=#hex` lines. */
|
||||||
|
export function parseGhosttyConf(text: string): AnsiSource | undefined {
|
||||||
|
let background: string | undefined;
|
||||||
|
let foreground: string | undefined;
|
||||||
|
const palette: Record<number, string> = {};
|
||||||
|
|
||||||
|
for (const rawLine of text.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (line.startsWith('#')) continue;
|
||||||
|
const bg = line.match(/^background\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||||
|
if (bg) background = normalizeHex(bg[1]!);
|
||||||
|
const fg = line.match(/^foreground\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||||
|
if (fg) foreground = normalizeHex(fg[1]!);
|
||||||
|
const pal = line.match(/^palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||||
|
if (pal) {
|
||||||
|
const hex = normalizeHex(pal[2]!);
|
||||||
|
if (hex) palette[Number(pal[1])] = hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!background || !foreground) return undefined;
|
||||||
|
for (let i = 0; i <= 15; i++) if (!palette[i]) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
black: palette[0]!,
|
||||||
|
red: palette[1]!,
|
||||||
|
green: palette[2]!,
|
||||||
|
yellow: palette[3]!,
|
||||||
|
blue: palette[4]!,
|
||||||
|
magenta: palette[5]!,
|
||||||
|
cyan: palette[6]!,
|
||||||
|
white: palette[7]!,
|
||||||
|
brightBlack: palette[8]!,
|
||||||
|
brightRed: palette[9]!,
|
||||||
|
brightGreen: palette[10]!,
|
||||||
|
brightYellow: palette[11]!,
|
||||||
|
brightBlue: palette[12]!,
|
||||||
|
brightMagenta: palette[13]!,
|
||||||
|
brightCyan: palette[14]!,
|
||||||
|
brightWhite: palette[15]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A third shape some community repos name `colors.toml` too, unrelated to Omarchy 4's own — flat
|
||||||
|
* `color0`..`color15` in standard ANSI order, plus `background`/`foreground`. Try this only after
|
||||||
|
* `paletteFromRecord` (the real Omarchy schema) has already failed on the same file: the two are
|
||||||
|
* distinguished only by which keys are present, and a file can't be both.
|
||||||
|
*/
|
||||||
|
export function parseColor16Toml(t: Record<string, string>): AnsiSource | undefined {
|
||||||
|
const at = (i: number): string | undefined => (t[`color${i}`] ? normalizeHex(t[`color${i}`]!) : undefined);
|
||||||
|
const background = t.background ? normalizeHex(t.background) : undefined;
|
||||||
|
const foreground = t.foreground ? normalizeHex(t.foreground) : undefined;
|
||||||
|
if (!background || !foreground) return undefined;
|
||||||
|
const slots = Array.from({ length: 16 }, (_, i) => at(i));
|
||||||
|
if (slots.some((s) => !s)) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
black: slots[0]!,
|
||||||
|
red: slots[1]!,
|
||||||
|
green: slots[2]!,
|
||||||
|
yellow: slots[3]!,
|
||||||
|
blue: slots[4]!,
|
||||||
|
magenta: slots[5]!,
|
||||||
|
cyan: slots[6]!,
|
||||||
|
white: slots[7]!,
|
||||||
|
brightBlack: slots[8]!,
|
||||||
|
brightRed: slots[9]!,
|
||||||
|
brightGreen: slots[10]!,
|
||||||
|
brightYellow: slots[11]!,
|
||||||
|
brightBlue: slots[12]!,
|
||||||
|
brightMagenta: slots[13]!,
|
||||||
|
brightCyan: slots[14]!,
|
||||||
|
brightWhite: slots[15]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Palette } from './palette';
|
||||||
|
import type { AnsiSource } from './ansi-source';
|
||||||
|
import { UI, contrast, ensureContrast, luminance, mix, parseHex } from './contrast';
|
||||||
|
|
||||||
|
// An `AnsiSource` (16 ANSI slots + primary bg/fg) has no notion of "three background depths" or "an
|
||||||
|
// accent" — Omarchy 4's `colors.toml` is a richer, hand-curated shape that a sixteen-slot terminal scheme
|
||||||
|
// was never designed to fill. This derives one anyway, by role rather than by guessing a fixed value:
|
||||||
|
//
|
||||||
|
// - accent is whichever blue-ish slot actually stands out from the background (every official palette
|
||||||
|
// picks blue too — this just guards against a scheme that reused the background for it).
|
||||||
|
// - the three depths are the background pushed a fixed distance toward black or white, in the direction
|
||||||
|
// each mode's surfaces already move (dark themes rise lighter, light themes recede darker — see the
|
||||||
|
// ratios comment below, calibrated against Nord and Rosé Pine's real colors.toml).
|
||||||
|
// - the three foreground weights are the foreground pulled toward the background by a fixed fraction,
|
||||||
|
// standing in for "faint", "muted" and "strong" text.
|
||||||
|
//
|
||||||
|
// None of this has to be exact. `mapPalette`'s `ensureContrast` re-checks every text/surface pair
|
||||||
|
// afterward and nudges anything that fails — the approximation only has to be plausible, not correct.
|
||||||
|
|
||||||
|
function pickAccent(a: AnsiSource): string {
|
||||||
|
const candidates = [a.blue, a.brightBlue, a.cyan, a.brightCyan];
|
||||||
|
const best = candidates.reduce((hi, c) => (contrast(c, a.background) > contrast(hi, a.background) ? c : hi));
|
||||||
|
// `--ring` is this colour laid directly on `--background` (see `map.ts`), and it's in the contract's
|
||||||
|
// PAIRS check same as the official themes — so guarantee it the way `ensureContrast` guarantees text,
|
||||||
|
// rather than only guarding against a background-coloured accent.
|
||||||
|
return ensureContrast(best, a.background, UI);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOmarchyPalette(a: AnsiSource): Palette {
|
||||||
|
const dark = luminance(parseHex(a.background)) < 0.5;
|
||||||
|
const mode: Palette['mode'] = dark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
const darkerBackground = dark ? mix(a.background, '#000000', 0.45) : mix(a.background, '#000000', 0.1);
|
||||||
|
const darkBackground = dark ? mix(a.background, '#000000', 0.22) : mix(a.background, '#000000', 0.06);
|
||||||
|
const lighterBackground = dark ? mix(a.background, '#ffffff', 0.07) : mix(a.background, '#000000', 0.035);
|
||||||
|
|
||||||
|
// Faintest → strongest, all pulled toward the background rather than toward a fixed grey, so a warm
|
||||||
|
// palette's dim text stays warm instead of turning neutral.
|
||||||
|
const darkForeground = mix(a.foreground, a.background, 0.55);
|
||||||
|
const lightForeground = mix(a.foreground, a.background, 0.3);
|
||||||
|
|
||||||
|
const accent = pickAccent(a);
|
||||||
|
const muted = contrast(a.brightBlack, a.background) >= 1.15 ? a.brightBlack : mix(a.foreground, a.background, 0.6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
accent,
|
||||||
|
// Unused by `mapPalette` today (grep confirms `p.selection` has no reader) — kept only because the
|
||||||
|
// `Palette` type requires it.
|
||||||
|
selection: mix(a.background, a.foreground, 0.2),
|
||||||
|
muted,
|
||||||
|
background: a.background,
|
||||||
|
darkBackground,
|
||||||
|
darkerBackground,
|
||||||
|
lighterBackground,
|
||||||
|
foreground: a.foreground,
|
||||||
|
darkForeground,
|
||||||
|
lightForeground,
|
||||||
|
brightForeground: a.foreground,
|
||||||
|
red: a.red,
|
||||||
|
yellow: a.yellow,
|
||||||
|
green: a.green,
|
||||||
|
cyan: a.cyan,
|
||||||
|
blue: a.blue,
|
||||||
|
magenta: a.magenta,
|
||||||
|
// Standard ANSI has no orange slot. Mixing red and yellow reads better as "busy" than falling back to
|
||||||
|
// plain yellow the way `loadPalette` does for the 3 official themes that omit it.
|
||||||
|
orange: mix(a.red, a.yellow, 0.5),
|
||||||
|
brightRed: a.brightRed,
|
||||||
|
brightYellow: a.brightYellow,
|
||||||
|
brightGreen: a.brightGreen,
|
||||||
|
brightCyan: a.brightCyan,
|
||||||
|
brightBlue: a.brightBlue,
|
||||||
|
brightMagenta: a.brightMagenta,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ export function mapPalette(p: Palette): Tokens {
|
|||||||
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
|
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
|
||||||
// person eyeballing a screenshot would have shipped.
|
// person eyeballing a screenshot would have shipped.
|
||||||
const fgMuted = ensureContrast(p.lightForeground, s.high);
|
const fgMuted = ensureContrast(p.lightForeground, s.high);
|
||||||
|
// Same reasoning as `fgMuted`, for the same surface: `--accent` and `--secondary` are BOTH `s.high`,
|
||||||
|
// so their text has to be checked against `s.high` too. It was deriving from `fgRaised` — the weight
|
||||||
|
// for `s.raised` — which is the identical near-miss the comment above describes, just one token over.
|
||||||
|
// A no-op for all 22 official palettes (each already clears 4.5:1 on `s.high` by 1.4x or more, and
|
||||||
|
// `ensureContrast` returns its input untouched when it passes); what it fixes is the ported palettes
|
||||||
|
// whose foreground sits between the two surfaces' thresholds.
|
||||||
|
const fgHigh = ensureContrast(p.foreground, s.high);
|
||||||
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
|
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,13 +95,13 @@ export function mapPalette(p: Palette): Tokens {
|
|||||||
'--muted': s.high,
|
'--muted': s.high,
|
||||||
'--muted-foreground': fgMuted,
|
'--muted-foreground': fgMuted,
|
||||||
'--accent': s.high,
|
'--accent': s.high,
|
||||||
'--accent-foreground': fgRaised,
|
'--accent-foreground': fgHigh,
|
||||||
|
|
||||||
// ── Actions ──
|
// ── Actions ──
|
||||||
'--primary': accent,
|
'--primary': accent,
|
||||||
'--primary-foreground': on(accent),
|
'--primary-foreground': on(accent),
|
||||||
'--secondary': s.high,
|
'--secondary': s.high,
|
||||||
'--secondary-foreground': fgRaised,
|
'--secondary-foreground': fgHigh,
|
||||||
'--cta': accent,
|
'--cta': accent,
|
||||||
'--cta-foreground': on(accent),
|
'--cta-foreground': on(accent),
|
||||||
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
|
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
|
||||||
|
|||||||
@@ -38,21 +38,28 @@ export type Palette = {
|
|||||||
brightMagenta: string;
|
brightMagenta: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A deliberately small TOML reader: this file is flat `key = "value"` and nothing else. */
|
/**
|
||||||
function readToml(path: string): Record<string, string> {
|
* A deliberately small TOML reader: this file is flat `key = "value"` and nothing else.
|
||||||
|
*
|
||||||
|
* Exported (not just used by `loadPalette` below) because a community theme can ship this exact shape
|
||||||
|
* too — see `port-community-themes.ts`, which reads it from a fetched string rather than a file path.
|
||||||
|
*/
|
||||||
|
export function parseTomlText(text: string): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
for (const line of text.split('\n')) {
|
||||||
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
|
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
|
||||||
if (m) out[m[1]!] = m[2]!;
|
// basecamp/omarchy's own files are already lowercase hex; a community `colors.toml` isn't guaranteed
|
||||||
|
// to be, so normalize here rather than leaving the two sources visibly inconsistent.
|
||||||
|
if (m) out[m[1]!] = m[2]!.toLowerCase();
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPalette(tomlPath: string): Palette {
|
/** The 24-key contract, given an already-parsed record rather than a path — see `parseTomlText`. */
|
||||||
const t = readToml(tomlPath);
|
export function paletteFromRecord(t: Record<string, string>, label: string): Palette {
|
||||||
const need = (key: string): string => {
|
const need = (key: string): string => {
|
||||||
const v = t[key];
|
const v = t[key];
|
||||||
if (!v) throw new Error(`${tomlPath}: missing "${key}"`);
|
if (!v) throw new Error(`${label}: missing "${key}"`);
|
||||||
return v;
|
return v;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,3 +91,7 @@ export function loadPalette(tomlPath: string): Palette {
|
|||||||
brightMagenta: need('bright_magenta'),
|
brightMagenta: need('bright_magenta'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadPalette(tomlPath: string): Palette {
|
||||||
|
return paletteFromRecord(parseTomlText(readFileSync(tomlPath, 'utf8')), tomlPath);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
|
||||||
|
|
||||||
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
|
// /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
|
||||||
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
|
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
|
||||||
// "Route, surface, placement" and "Layout, v1".
|
// "Route, surface, placement".
|
||||||
|
|
||||||
export const CommandHqScreen = () => {
|
export const CommandHqScreen = () => {
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Move a saved three-column layout onto the two-column one.
|
||||||
|
|
||||||
|
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
|
||||||
|
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
|
||||||
|
appType check, and why replacing outright is the safe direction on a `locked` screen.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace.isLoaded) return;
|
||||||
|
if (!isLegacyThreeColumn(workspace.value)) return;
|
||||||
|
workspace.setValue(defaultLayout);
|
||||||
|
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
|
|||||||
@@ -1,14 +1,60 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
|
/*
|
||||||
// detail pane gets the most room because it is a live chat transcript once something is selected.
|
Two columns, with the navigation stacked into the left one.
|
||||||
|
|
||||||
|
── Why it changed from three ──
|
||||||
|
|
||||||
|
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
|
||||||
|
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
|
||||||
|
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
|
||||||
|
enough to see whole.
|
||||||
|
|
||||||
|
Now: commanders on top, categories beneath them, transcript taking three quarters.
|
||||||
|
|
||||||
|
── Why the categories are BELOW the commanders ──
|
||||||
|
|
||||||
|
Because the commanders are the thing you came for and the categories are how you narrow them when there
|
||||||
|
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
|
||||||
|
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
|
||||||
|
the categories can grow their own scrollbar without ever taking room from the commanders.
|
||||||
|
*/
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'command-hq-root',
|
id: 'command-hq-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
|
{
|
||||||
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
|
node: {
|
||||||
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
|
type: 'group',
|
||||||
|
id: 'command-hq-nav',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
|
||||||
|
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 24,
|
||||||
|
},
|
||||||
|
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this a saved copy of the three-column layout this replaced?
|
||||||
|
*
|
||||||
|
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
|
||||||
|
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
|
||||||
|
* horizontal root with THREE children, and the new one has two.
|
||||||
|
*
|
||||||
|
* It terminates for that reason: the replacement does not satisfy the test.
|
||||||
|
*
|
||||||
|
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
|
||||||
|
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
|
||||||
|
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
|
||||||
|
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
|
||||||
|
* copies of one picker.
|
||||||
|
*/
|
||||||
|
export function isLegacyThreeColumn(node: LayoutNode): boolean {
|
||||||
|
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Background } from './Background';
|
|||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
|
||||||
import { Spotlight } from './Spotlight/Spotlight';
|
import { Spotlight } from './Spotlight/Spotlight';
|
||||||
|
import { ShortcutsModal } from './ShortcutsModal';
|
||||||
import { useIsTouch } from './useIsTouch';
|
import { useIsTouch } from './useIsTouch';
|
||||||
import { usePageTitleSync } from '@/state/usePageTitle';
|
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||||
import { RouteGate } from './RouteGate';
|
import { RouteGate } from './RouteGate';
|
||||||
@@ -51,6 +52,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||||
<Spotlight />
|
<Spotlight />
|
||||||
|
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
|
||||||
|
panel, an error boundary, or mid-navigation. */}
|
||||||
|
<ShortcutsModal />
|
||||||
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
||||||
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
||||||
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { JobsIndicator } from './JobsIndicator';
|
|||||||
import { ThemeSwitcher } from './ThemeSwitcher';
|
import { ThemeSwitcher } from './ThemeSwitcher';
|
||||||
import { RescanButton } from '../Rescan/RescanButton';
|
import { RescanButton } from '../Rescan/RescanButton';
|
||||||
import { usePageTitle } from '@/state/usePageTitle';
|
import { usePageTitle } from '@/state/usePageTitle';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
import { useThemeAsset } from '@/components/ui/ThemeProvider';
|
||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ type HeaderProps = {
|
|||||||
export function Header({ dockItems, hidden }: HeaderProps) {
|
export function Header({ dockItems, hidden }: HeaderProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
|
const { user } = useAuth();
|
||||||
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
|
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
|
||||||
const logo = useThemeAsset('--logo-src');
|
const logo = useThemeAsset('--logo-src');
|
||||||
|
|
||||||
@@ -100,9 +102,15 @@ export function Header({ dockItems, hidden }: HeaderProps) {
|
|||||||
*/}
|
*/}
|
||||||
{/* Bug Report — hidden */}
|
{/* Bug Report — hidden */}
|
||||||
{/* <BugReportButton /> */}
|
{/* <BugReportButton /> */}
|
||||||
|
<ThemeSwitcher />
|
||||||
|
{/* Owner only, and tidiness rather than access control — /jobs stays reachable and the rescan
|
||||||
|
endpoint is gated server-side, because a hidden button is not a permission. */}
|
||||||
|
{user?.role === 'Super Admin' && (
|
||||||
|
<>
|
||||||
<JobsIndicator />
|
<JobsIndicator />
|
||||||
<RescanButton />
|
<RescanButton />
|
||||||
<ThemeSwitcher />
|
</>
|
||||||
|
)}
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Palette, Check } from 'lucide-react';
|
import { Check, Star } from 'lucide-react';
|
||||||
|
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import type { Theme } from '@/components/ui/ThemeProvider';
|
|
||||||
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
||||||
|
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -21,7 +23,7 @@ const {
|
|||||||
*
|
*
|
||||||
* ── The overlay ──
|
* ── The overlay ──
|
||||||
*
|
*
|
||||||
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
||||||
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
||||||
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
||||||
* you browse, so as little of it as possible should be covered.
|
* you browse, so as little of it as possible should be covered.
|
||||||
@@ -30,43 +32,115 @@ const {
|
|||||||
*/
|
*/
|
||||||
export const ThemeSwitcher = () => {
|
export const ThemeSwitcher = () => {
|
||||||
const { theme, themeId, setTheme, themes } = useTheme();
|
const { theme, themeId, setTheme, themes } = useTheme();
|
||||||
const { open, setOpen, focused, setFocused } = useThemeKeys();
|
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
||||||
|
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
||||||
|
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
||||||
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||||
|
|
||||||
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
|
/*
|
||||||
// readonly, so this builds mutable arrays rather than pushing into its own type.
|
One ordering, used by both lists.
|
||||||
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
|
|
||||||
(acc[t.family] ??= []).push(t);
|
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
||||||
return acc;
|
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
||||||
}, {});
|
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
||||||
|
`theme/rotate.ts`.
|
||||||
|
|
||||||
|
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
||||||
|
the order it was given, so the theme you are wearing still leads.
|
||||||
|
*/
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const rotated = rotateToSelected(themes, themeId);
|
||||||
|
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
|
||||||
|
return favouritesFirst(listed, favorites);
|
||||||
|
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
||||||
|
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
||||||
|
|
||||||
|
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
|
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
|
||||||
aria-label="Theme"
|
isFavorite(themeId) ? 'unstar' : 'star'
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer"
|
}`}
|
||||||
|
aria-label={`Theme: ${theme.name}`}
|
||||||
|
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
||||||
|
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
||||||
|
// the cluster stays one shape.
|
||||||
|
className="flex h-8 cursor-pointer items-center rounded-full bg-black/25 px-2.5 text-xs font-semibold transition-colors hover:bg-black/35"
|
||||||
style={{ color: 'var(--header-fg)' }}
|
style={{ color: 'var(--header-fg)' }}
|
||||||
>
|
>
|
||||||
<Palette className="h-4 w-4" />
|
{theme.name}
|
||||||
|
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
||||||
|
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
||||||
|
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
|
{/*
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
The CONTENT does not scroll — the list inside it does.
|
||||||
ctrl+shift+←/→ · ctrl+shift+space
|
|
||||||
|
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
||||||
|
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
||||||
|
visible for the first half-second and then gone for good, which is the same as not having written
|
||||||
|
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
||||||
|
*/}
|
||||||
|
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
||||||
|
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
||||||
|
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
||||||
|
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
|
||||||
|
<span className="block">ctrl+? for every shortcut</span>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<button
|
||||||
{Object.entries(families).map(([family, entries]) => (
|
type="button"
|
||||||
|
onClick={(ev) => {
|
||||||
|
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
setOnlyFavorites((was) => !was);
|
||||||
|
}}
|
||||||
|
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
||||||
|
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
||||||
|
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||||
|
{ordered.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
No favourites yet — star one with the button on its row.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
families.map(([family, entries]) => (
|
||||||
<div key={family}>
|
<div key={family}>
|
||||||
{entries.map((t) => (
|
{entries.map((t) => (
|
||||||
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
|
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
|
||||||
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
{t.name}
|
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||||
|
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||||
|
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggle(t.id);
|
||||||
|
}}
|
||||||
|
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
|
||||||
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
@@ -87,8 +161,11 @@ export const ThemeSwitcher = () => {
|
|||||||
}}
|
}}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ul className="flex flex-col gap-1">
|
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
||||||
{themes.map((t) => {
|
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
||||||
|
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
||||||
|
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
||||||
|
{ordered.map((t) => {
|
||||||
const isFocused = t.id === focused;
|
const isFocused = t.id === focused;
|
||||||
return (
|
return (
|
||||||
<li key={t.id}>
|
<li key={t.id}>
|
||||||
@@ -98,19 +175,43 @@ export const ThemeSwitcher = () => {
|
|||||||
setTheme(t.id);
|
setTheme(t.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
||||||
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.name}
|
<span className="min-w-0 flex-1 truncate">{t.name}</span>
|
||||||
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
|
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
|
||||||
|
{/*
|
||||||
|
A span, not a button: this sits inside the row's own <button>, and a button inside
|
||||||
|
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
||||||
|
handler and the role are set explicitly instead, and `f` does the same thing from
|
||||||
|
the keyboard.
|
||||||
|
*/}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
|
||||||
|
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
|
||||||
|
onClick={(ev) => {
|
||||||
|
// Or the row's own onClick applies the theme and closes the overlay, which is
|
||||||
|
// the opposite of what starring is for.
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggle(t.id);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
||||||
|
header does. */}
|
||||||
<p className="mt-6 text-center text-xs opacity-60">
|
<p className="mt-6 text-center text-xs opacity-60">
|
||||||
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
|
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Keyboard } from 'lucide-react';
|
||||||
|
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
|
||||||
|
|
||||||
|
/*
|
||||||
|
`Ctrl+?` — every shortcut the shell owns, on one screen.
|
||||||
|
|
||||||
|
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
|
||||||
|
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
|
||||||
|
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
|
||||||
|
interesting part is `state/shortcuts.ts`.
|
||||||
|
|
||||||
|
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
|
||||||
|
controls grid, and for the same reason: these are short lists that read badly as one long column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Row = ({ keys, description }: { keys: string; description: string }) => (
|
||||||
|
<div className="flex items-baseline gap-3 py-1">
|
||||||
|
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
|
||||||
|
putting them on the left. */}
|
||||||
|
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
|
||||||
|
{keys}
|
||||||
|
</kbd>
|
||||||
|
<span className="text-sm text-muted-foreground">{description}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ShortcutsModal = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
|
if (isShortcutsChord(ev)) {
|
||||||
|
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
|
||||||
|
// composer is a help list you cannot open — the lesson from the Spotlight guard.
|
||||||
|
ev.preventDefault();
|
||||||
|
setOpen((was) => !was);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'Escape' && open) setOpen(false);
|
||||||
|
};
|
||||||
|
// Capture, so a panel with its own key handling cannot swallow it first.
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
|
||||||
|
onMouseDown={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
||||||
|
onMouseDown={(ev) => ev.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
|
||||||
|
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
|
||||||
|
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
|
||||||
|
{SHORTCUT_SECTIONS.map((section) => (
|
||||||
|
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
|
||||||
|
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
|
||||||
|
<div className="mt-2">
|
||||||
|
{section.shortcuts.map((shortcut) => (
|
||||||
|
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,19 +11,58 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
|||||||
//
|
//
|
||||||
// ── Keyboard first, and the trigger ──
|
// ── Keyboard first, and the trigger ──
|
||||||
//
|
//
|
||||||
// `shift-space`. The two obvious chords were both taken: `ctrl-space` focuses the address bar in Brave,
|
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
|
||||||
// and `alt-space` is load-bearing for the owner elsewhere. `cmd-k` belongs to the browser on some
|
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
|
||||||
// platforms and to text fields on others.
|
|
||||||
//
|
//
|
||||||
// **Shift-space is a real key combination inside a text field** — it types a space, and people hold shift
|
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
|
||||||
// through one more often than they think while typing capitals. So the trigger is IGNORED when focus is
|
// preventDefault would win. It does not — the key never reaches this handler.
|
||||||
// in an input, a textarea or anything contenteditable, and the space is typed as normal.
|
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
|
||||||
|
// would rather move the app than the window manager.
|
||||||
|
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
|
||||||
//
|
//
|
||||||
// The cost of that is worth stating: focus usually IS in the chat composer, so opening Spotlight from
|
// ── Shift-space overrides two things, on purpose ──
|
||||||
// there means clicking out first. If that turns out to be the common case, a double-tap of shift is the
|
//
|
||||||
// escape hatch — it conflicts with nothing anywhere.
|
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
|
||||||
|
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
|
||||||
|
// and typing a space is what the unmodified spacebar is for.
|
||||||
|
//
|
||||||
|
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
|
||||||
|
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
|
||||||
|
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
|
||||||
|
// what made the launcher unopenable from the composer, where it is most wanted.
|
||||||
|
//
|
||||||
|
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
|
||||||
|
// is the argument for making them configurable, not for choosing better.
|
||||||
|
//
|
||||||
|
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
|
||||||
|
// combination inside a text field: it types a space, and people hold shift through one more often than
|
||||||
|
// they think while typing capitals. Guarding it meant IGNORING the trigger whenever focus was in an input
|
||||||
|
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
|
||||||
|
// A launcher you have to prepare for is not a launcher.
|
||||||
|
//
|
||||||
|
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
|
||||||
|
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
|
||||||
|
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
|
||||||
|
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
|
||||||
|
//
|
||||||
|
// Nothing else opens it. There is deliberately no second chord — two triggers for one thing means two
|
||||||
|
// things to collide with, and this one moved twice already.
|
||||||
//
|
//
|
||||||
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
|
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
|
||||||
|
//
|
||||||
|
// ── Sizing ──
|
||||||
|
//
|
||||||
|
// Loosened 2026-08-22: the first pass reused the app's list metrics (7px tile, text-sm, py-2) and read as
|
||||||
|
// cramped, because this is not a list inside a panel — it is a modal you look at for two seconds with
|
||||||
|
// nothing else competing for the eye. So the search input leads at `text-base`, rows are `py-2.5` with a
|
||||||
|
// 9px tile, and the label is `text-[15px]` with the hint and path kept deliberately subordinate.
|
||||||
|
//
|
||||||
|
// The panel widened with them (560 → 640). Rows now carry a label, a hint and a route on one line, and the
|
||||||
|
// route is the first thing to be squeezed out — it is also the thing that disambiguates two rows with the
|
||||||
|
// same name.
|
||||||
|
//
|
||||||
|
// The results box grew 52vh → 60vh for the arithmetic reason: taller rows in the same box show fewer
|
||||||
|
// results, and the cap on how many are listed was removed for a reason.
|
||||||
|
|
||||||
/*
|
/*
|
||||||
No cap, deliberately, and it used to be 8.
|
No cap, deliberately, and it used to be 8.
|
||||||
@@ -38,19 +77,6 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
|
|||||||
cap becomes indistinguishable from a missing feature.
|
cap becomes indistinguishable from a missing feature.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the user typing into something?
|
|
||||||
*
|
|
||||||
* `shift-space` types a space in any text field, so the trigger has to stand down there or it eats a
|
|
||||||
* keystroke people make by accident all day. `isContentEditable` covers the editor and any rich input a
|
|
||||||
* panel brings with it.
|
|
||||||
*/
|
|
||||||
function isTyping(target: EventTarget | null): boolean {
|
|
||||||
const el = target as HTMLElement | null;
|
|
||||||
if (!el || !el.tagName) return false;
|
|
||||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
|
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
|
||||||
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
|
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
|
||||||
if (!hits.length) return <>{label}</>;
|
if (!hits.length) return <>{label}</>;
|
||||||
@@ -98,7 +124,7 @@ const Row = ({
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
onPick();
|
onPick();
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
|
className={`flex w-full items-center gap-3.5 rounded-lg px-3.5 py-2.5 text-left transition-colors ${
|
||||||
active ? 'bg-accent' : 'hover:bg-accent/50'
|
active ? 'bg-accent' : 'hover:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -116,7 +142,7 @@ const Row = ({
|
|||||||
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
||||||
*/}
|
*/}
|
||||||
<span
|
<span
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
||||||
style={{
|
style={{
|
||||||
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
||||||
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
||||||
@@ -130,18 +156,20 @@ const Row = ({
|
|||||||
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
|
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
|
||||||
/>
|
/>
|
||||||
) : Icon ? (
|
) : Icon ? (
|
||||||
<Icon className={`h-4 w-4 ${item.color ? '' : 'text-muted-foreground'}`} />
|
<Icon className={`h-[18px] w-[18px] ${item.color ? '' : 'text-muted-foreground'}`} />
|
||||||
) : (
|
) : (
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
<Search className="h-[18px] w-[18px] text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm text-foreground">
|
<span className="block truncate text-[15px] leading-snug text-foreground">
|
||||||
<Highlighted label={item.label} hits={hits} />
|
<Highlighted label={item.label} hits={hits} />
|
||||||
</span>
|
</span>
|
||||||
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null}
|
{item.hint ? (
|
||||||
|
<span className="block truncate text-[13px] leading-snug text-muted-foreground">{item.hint}</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">{item.to}</span>
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground/60">{item.to}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -156,6 +184,19 @@ export const Spotlight = () => {
|
|||||||
|
|
||||||
const results = useMemo(() => rank(items, query), [items, query]);
|
const results = useMemo(() => rank(items, query), [items, query]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The window handler reads these through refs.
|
||||||
|
|
||||||
|
It has to: the listener is registered once per open, and closing over `results` and `active` would pin
|
||||||
|
it to their values at registration — so arrowing down would read a stale list and Enter would open
|
||||||
|
whatever was first when the panel appeared. Adding them to the effect's deps instead would tear down and
|
||||||
|
re-register the listener on every keystroke, which is the same bug wearing a costume.
|
||||||
|
*/
|
||||||
|
const resultsRef = useRef(results);
|
||||||
|
resultsRef.current = results;
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
const close = useCallback(() => {
|
const close = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
@@ -175,16 +216,51 @@ export const Spotlight = () => {
|
|||||||
// anywhere — including inside a panel that has its own key handling.
|
// anywhere — including inside a panel that has its own key handling.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (ev: KeyboardEvent) => {
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
if (ev.shiftKey && ev.code === 'Space' && !isTyping(ev.target)) {
|
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
|
||||||
|
// the physical key is `Space` everywhere.
|
||||||
|
//
|
||||||
|
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
|
||||||
|
// both is the bug that took a capture-phase listener to fix on the theme side.
|
||||||
|
//
|
||||||
|
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
|
||||||
|
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
|
||||||
|
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
|
||||||
|
// below covers the only thing the guard was protecting against — a layout that would otherwise type
|
||||||
|
// a character.
|
||||||
|
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
|
||||||
|
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
|
||||||
|
// that sometimes did nothing: focus stayed in the composer, so ArrowDown went to the textarea and
|
||||||
|
// the launcher never saw it. Blurring the outgoing element is one line and removes the race rather
|
||||||
|
// than narrowing it.
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
setOpen((wasOpen) => !wasOpen);
|
setOpen((wasOpen) => !wasOpen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'Escape' && open) close();
|
if (!open) return;
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Navigation is handled HERE rather than on the input, so it works no matter what has focus — a
|
||||||
|
// panel that grabbed it back, a blur that lost the race, an element with its own key handling. The
|
||||||
|
// input's own onKeyDown used to own this, which made every keystroke conditional on focus having
|
||||||
|
// landed where we expected.
|
||||||
|
if (ev.key === 'ArrowDown') {
|
||||||
|
ev.preventDefault();
|
||||||
|
setActive((i) => Math.min(i + 1, resultsRef.current.length - 1));
|
||||||
|
} else if (ev.key === 'ArrowUp') {
|
||||||
|
ev.preventDefault();
|
||||||
|
setActive((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
pick(resultsRef.current[activeRef.current]?.item);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [open, close]);
|
}, [open, close, pick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) inputRef.current?.focus();
|
if (open) inputRef.current?.focus();
|
||||||
@@ -201,34 +277,22 @@ export const Spotlight = () => {
|
|||||||
onMouseDown={close}
|
onMouseDown={close}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-[min(92vw,560px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
className="w-[min(92vw,640px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
|
||||||
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
|
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
|
||||||
onMouseDown={(ev) => ev.stopPropagation()}
|
onMouseDown={(ev) => ev.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4">
|
<div className="flex items-center gap-2 border-b border-border px-4">
|
||||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Search className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
onChange={(ev) => setQuery(ev.target.value)}
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'ArrowDown') {
|
|
||||||
ev.preventDefault();
|
|
||||||
setActive((i) => Math.min(i + 1, results.length - 1));
|
|
||||||
} else if (ev.key === 'ArrowUp') {
|
|
||||||
ev.preventDefault();
|
|
||||||
setActive((i) => Math.max(i - 1, 0));
|
|
||||||
} else if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
pick(results[active]?.item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Go to…"
|
placeholder="Go to…"
|
||||||
className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[52vh] overflow-y-auto p-2">
|
<div className="max-h-[60vh] overflow-y-auto p-2">
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
Every keyboard shortcut the shell owns, as data.
|
||||||
|
|
||||||
|
── Why a registry ──
|
||||||
|
|
||||||
|
Ported in shape from the owner's `vimazing` (`vim-maze/src/gameInfo.ts`), where the controls are declared
|
||||||
|
once and the help screen renders them. The alternative is what this replaced: chords spelled out inside
|
||||||
|
three separate key handlers, and a help text nobody updates because it lives somewhere else.
|
||||||
|
|
||||||
|
Two things follow from having it. `Ctrl+?` can render an accurate list without anybody maintaining prose.
|
||||||
|
And when shortcuts become configurable — the next big piece — this is the table a binding UI edits, rather
|
||||||
|
than a grep across handlers.
|
||||||
|
|
||||||
|
── What it is NOT, yet ──
|
||||||
|
|
||||||
|
Declarative but not authoritative: the handlers still contain their own conditions, so a chord changed here
|
||||||
|
does not move. Making this the single source is part of the configurable-shortcuts work, and doing it now
|
||||||
|
would mean a rewrite of three handlers for no visible gain. Where a chord appears in both places the file
|
||||||
|
that owns it is named in `implementedIn`, so the pair can be found in one search.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Shortcut = {
|
||||||
|
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
|
||||||
|
keys: string;
|
||||||
|
description: string;
|
||||||
|
/** The file whose handler actually implements it, until this registry is authoritative. */
|
||||||
|
implementedIn: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShortcutSection = {
|
||||||
|
title: string;
|
||||||
|
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
|
||||||
|
note?: string;
|
||||||
|
shortcuts: Shortcut[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Navigation',
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: 'Shift+Space',
|
||||||
|
description: 'Spotlight — go to any screen, plugin or setting by name',
|
||||||
|
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
|
||||||
|
},
|
||||||
|
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Themes',
|
||||||
|
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: 'Ctrl+Shift+Space',
|
||||||
|
description: 'Open the theme browser',
|
||||||
|
implementedIn: 'theme/useThemeKeys.ts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: 'Ctrl+Shift+← / →',
|
||||||
|
description: 'Previous / next theme, without opening anything',
|
||||||
|
implementedIn: 'theme/useThemeKeys.ts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: 'Ctrl+Shift+F',
|
||||||
|
description: 'Star or unstar the current theme, from anywhere',
|
||||||
|
implementedIn: 'theme/useThemeKeys.ts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'In the theme browser',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
|
||||||
|
{
|
||||||
|
keys: 'l →',
|
||||||
|
description: 'Apply the focused theme and keep browsing',
|
||||||
|
implementedIn: 'theme/useThemeKeys.ts',
|
||||||
|
},
|
||||||
|
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
|
||||||
|
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
|
||||||
|
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
|
||||||
|
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'In Spotlight',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||||
|
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||||
|
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Chat',
|
||||||
|
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: '↑ / ↓',
|
||||||
|
description: 'Previous / next prompt you sent, shell-style',
|
||||||
|
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: 'Enter',
|
||||||
|
description: 'Send · on an empty composer with something queued, send that now',
|
||||||
|
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||||
|
},
|
||||||
|
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
|
||||||
|
{
|
||||||
|
keys: 'Esc',
|
||||||
|
description: 'Stop the turn that is generating',
|
||||||
|
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this the `Ctrl+?` chord?
|
||||||
|
*
|
||||||
|
* `?` is `Shift+/` on most layouts and its own key on some, so both spellings are accepted rather than
|
||||||
|
* guessing which keyboard is attached. `code === 'Slash'` is the physical key, which is stable where `key`
|
||||||
|
* is not.
|
||||||
|
*/
|
||||||
|
export function isShortcutsChord(ev: KeyboardEvent): boolean {
|
||||||
|
if (!ev.ctrlKey) return false;
|
||||||
|
return ev.key === '?' || (ev.shiftKey && ev.code === 'Slash');
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Starred themes, per account.
|
||||||
|
|
||||||
|
With 200+ installed, "which of these did I like" is a question the list cannot answer by itself, and the
|
||||||
|
answer belongs to the account rather than to the browser — the same taste follows you to the phone.
|
||||||
|
|
||||||
|
Optimistic, and that is not a nicety here: the star sits in a dropdown you are about to close, so a
|
||||||
|
round trip before the icon fills means the confirmation arrives after the thing it confirms has gone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = ['themes', 'favorites'];
|
||||||
|
|
||||||
|
export function useThemeFavorites() {
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery<{ favorites: string[] }>({
|
||||||
|
queryKey: KEY,
|
||||||
|
queryFn: () => client.get<{ favorites: string[] }>('/theme-favorites'),
|
||||||
|
// Taste does not change while you look at it, and the mutation below keeps the cache honest.
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: ({ themeId, favorite }: { themeId: string; favorite: boolean }) =>
|
||||||
|
client.put<{ themeId: string; favorite: boolean }>(`/theme-favorites/${themeId}`, { favorite }),
|
||||||
|
onMutate: async ({ themeId, favorite }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: KEY });
|
||||||
|
const previous = queryClient.getQueryData<{ favorites: string[] }>(KEY);
|
||||||
|
const current = previous?.favorites ?? [];
|
||||||
|
queryClient.setQueryData(KEY, {
|
||||||
|
favorites: favorite ? [...new Set([...current, themeId])] : current.filter((id) => id !== themeId),
|
||||||
|
});
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
// Put it back on failure. A star that stayed filled after the write was refused is worse than one that
|
||||||
|
// never filled: it reports a preference the server does not hold.
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (context?.previous) queryClient.setQueryData(KEY, context.previous);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const favorites = new Set(query.data?.favorites ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
favorites,
|
||||||
|
isFavorite: (themeId: string) => favorites.has(themeId),
|
||||||
|
toggle: (themeId: string) => toggle.mutate({ themeId, favorite: !favorites.has(themeId) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export {
|
|||||||
recordCommandHqWatcherFired,
|
recordCommandHqWatcherFired,
|
||||||
disarmCommandHqWatcher,
|
disarmCommandHqWatcher,
|
||||||
recordCommandHqTurn,
|
recordCommandHqTurn,
|
||||||
|
ingestSpawnedChildren,
|
||||||
|
recordSpawnedOutcome,
|
||||||
getCommandHqCostSummary,
|
getCommandHqCostSummary,
|
||||||
listCommandHqTurns,
|
listCommandHqTurns,
|
||||||
listCommandHqWatcherFires,
|
listCommandHqWatcherFires,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
commandHqTags,
|
commandHqTags,
|
||||||
commandHqTurns,
|
commandHqTurns,
|
||||||
commandHqWatcherFires,
|
commandHqWatcherFires,
|
||||||
|
commandHqSpawned,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
// ── Categories ──
|
// ── Categories ──
|
||||||
@@ -405,3 +406,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num
|
|||||||
.delete(commandHqSessionTags)
|
.delete(commandHqSessionTags)
|
||||||
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
|
.where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Spawned children. The file is the inbox; these rows are the record — see the schema note.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SpawnedChild = {
|
||||||
|
spawnedSessionId: string;
|
||||||
|
firstSeenAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
totalUsd: number | null;
|
||||||
|
outcome: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingest ids the loop appended, idempotently, and return the commander's children.
|
||||||
|
*
|
||||||
|
* One statement for the insert because the loop can repeat itself after a restart, so a duplicate append is
|
||||||
|
* expected rather than exceptional. `onConflictDoNothing` on the unique index makes re-ingesting the whole
|
||||||
|
* file cost nothing and change nothing, which is what lets the caller ingest on every read instead of
|
||||||
|
* running a cron that can silently stop.
|
||||||
|
*/
|
||||||
|
export async function ingestSpawnedChildren(sessionRowId: number, ids: string[]): Promise<SpawnedChild[]> {
|
||||||
|
if (ids.length) {
|
||||||
|
await db
|
||||||
|
.insert(commandHqSpawned)
|
||||||
|
.values(ids.map((spawnedSessionId) => ({ sessionRowId, spawnedSessionId })))
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(commandHqSpawned)
|
||||||
|
.where(eq(commandHqSpawned.sessionRowId, sessionRowId))
|
||||||
|
.orderBy(commandHqSpawned.firstSeenAt);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
spawnedSessionId: r.spawnedSessionId,
|
||||||
|
firstSeenAt: r.firstSeenAt.toISOString(),
|
||||||
|
finishedAt: r.finishedAt?.toISOString() ?? null,
|
||||||
|
totalUsd: r.totalUsd,
|
||||||
|
outcome: r.outcome,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a child cost, once something knows.
|
||||||
|
*
|
||||||
|
* Separate from the ingest because the two facts arrive at different times from different parties: the loop
|
||||||
|
* knows the id at spawn, and only the child knows what it cost when it exits. Null cost stays null — an
|
||||||
|
* unknown total must not be reported as zero.
|
||||||
|
*/
|
||||||
|
export async function recordSpawnedOutcome(
|
||||||
|
sessionRowId: number,
|
||||||
|
spawnedSessionId: string,
|
||||||
|
outcome: { totalUsd?: number | null; outcome?: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(commandHqSpawned)
|
||||||
|
.set({
|
||||||
|
finishedAt: new Date(),
|
||||||
|
...(outcome.totalUsd === undefined ? {} : { totalUsd: outcome.totalUsd }),
|
||||||
|
...(outcome.outcome === undefined ? {} : { outcome: outcome.outcome }),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,3 +195,53 @@ export const commandHqSessionTags = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per agent a commander spawned.
|
||||||
|
*
|
||||||
|
* ── Why the database, when the loop writes a file ──
|
||||||
|
*
|
||||||
|
* The file is an INBOX, not the record. A commander's loop is bash: it can append a session id with a shell
|
||||||
|
* redirect and it cannot reach Postgres, and giving it a credential so it could would put an authenticated
|
||||||
|
* writer inside a background job — worse than the problem. So the loop appends, and the platform ingests
|
||||||
|
* into this table whenever anything asks. The file is allowed to be lossy; this is not.
|
||||||
|
*
|
||||||
|
* What the table buys that the file cannot:
|
||||||
|
*
|
||||||
|
* - the LINK, enforced. `onDelete: 'cascade'` means deleting a commander cannot leave orphaned children
|
||||||
|
* behind, which is the whole reason this became important.
|
||||||
|
* - queries. Cost per commander, how many agents a trigger spawned, which ones are still running — none of
|
||||||
|
* which a newline-delimited file can answer without reading every one.
|
||||||
|
* - a place for what the loop cannot know at append time: when the child finished, what it cost, whether
|
||||||
|
* it succeeded. Those arrive later, from the child itself, and a file appended by a shell cannot be
|
||||||
|
* updated in place.
|
||||||
|
*
|
||||||
|
* ── Why the child's own id is the key and not a serial ──
|
||||||
|
*
|
||||||
|
* Because the same child must not be ingested twice — the file is appended to by a loop that may repeat
|
||||||
|
* itself after a restart, and the ingest is idempotent on `(session_row_id, spawned_session_id)`.
|
||||||
|
*/
|
||||||
|
export const commandHqSpawned = pgTable(
|
||||||
|
'command_hq_spawned',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
/** The commander. Cascades: its children go with it, which is the point. */
|
||||||
|
sessionRowId: integer('session_row_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||||
|
/** The child's own transcript id, as the harness wrote it. */
|
||||||
|
spawnedSessionId: text('spawned_session_id').notNull(),
|
||||||
|
/** When the platform first saw it in the inbox — not when the child started, which nothing records. */
|
||||||
|
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
/**
|
||||||
|
* Filled in later, by whoever finds out. Null means unknown rather than zero — a commander whose
|
||||||
|
* children's costs are unknown must not display a confident total that happens to be too low, which is
|
||||||
|
* the failure this table exists to end.
|
||||||
|
*/
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||||
|
totalUsd: doublePrecision('total_usd'),
|
||||||
|
outcome: text('outcome'),
|
||||||
|
},
|
||||||
|
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||||
|
(table) => [uniqueIndex('uq_command_hq_spawned').on(table.sessionRowId, table.spawnedSessionId)],
|
||||||
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export * from './chat-notification-prefs';
|
|||||||
export * from './nudges';
|
export * from './nudges';
|
||||||
export * from './ai-provider-usage';
|
export * from './ai-provider-usage';
|
||||||
export * from './anthropic-accounts';
|
export * from './anthropic-accounts';
|
||||||
|
export * from './theme-favorites';
|
||||||
export * from './command-hq';
|
export * from './command-hq';
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './integrations';
|
export * from './integrations';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export * from './chat-session-models/schema'; // chat_session_models
|
|||||||
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
|
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
|
||||||
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
|
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
|
||||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||||
|
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||||
export * from './agent-panels/schema'; // agent_panels
|
export * from './agent-panels/schema'; // agent_panels
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './schema';
|
||||||
|
export * from './queries';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { themeFavorites } from './schema';
|
||||||
|
|
||||||
|
/** The theme ids this account has starred. Order is not meaningful — the caller owns the ordering. */
|
||||||
|
export async function listThemeFavorites(userId: number): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ themeId: themeFavorites.themeId })
|
||||||
|
.from(themeFavorites)
|
||||||
|
.where(eq(themeFavorites.userId, userId));
|
||||||
|
return rows.map((r) => r.themeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Star or unstar, returning the state it ended in.
|
||||||
|
*
|
||||||
|
* Idempotent in both directions on purpose: a double-tap, or two tabs racing, must not end with the star
|
||||||
|
* in a state neither of them asked for. `onConflictDoNothing` makes the insert safe to repeat, and the
|
||||||
|
* delete is naturally so.
|
||||||
|
*/
|
||||||
|
export async function setThemeFavorite(userId: number, themeId: string, favorite: boolean): Promise<boolean> {
|
||||||
|
if (favorite) {
|
||||||
|
await db.insert(themeFavorites).values({ userId, themeId }).onConflictDoNothing();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await db.delete(themeFavorites).where(and(eq(themeFavorites.userId, userId), eq(themeFavorites.themeId, themeId)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which themes an account has starred.
|
||||||
|
*
|
||||||
|
* ── Why a table, when a preference could be one JSON column ──
|
||||||
|
*
|
||||||
|
* Because it is per account and unbounded. `user_settings` would hold an array that grows with taste and
|
||||||
|
* has to be read-modify-written by whoever toggles a star, and two tabs doing that at once lose one of the
|
||||||
|
* stars. A row per favourite makes a toggle an INSERT or a DELETE, which cannot race with itself.
|
||||||
|
*
|
||||||
|
* ── Why the theme id is text with no foreign key ──
|
||||||
|
*
|
||||||
|
* Themes are not database rows. Most are compiled into the bundle; the rest are directories under
|
||||||
|
* `platform/themes/` discovered at request time, so a theme can appear or vanish without anything writing
|
||||||
|
* SQL. A foreign key would either be unenforceable or would make installing a theme a migration.
|
||||||
|
*
|
||||||
|
* The consequence is deliberate: a star can outlive its theme. That is why the reader intersects with the
|
||||||
|
* live registry rather than trusting the table — a favourite naming a theme nobody has installed is
|
||||||
|
* ignored, not shown as a broken entry, and it costs one row until the account unstars something.
|
||||||
|
*/
|
||||||
|
export const themeFavorites = pgTable(
|
||||||
|
'theme_favorites',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** The theme's registry id — `duck-dark`, `catppuccin`. Not a foreign key; see above. */
|
||||||
|
themeId: text('theme_id').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||||
|
// See src/databases/CLAUDE.md → "Composite keys".
|
||||||
|
(table) => [uniqueIndex('uq_theme_favorites_user_theme').on(table.userId, table.themeId)],
|
||||||
|
);
|
||||||
+67
-16
@@ -162,16 +162,60 @@ const handlers: Record<string, any> = {
|
|||||||
sidecar: sidecarWebsocket,
|
sidecar: sidecarWebsocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both doors into the platform, checked against the permission registry before either opens.
|
/**
|
||||||
//
|
* Every websocket path Bun serves, and the provider each one upgrades to.
|
||||||
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
*
|
||||||
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
* ── Why this exists rather than a hand-written route table ──
|
||||||
// from the map directly above, so neither list can be a stale copy of the thing it describes.
|
*
|
||||||
|
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
|
||||||
|
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
|
||||||
|
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
|
||||||
|
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
|
||||||
|
*
|
||||||
|
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
|
||||||
|
* days after their handlers and registry entries were commented out — not exploitable, since
|
||||||
|
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
|
||||||
|
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
|
||||||
|
* shape.
|
||||||
|
*
|
||||||
|
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
|
||||||
|
* handler or without a registry claim fails the boot rather than serving quietly.
|
||||||
|
*/
|
||||||
|
const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||||
|
'/api/tasks/run/ws': 'task-runner',
|
||||||
|
'/api/tasks/pipeline/ws': 'pipeline',
|
||||||
|
'/api/terminal/ws': 'terminal',
|
||||||
|
'/api/chat/ws': 'chat',
|
||||||
|
'/api/chat/notify/ws': 'chat-notify',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
||||||
|
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
||||||
|
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
||||||
|
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Both doors into the platform, checked against the permission registry before either opens.
|
||||||
|
|
||||||
|
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
|
||||||
|
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
|
||||||
|
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
|
||||||
|
parallel list that agrees with it by hand.
|
||||||
|
|
||||||
|
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
|
||||||
|
failure this catches is the other one — a route reachable with nothing claiming it.
|
||||||
|
*/
|
||||||
assertPermissionTotality({
|
assertPermissionTotality({
|
||||||
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
||||||
wsProviders: Object.keys(handlers),
|
wsProviders: SERVED_WS_PROVIDERS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
|
||||||
|
// into a refusal to boot with the name in it.
|
||||||
|
for (const [path, provider] of Object.entries(WS_ROUTES)) {
|
||||||
|
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
|
||||||
|
}
|
||||||
|
|
||||||
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
|
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
|
||||||
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
|
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
|
||||||
// module whose only job is a side effect is one an import sweep quietly deletes.
|
// module whose only job is a side effect is one an import sweep quietly deletes.
|
||||||
@@ -312,11 +356,16 @@ console.log(
|
|||||||
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
|
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
|
||||||
);
|
);
|
||||||
|
|
||||||
async function upgradeWs(
|
/**
|
||||||
req: Request,
|
* A provider reachable by upgrading a URL.
|
||||||
server: any,
|
*
|
||||||
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
|
* Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
|
||||||
) {
|
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
||||||
|
* deliberately absent — they upgrade through their own handlers, not through here.
|
||||||
|
*/
|
||||||
|
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
|
||||||
|
|
||||||
|
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||||
const token = new URL(req.url).searchParams.get('token');
|
const token = new URL(req.url).searchParams.get('token');
|
||||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
@@ -441,11 +490,13 @@ const server = serve({
|
|||||||
});
|
});
|
||||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
},
|
},
|
||||||
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
|
||||||
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
...Object.fromEntries(
|
||||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
Object.entries(WS_ROUTES).map(([path, provider]) => [
|
||||||
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
path,
|
||||||
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
|
(req: Request, server: any) => upgradeWs(req, server, provider),
|
||||||
|
]),
|
||||||
|
),
|
||||||
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
||||||
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
||||||
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
|
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
listClaudePwds,
|
listClaudePwds,
|
||||||
listClaudeSessions,
|
listClaudeSessions,
|
||||||
listAllClaudeSessions,
|
listAllClaudeSessions,
|
||||||
|
withCommanderFlag,
|
||||||
loadClaudeSession,
|
loadClaudeSession,
|
||||||
loadClaudeSessionById,
|
loadClaudeSessionById,
|
||||||
deleteClaudeSession,
|
deleteClaudeSession,
|
||||||
@@ -27,7 +28,6 @@ import {
|
|||||||
stopBackgroundTask,
|
stopBackgroundTask,
|
||||||
claudeSessionContext,
|
claudeSessionContext,
|
||||||
liveSessionTitle,
|
liveSessionTitle,
|
||||||
isCommandersDir,
|
|
||||||
} from './claude-sessions';
|
} from './claude-sessions';
|
||||||
import {
|
import {
|
||||||
listOpenCodeSessions,
|
listOpenCodeSessions,
|
||||||
@@ -129,10 +129,15 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
// the row renderer) is indifferent to which directories the rows came from.
|
// the row renderer) is indifferent to which directories the rows came from.
|
||||||
const all = ctx.req.query('all') === '1';
|
const all = ctx.req.query('all') === '1';
|
||||||
|
|
||||||
const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({
|
// Which of these are commanders comes from the ROWS, not from where the transcript lives — a commander
|
||||||
|
// chooses its own working directory now, so the directory answers nothing. One query for the whole set.
|
||||||
|
const commanderIds = await listCommandHqSessionIds(user.id);
|
||||||
|
const claude = withCommanderFlag(all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd), commanderIds).map(
|
||||||
|
(s) => ({
|
||||||
...s,
|
...s,
|
||||||
harness: 'claude' as const,
|
harness: 'claude' as const,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
|
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
|
||||||
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
|
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
|
||||||
//
|
//
|
||||||
@@ -257,6 +262,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
offset: windowed ? start : 0,
|
offset: windowed ? start : 0,
|
||||||
title: context?.title ?? null,
|
title: context?.title ?? null,
|
||||||
partCount: context?.partCount ?? 1,
|
partCount: context?.partCount ?? 1,
|
||||||
|
/*
|
||||||
|
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
|
||||||
|
|
||||||
|
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
|
||||||
|
decided whether to by looking at the route it was rendered on. So the same commander, opened from
|
||||||
|
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
|
||||||
|
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
||||||
|
travels with the conversation.
|
||||||
|
*/
|
||||||
|
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,6 +297,7 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
sidecar.listLiveClaudeSessions(user.id),
|
sidecar.listLiveClaudeSessions(user.id),
|
||||||
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
const commanderIds = await listCommandHqSessionIds(user.id);
|
||||||
const sessions = live.map((session) => {
|
const sessions = live.map((session) => {
|
||||||
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
||||||
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||||
@@ -294,14 +310,16 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
harness: 'claude' as const,
|
harness: 'claude' as const,
|
||||||
title: resolved?.title ?? null,
|
title: resolved?.title ?? null,
|
||||||
cwd,
|
cwd,
|
||||||
// Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the
|
// From the `command_hq_sessions` rows, because a commander chooses its own working directory and the
|
||||||
// history listing does it: every commander shares one directory, so the directory IS the answer and
|
// directory therefore answers nothing. One query for the whole set, before the map below.
|
||||||
// it needs no query. It also stays right for a commander mid-first-turn, before any row exists.
|
//
|
||||||
|
// A commander mid-first-turn has no row yet and so reports false here for one turn. That is the same
|
||||||
|
// turn on which it must not arm anything, and it corrects itself the moment it is filed.
|
||||||
//
|
//
|
||||||
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
|
// This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no
|
||||||
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
|
// room for tabs, so it can only exclude commanders if the payload says which ones they are — and a
|
||||||
// deploy commander turning up in that swipe order is exactly what it must not do.
|
// deploy commander turning up in that swipe order is exactly what it must not do.
|
||||||
isCommander: cwd ? isCommandersDir(who.email, cwd) : false,
|
isCommander: transcriptId ? commanderIds.has(transcriptId) : false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -984,20 +984,42 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
|||||||
return applyLineage(sessions);
|
return applyLineage(sessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
/**
|
||||||
|
* Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain.
|
||||||
|
*
|
||||||
|
* **Says nothing about commanders**, deliberately. It used to: every commander shared one directory, so the
|
||||||
|
* directory was the answer and this computed it once per group. That stopped being true the moment a
|
||||||
|
* commander could choose its own working directory — a commander in the platform checkout would have been
|
||||||
|
* reported as an ordinary chat, and an ordinary chat in the old commanders directory as a commander.
|
||||||
|
*
|
||||||
|
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
|
||||||
|
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
|
||||||
|
* directory scan — see `withCommanderFlag`.
|
||||||
|
*/
|
||||||
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
||||||
// Every commander a user has lives in ONE directory, so this is a property of the group rather than of
|
|
||||||
// each transcript — computed once here instead of per row. See `isCommander` on the summary type for
|
|
||||||
// why the flag has to reach the payload at all.
|
|
||||||
const commander = isCommandersDir(who.email, cwd);
|
|
||||||
return mergeChains(scanGroup(who, cwd))
|
return mergeChains(scanGroup(who, cwd))
|
||||||
.map(publish)
|
.map(publish)
|
||||||
.map((entry) => (commander ? { ...entry, isCommander: true } : entry))
|
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is this directory the one every commander shares?
|
* Mark which of these are commanders, from the rows rather than from where they live.
|
||||||
|
*
|
||||||
|
* One query per request rather than one per session: `listCommandHqSessionIds` returns the whole set for an
|
||||||
|
* account, and a set membership test is what the per-row decoration needs. Commanders are few, so the set is
|
||||||
|
* small; the alternative — a lookup per transcript — would turn one list into fifty queries.
|
||||||
|
*/
|
||||||
|
export function withCommanderFlag<T extends { id: string }>(rows: T[], commanderIds: ReadonlySet<string>): T[] {
|
||||||
|
return rows.map((row) => (commanderIds.has(row.id) ? { ...row, isCommander: true } : row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this the default commanders directory?
|
||||||
|
*
|
||||||
|
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
|
||||||
|
* a commander now chooses its own working directory. This remains only for the places that legitimately care
|
||||||
|
* about that specific path: creating it for a new OS account, and as the historical location of commanders
|
||||||
|
* created before the cwd was choosable.
|
||||||
*
|
*
|
||||||
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
|
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
|
||||||
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
|
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { getCommandHqSession, getUserById } from 'officerdb';
|
||||||
|
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||||
|
|
||||||
|
/*
|
||||||
|
What a commander is told about ITSELF, once per session.
|
||||||
|
|
||||||
|
── Why this exists ──
|
||||||
|
|
||||||
|
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
|
||||||
|
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
|
||||||
|
background-job dialog composes. Which was fine while the only way to arm a commander was through that
|
||||||
|
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
|
||||||
|
starting its own background job never sees that preamble, so it would record nothing, and delete would go
|
||||||
|
back to leaving orphaned loops without anybody noticing it had.
|
||||||
|
|
||||||
|
── Why the paths come from here and the method comes from the skill ──
|
||||||
|
|
||||||
|
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
|
||||||
|
material that belongs with everything else about commanders, in the skill, where it can be as long as it
|
||||||
|
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
|
||||||
|
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
|
||||||
|
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
|
||||||
|
exactly what an always-loaded block is for.
|
||||||
|
|
||||||
|
Short for the same reason every affordance is short: it is charged to every turn of every commander.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type CommanderBlockParams = {
|
||||||
|
/** The Officer account, for the row lookup and for the paths. */
|
||||||
|
userId: number;
|
||||||
|
/** Claude's transcript id — what a commander is keyed on everywhere. */
|
||||||
|
sessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
|
||||||
|
if (!params.sessionId) return null;
|
||||||
|
|
||||||
|
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
|
||||||
|
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
|
||||||
|
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
|
||||||
|
// session spawn, alongside the affordances and the skills index, which are also read here rather than
|
||||||
|
// cached — the same trade for the same reason.
|
||||||
|
const user = await getUserById(params.userId).catch(() => null);
|
||||||
|
if (!user?.email) return null;
|
||||||
|
const email = user.email;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'# You are a commander',
|
||||||
|
'',
|
||||||
|
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
|
||||||
|
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
|
||||||
|
'establish with the owner first, and the mistakes that are expensive.',
|
||||||
|
'',
|
||||||
|
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
|
||||||
|
'',
|
||||||
|
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
|
||||||
|
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
|
||||||
|
'',
|
||||||
|
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
|
||||||
|
'record is the only one. Without it, deleting this commander leaves your loop running.',
|
||||||
|
'',
|
||||||
|
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
|
||||||
|
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||||
|
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
|
||||||
|
'that is what their absence means. Ask, and arm once it is agreed.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -5,7 +5,14 @@ import { checkBudget } from '../sub-agents/budget';
|
|||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { resolveHomeDir } from '@@/user-home';
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||||
import { getChatEventsSince } from 'officerdb';
|
import { getChatEventsSince, ingestSpawnedChildren } from 'officerdb';
|
||||||
|
import {
|
||||||
|
clearSpawnedRecord,
|
||||||
|
listSpawnedSessions,
|
||||||
|
loopPidPath,
|
||||||
|
spawnedRecordPath,
|
||||||
|
stopCommanderProcesses,
|
||||||
|
} from './spawned';
|
||||||
import type { ServerMessage } from '../chat/types';
|
import type { ServerMessage } from '../chat/types';
|
||||||
import {
|
import {
|
||||||
listClaudeSessions,
|
listClaudeSessions,
|
||||||
@@ -469,11 +476,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
|||||||
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
||||||
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
||||||
*
|
*
|
||||||
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
|
* ── It cascades, and it has to ──
|
||||||
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to
|
*
|
||||||
* update once this returns, and the running background process (if any) dies on its own the same way it
|
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
|
||||||
* would if the harness process were killed out from under any other conversation — not a new failure
|
*
|
||||||
* mode this route has to account for.
|
* 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
|
||||||
|
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
|
||||||
|
* process takes all of it. The comment here used to claim the background process "dies on its own" —
|
||||||
|
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
|
||||||
|
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
|
||||||
|
* row left to disarm.
|
||||||
|
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
|
||||||
|
* — see `spawned.ts` for the three approaches that do not work.
|
||||||
|
* 3. DELETE the commander's own transcript and row, as before.
|
||||||
|
*
|
||||||
|
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
|
||||||
|
* could spawn a new one between steps 2 and 3.
|
||||||
|
*
|
||||||
|
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
|
||||||
|
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
|
||||||
*/
|
*/
|
||||||
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
@@ -483,9 +504,38 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
|||||||
const row = await getCommandHqSession(user.id, sessionId);
|
const row = await getCommandHqSession(user.id, sessionId);
|
||||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||||
|
|
||||||
|
// 1. Everything this commander is still running.
|
||||||
|
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||||
|
|
||||||
|
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||||
|
// not stop the rest from going, and a child is often gone before its parent.
|
||||||
|
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
||||||
|
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
||||||
|
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
||||||
|
(c) => c.spawnedSessionId,
|
||||||
|
);
|
||||||
|
let childrenDeleted = 0;
|
||||||
|
for (const child of children) {
|
||||||
|
try {
|
||||||
|
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
|
||||||
|
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
|
||||||
|
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
|
||||||
|
// transcript wherever it actually is, which is what makes this work for a child that ran in the
|
||||||
|
// platform checkout rather than the commanders directory.
|
||||||
|
if (deleteClaudeSession(who, getCommandersDir(who.email), child)) childrenDeleted += 1;
|
||||||
|
} catch {
|
||||||
|
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSpawnedRecord(who.email, sessionId);
|
||||||
|
|
||||||
|
// 3. The commander itself.
|
||||||
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
||||||
await deleteCommandHqSession(user.id, sessionId);
|
await deleteCommandHqSession(user.id, sessionId);
|
||||||
return ctx.json({ ok: true });
|
|
||||||
|
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
|
||||||
|
// make it auditable after the fact.
|
||||||
|
return ctx.json({ ok: true, processesStopped: stopped, childrenFound: children.length, childrenDeleted });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -518,6 +568,59 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /command-hq/commanders/:sessionId/children
|
||||||
|
*
|
||||||
|
* The agents this commander spawned, ingested from the loop's inbox on the way through.
|
||||||
|
*
|
||||||
|
* Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop
|
||||||
|
* without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on
|
||||||
|
* the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the
|
||||||
|
* UI likes.
|
||||||
|
*/
|
||||||
|
commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const who = await commandHqIdentity(user);
|
||||||
|
const sessionId = ctx.req.param('sessionId');
|
||||||
|
|
||||||
|
const row = await getCommandHqSession(user.id, sessionId);
|
||||||
|
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||||
|
|
||||||
|
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||||
|
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
||||||
|
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
||||||
|
// this exists.
|
||||||
|
const known = children.filter((c) => c.totalUsd !== null);
|
||||||
|
return ctx.json({
|
||||||
|
children,
|
||||||
|
totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0),
|
||||||
|
unknownCost: children.length - known.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /command-hq/commanders/:sessionId/record-paths
|
||||||
|
*
|
||||||
|
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||||
|
*
|
||||||
|
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||||
|
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||||
|
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||||
|
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||||
|
*
|
||||||
|
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||||
|
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||||
|
*/
|
||||||
|
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const who = await commandHqIdentity(user);
|
||||||
|
const sessionId = ctx.req.param('sessionId');
|
||||||
|
return ctx.json({
|
||||||
|
loopPidPath: loopPidPath(who.email, sessionId),
|
||||||
|
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
||||||
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getCommanderStateDir } from '../../data-path';
|
||||||
|
import { descendantPids } from '../tasks/process-tree';
|
||||||
|
|
||||||
|
/*
|
||||||
|
What a commander spawned, and why the platform cannot work it out for itself.
|
||||||
|
|
||||||
|
A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself
|
||||||
|
started. The platform never sees that spawn: it happens inside a background job, two processes below a
|
||||||
|
conversation, and the agent talks to Anthropic rather than to us.
|
||||||
|
|
||||||
|
Three ways to recover the link were tried and none works:
|
||||||
|
|
||||||
|
- the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing
|
||||||
|
identifying in its environment. Verified 2026-08-22.
|
||||||
|
- the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look
|
||||||
|
like markers the platform injects and are the harness's own, appearing in every session equally. A
|
||||||
|
discriminator built on them rejected a real child and reported zero when one was running.
|
||||||
|
- CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches
|
||||||
|
as well as the agent following it.
|
||||||
|
|
||||||
|
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||||
|
|
||||||
|
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||||
|
|
||||||
|
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||||
|
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||||
|
every link is required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FILE = 'spawned';
|
||||||
|
const PID_FILE = 'loop-pid';
|
||||||
|
|
||||||
|
/** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */
|
||||||
|
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||||
|
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||||
|
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every agent this commander recorded, oldest first, de-duplicated.
|
||||||
|
*
|
||||||
|
* Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a
|
||||||
|
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||||
|
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||||
|
*/
|
||||||
|
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||||
|
const path = spawnedRecordPath(email, commanderSessionId);
|
||||||
|
if (!existsSync(path)) return [];
|
||||||
|
try {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
|
||||||
|
const id = raw.trim();
|
||||||
|
if (SESSION_ID.test(id)) seen.add(id.toLowerCase());
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one, idempotently. For the platform's own use — the loop appends directly, by design.
|
||||||
|
*
|
||||||
|
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||||
|
* that arrives it must write the same file rather than a second record.
|
||||||
|
*/
|
||||||
|
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||||
|
if (!SESSION_ID.test(spawnedId)) return;
|
||||||
|
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||||
|
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||||
|
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||||
|
try {
|
||||||
|
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||||
|
// without a commander to read it for.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
── Stopping what a commander is running ──
|
||||||
|
|
||||||
|
The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and
|
||||||
|
a `claude` process cannot be attributed to a particular commander from outside — every commander's session
|
||||||
|
runs with the same cwd, so the process table cannot tell them apart.
|
||||||
|
|
||||||
|
So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the
|
||||||
|
only party that knows. From that one number the whole tree follows — the loop's children include any agent
|
||||||
|
it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct
|
||||||
|
child, so a running grandchild is left orphaned and keeps going").
|
||||||
|
|
||||||
|
A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed
|
||||||
|
before this existed, and pretending otherwise would report a clean delete over a loop still polling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||||
|
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||||
|
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||||
|
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||||
|
const path = loopPidPath(email, commanderSessionId);
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||||
|
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||||
|
try {
|
||||||
|
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||||
|
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return pid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||||
|
*
|
||||||
|
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||||
|
*/
|
||||||
|
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||||
|
const pid = liveLoopPid(email, commanderSessionId);
|
||||||
|
if (!pid) return 0;
|
||||||
|
const tree = [...descendantPids(pid), pid];
|
||||||
|
let signalled = 0;
|
||||||
|
for (const target of tree) {
|
||||||
|
try {
|
||||||
|
process.kill(target, 'SIGTERM');
|
||||||
|
signalled += 1;
|
||||||
|
} catch {
|
||||||
|
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return signalled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
|
import { listThemeFavorites, setThemeFavorite } from 'officerdb';
|
||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Starred themes, per account.
|
||||||
|
|
||||||
|
── Why this is not on `/themes` ──
|
||||||
|
|
||||||
|
Because that prefix is deliberately UNAUTHENTICATED: the sign-in screen renders in the chosen theme, so
|
||||||
|
the list and the stylesheets have to be readable before anyone has an account. A per-account preference on
|
||||||
|
an anonymous prefix has no caller to attribute it to — the first version of this lived there and answered
|
||||||
|
500 to an anonymous GET, which was the honest consequence arriving early.
|
||||||
|
|
||||||
|
── Why the `account` permission ──
|
||||||
|
|
||||||
|
It is `core`, so every account holds it and it cannot be denied, and it already owns `/user`, `/dock` and
|
||||||
|
`/api-keys` — preferences and credentials belonging to the caller themselves. A theme favourite is the
|
||||||
|
same shape as a dock preference: it grants nothing, it is about this account's own eyes, and denying it
|
||||||
|
would only mean an account that can choose a theme cannot remember which ones it liked.
|
||||||
|
|
||||||
|
Every route here reads `user.id` from the token. None of them names an account, so there is nothing to get
|
||||||
|
wrong about whose stars these are.
|
||||||
|
*/
|
||||||
|
export const themeFavoritesRouter = createRouter();
|
||||||
|
|
||||||
|
// GET /theme-favorites — this account's starred theme ids.
|
||||||
|
themeFavoritesRouter.get('/', async (ctx: Context) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
return ctx.json({ favorites: await listThemeFavorites(user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /theme-favorites/:id — star or unstar one.
|
||||||
|
//
|
||||||
|
// PUT rather than POST because it SETS a state rather than appending: sending `{favorite:true}` twice ends
|
||||||
|
// where sending it once does, which is what a double-tap and two racing tabs both need. The store is
|
||||||
|
// idempotent in both directions for the same reason.
|
||||||
|
themeFavoritesRouter.put('/:id', async (ctx: Context) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const themeId = ctx.req.param('id');
|
||||||
|
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||||
|
const favorite = body.favorite !== false;
|
||||||
|
return ctx.json({ themeId, favorite: await setThemeFavorite(user.id, themeId, favorite) });
|
||||||
|
});
|
||||||
@@ -5,6 +5,12 @@ import { discoverThemes, readThemeCss } from '../../themes/discover';
|
|||||||
/**
|
/**
|
||||||
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
|
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
|
||||||
*
|
*
|
||||||
|
* **This prefix is UNAUTHENTICATED** (`UNPROTECTED_API_PREFIXES` in hono.ts) and must stay that way: the
|
||||||
|
* sign-in screen paints in the chosen theme, before any account exists to ask. So nothing per-account can
|
||||||
|
* live here — theme FAVOURITES are on `/theme-favorites`, behind the `account` permission, for exactly that
|
||||||
|
* reason. Adding them here returned 500 on an anonymous request, which was the polite version of the
|
||||||
|
* problem.
|
||||||
|
*
|
||||||
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request
|
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request
|
||||||
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no
|
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no
|
||||||
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
|
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
|
||||||
|
|||||||
@@ -219,6 +219,21 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
|||||||
*/
|
*/
|
||||||
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a commander records what it spawned — one directory per commander, named by its session id.
|
||||||
|
*
|
||||||
|
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
|
||||||
|
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
|
||||||
|
* commander started, because nothing else CAN know them — a spawned agent's id is not recoverable from its
|
||||||
|
* process (no open descriptor, nothing in its environment) and its transcript is indistinguishable from any
|
||||||
|
* other session's.
|
||||||
|
*
|
||||||
|
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
|
||||||
|
* completely, its children cannot be followed, and its cost cannot be totalled.
|
||||||
|
*/
|
||||||
|
export const getCommanderStateDir = (email: string, sessionId: string) =>
|
||||||
|
join(DATA_PATH, email, 'commander-state', sessionId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which mail account's store to use, found on DISK rather than by asking the database.
|
* Which mail account's store to use, found on DISK rather than by asking the database.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { authRouter } from './api/auth';
|
|||||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||||
import { themesRouter } from './api/themes/themes';
|
import { themesRouter } from './api/themes/themes';
|
||||||
|
import { themeFavoritesRouter } from './api/themes/favorites';
|
||||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||||
import { usersRouter } from './api/users/users-router';
|
import { usersRouter } from './api/users/users-router';
|
||||||
import { apiKeysRouter } from './api/api-keys/router';
|
import { apiKeysRouter } from './api/api-keys/router';
|
||||||
@@ -122,6 +123,8 @@ const isDavPath = (path: string) =>
|
|||||||
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
|
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
|
||||||
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
||||||
['/server-settings', serverSettingsRouter],
|
['/server-settings', serverSettingsRouter],
|
||||||
|
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
|
||||||
|
['/theme-favorites', themeFavoritesRouter],
|
||||||
['/users', usersRouter],
|
['/users', usersRouter],
|
||||||
['/skills', skillsRouter],
|
['/skills', skillsRouter],
|
||||||
['/tasks', tasksRouter],
|
['/tasks', tasksRouter],
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { UserRole } from 'officerdb';
|
||||||
|
import type { EffectivePermissions } from './authorize';
|
||||||
|
import { decideApiRequest, decideWsProvider, resolveEffectivePermissions } from './authorize';
|
||||||
|
import type { PermissionLevel } from './registry';
|
||||||
|
import { CORE_PERMISSIONS } from './registry';
|
||||||
|
|
||||||
|
/*
|
||||||
|
The resolver, which its own header calls the file standing between a Member and a shell — and which had no
|
||||||
|
automated tests at all until 2026-08-22.
|
||||||
|
|
||||||
|
It had none because every path needed two database calls. The decisions are now split from the fetching
|
||||||
|
(`resolveEffectivePermissions`, `decideApiRequest`, `decideWsProvider` are pure), so this file needs no
|
||||||
|
mock, no database and no `officerdb` stub — which matters beyond convenience: the one file in this
|
||||||
|
repository that mocks `officerdb` leaks its stub into whatever runs after it.
|
||||||
|
|
||||||
|
REAL registry keys throughout, deliberately. A suite built on invented permissions would keep passing
|
||||||
|
after a rename, which is the failure mode a test of an authorization table can least afford. If a key here
|
||||||
|
disappears, this file should go red.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const member = (osUser: string | null = 'm1'): { role: UserRole; osUser: string | null } => ({
|
||||||
|
role: 'Member',
|
||||||
|
osUser,
|
||||||
|
});
|
||||||
|
const grants = (...pairs: Array<[string, PermissionLevel]>) => new Map<string, PermissionLevel>(pairs);
|
||||||
|
const resolve = (user: { role: UserRole; osUser: string | null }, g = grants()) => resolveEffectivePermissions(user, g);
|
||||||
|
|
||||||
|
/** No account at all — an unauthenticated or deleted caller, as the async wrapper reports it. */
|
||||||
|
const NOBODY: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||||
|
|
||||||
|
describe('resolving what an account holds', () => {
|
||||||
|
test('the owner bypasses, and carries no grants to consult', () => {
|
||||||
|
const owner = resolve({ role: 'Super Admin', osUser: null });
|
||||||
|
expect(owner.isOwner).toBe(true);
|
||||||
|
// Empty on purpose: an owner's grants are never read, and filling them would invite somebody to.
|
||||||
|
expect(owner.grants.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a member gets every core permission at write, granted nothing', () => {
|
||||||
|
const held = resolve(member()).grants;
|
||||||
|
for (const permission of CORE_PERMISSIONS) expect(held.get(permission.key)).toBe('write');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core is write even though nothing granted it', () => {
|
||||||
|
// A read-only "change your own password" is not a coherent thing to offer, which is why core is not
|
||||||
|
// levelled. Pinned because a future levels-everywhere refactor would silently break it.
|
||||||
|
expect(resolve(member()).grants.get('account')).toBe('write');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an app grant is honoured at the level given', () => {
|
||||||
|
expect(resolve(member(), grants(['calendar', 'read'])).grants.get('calendar')).toBe('read');
|
||||||
|
expect(resolve(member(), grants(['calendar', 'write'])).grants.get('calendar')).toBe('write');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The rule that only exists because getOwnerHomeDir ignores its argument: a confined grant with no Linux
|
||||||
|
// user to confine to would resolve to the OWNER's home.
|
||||||
|
test('a confined grant needs a Linux account', () => {
|
||||||
|
expect(resolve(member('m1'), grants(['files', 'write'])).grants.get('files')).toBe('write');
|
||||||
|
expect(resolve(member(null), grants(['files', 'write'])).grants.has('files')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every confined permission is dropped together, not one by one', () => {
|
||||||
|
const held = resolve(member(null), grants(['files', 'write'], ['terminal', 'write'], ['chat', 'write']));
|
||||||
|
for (const key of ['files', 'terminal', 'chat']) expect(held.grants.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The database has no CHECK stopping these rows; the API refuses to write one. This is the layer that
|
||||||
|
// has to hold if one ever exists.
|
||||||
|
test('an execution grant in the table is ignored', () => {
|
||||||
|
expect(resolve(member(), grants(['tasks', 'write'])).grants.has('tasks')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin grant in the table is ignored', () => {
|
||||||
|
expect(resolve(member(), grants(['user-admin', 'write'])).grants.has('user-admin')).toBe(false);
|
||||||
|
expect(resolve(member(), grants(['server-admin', 'write'])).grants.has('server-admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a grant naming a permission that no longer exists is ignored', () => {
|
||||||
|
const held = resolve(member(), grants(['cliamp', 'write'], ['a-plugin-uninstalled-last-year', 'read']));
|
||||||
|
expect(held.grants.has('cliamp')).toBe(false);
|
||||||
|
expect(held.grants.has('a-plugin-uninstalled-last-year')).toBe(false);
|
||||||
|
// …and does not disturb what is legitimately held.
|
||||||
|
expect(held.grants.get('account')).toBe('write');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deciding an HTTP request', () => {
|
||||||
|
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||||
|
|
||||||
|
test('the owner is allowed anything, including paths nothing serves', () => {
|
||||||
|
expect(decideApiRequest(owner, 'DELETE', '/api/users/9').allowed).toBe(true);
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this').allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a path no permission covers is refused, not allowed by default', () => {
|
||||||
|
const d = decideApiRequest(NOBODY, 'GET', '/api/nothing-serves-this');
|
||||||
|
expect(d.allowed).toBe(false);
|
||||||
|
expect(d.reason).toContain('no permission covers this path');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an execution permission is refused however it is granted', () => {
|
||||||
|
// Refused by KIND, before any grant lookup — so a row granting it changes nothing.
|
||||||
|
const forged = resolve(member(), grants(['tasks', 'write']));
|
||||||
|
const d = decideApiRequest(forged, 'GET', '/api/tasks');
|
||||||
|
expect(d.allowed).toBe(false);
|
||||||
|
expect(d.reason).toContain('runs as the server owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin permission is refused', () => {
|
||||||
|
const d = decideApiRequest(resolve(member()), 'GET', '/api/users');
|
||||||
|
expect(d.allowed).toBe(false);
|
||||||
|
expect(d.reason).toContain('restricted to the server owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The one self-service route in the registry: updating your own profile lives on the router that also
|
||||||
|
// holds account administration.
|
||||||
|
test('a self-service route is allowed with no grant at all', () => {
|
||||||
|
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users').allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self-service is exact — neither the method nor the path may drift', () => {
|
||||||
|
expect(decideApiRequest(resolve(member()), 'POST', '/api/users').allowed).toBe(false);
|
||||||
|
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users/7').allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no grant means no access, with the permission named', () => {
|
||||||
|
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser');
|
||||||
|
expect(d.allowed).toBe(false);
|
||||||
|
expect(d.reason).toContain('your role does not have access to');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read grants reads and refuses writes', () => {
|
||||||
|
const held = resolve(member('m1'), grants(['files', 'read']));
|
||||||
|
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
|
||||||
|
const write = decideApiRequest(held, 'POST', '/api/file-browser');
|
||||||
|
expect(write.allowed).toBe(false);
|
||||||
|
expect(write.reason).toContain('read-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write grants both', () => {
|
||||||
|
const held = resolve(member('m1'), grants(['files', 'write']));
|
||||||
|
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
|
||||||
|
expect(decideApiRequest(held, 'POST', '/api/file-browser').allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A read that has to be a POST — the body is too big for a query string — must not need write.
|
||||||
|
//
|
||||||
|
// The path is `/api/file-browser/download`, not `/api/download`: readOnlyWrites entries are RELATIVE to
|
||||||
|
// the permission's own api prefix. Worth a comment because the absolute form looks right, is what I wrote
|
||||||
|
// first, and fails as "no permission covers this path" rather than as a level problem — a misleading
|
||||||
|
// error for a misleading path.
|
||||||
|
test('a readOnlyWrites path is allowed at read level', () => {
|
||||||
|
const held = resolve(member('m1'), grants(['files', 'read']));
|
||||||
|
expect(decideApiRequest(held, 'POST', '/api/file-browser/download').allowed).toBe(true);
|
||||||
|
// And the same POST elsewhere under the prefix still needs write.
|
||||||
|
expect(decideApiRequest(held, 'POST', '/api/file-browser/move').allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The two rules composing: granted, but with no kernel boundary to be granted inside.
|
||||||
|
test('a granted confined permission is still refused without a Linux account', () => {
|
||||||
|
const held = resolve(member(null), grants(['files', 'write']));
|
||||||
|
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unauthenticated caller is refused everything', () => {
|
||||||
|
for (const path of ['/api/file-browser', '/api/chat', '/api/users', '/api/tasks']) {
|
||||||
|
expect(decideApiRequest(NOBODY, 'GET', path).allowed).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deciding a websocket', () => {
|
||||||
|
test('the owner opens anything', () => {
|
||||||
|
expect(decideWsProvider({ isOwner: true, grants: new Map() }, 'terminal')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a provider no permission claims is refused', () => {
|
||||||
|
expect(decideWsProvider(resolve(member()), 'not-a-provider')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a confined socket needs the grant AND the Linux account', () => {
|
||||||
|
expect(decideWsProvider(resolve(member('m1'), grants(['terminal', 'read'])), 'terminal')).toBe(true);
|
||||||
|
expect(decideWsProvider(resolve(member(null), grants(['terminal', 'read'])), 'terminal')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// No method to reason about, so any level opens it. Pinned because "read-only socket" is a tempting idea
|
||||||
|
// that would silently close terminals granted at read.
|
||||||
|
test('read is enough — a socket has no method', () => {
|
||||||
|
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat')).toBe(true);
|
||||||
|
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat-notify')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an execution permission's socket is refused however it is granted", () => {
|
||||||
|
const forged = resolve(member('m1'), grants(['tasks', 'write']));
|
||||||
|
expect(decideWsProvider(forged, 'task-runner')).toBe(false);
|
||||||
|
expect(decideWsProvider(forged, 'pipeline')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unauthenticated caller opens nothing', () => {
|
||||||
|
for (const p of ['terminal', 'chat', 'task-runner', 'sidecar']) expect(decideWsProvider(NOBODY, p)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
NOT covered here, and stated rather than left as a gap somebody has to notice:
|
||||||
|
|
||||||
|
- `isOwnerOnlyRoute`. No permission in the registry declares `ownerOnly` yet — the field was added on
|
||||||
|
2026-08-21 for plugin settings routes and has no user. It is checked before self-service and before
|
||||||
|
kind, which is the part worth a test, and it cannot get one until a real permission uses it. Covering it
|
||||||
|
with an invented permission would test this file's fixtures rather than the registry.
|
||||||
|
- The grant cache and `invalidateRoleGrants`. They live on the async side, around a database call, which
|
||||||
|
is exactly the half this split moved away from. Their contract — the one writer in
|
||||||
|
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
|
||||||
|
and a test here could only re-state it.
|
||||||
|
*/
|
||||||
@@ -65,13 +65,21 @@ async function grantsForRole(role: UserRole): Promise<Map<string, PermissionLeve
|
|||||||
* write such a row, but this is the layer that has to hold if one ever exists — a constraint the database
|
* write such a row, but this is the layer that has to hold if one ever exists — a constraint the database
|
||||||
* does not enforce is a constraint the reader must.
|
* does not enforce is a constraint the reader must.
|
||||||
*/
|
*/
|
||||||
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
|
/**
|
||||||
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
|
* The resolution itself, with no database in it.
|
||||||
if (!userId) return empty;
|
*
|
||||||
|
* Split out on 2026-08-22 so it can be tested. `authorize.ts` is described in its own header as the file
|
||||||
try {
|
* standing between a Member and a shell, and it had no automated tests at all — because every path through
|
||||||
const user = await getUserById(userId);
|
* it needed two database calls, and the only file in this repository that mocks `officerdb` has a stub that
|
||||||
if (!user) return empty;
|
* leaks into whatever runs after it. Pulling the decision away from the fetching removes the need for a
|
||||||
|
* mock rather than working around one: everything below this line is a pure function of two arguments.
|
||||||
|
*
|
||||||
|
* The async wrapper keeps the caching, the error handling and the single-question shape callers rely on.
|
||||||
|
*/
|
||||||
|
export function resolveEffectivePermissions(
|
||||||
|
user: { role: UserRole; osUser: string | null },
|
||||||
|
roleGrants: Map<string, PermissionLevel>,
|
||||||
|
): EffectivePermissions {
|
||||||
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||||
|
|
||||||
const grants = new Map<string, PermissionLevel>();
|
const grants = new Map<string, PermissionLevel>();
|
||||||
@@ -81,7 +89,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
|
|||||||
// without it — see below.
|
// without it — see below.
|
||||||
const hasOsAccount = !!user.osUser;
|
const hasOsAccount = !!user.osUser;
|
||||||
|
|
||||||
for (const [key, level] of await grantsForRole(user.role)) {
|
for (const [key, level] of roleGrants) {
|
||||||
const permission = PERMISSION_BY_KEY.get(key);
|
const permission = PERMISSION_BY_KEY.get(key);
|
||||||
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
||||||
// alternative is honouring a name nothing defines.
|
// alternative is honouring a name nothing defines.
|
||||||
@@ -106,6 +114,17 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { isOwner: false, grants };
|
return { isOwner: false, grants };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
|
||||||
|
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||||
|
if (!userId) return empty;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
if (!user) return empty;
|
||||||
|
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||||
|
return resolveEffectivePermissions(user, await grantsForRole(user.role));
|
||||||
} catch {
|
} catch {
|
||||||
// A transient database error must never grant anything. Deny, and let the next request retry.
|
// A transient database error must never grant anything. Deny, and let the next request retry.
|
||||||
return empty;
|
return empty;
|
||||||
@@ -123,7 +142,16 @@ export async function isApiRequestAllowed(
|
|||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<{ allowed: boolean; reason?: string }> {
|
): Promise<{ allowed: boolean; reason?: string }> {
|
||||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
return decideApiRequest(await getEffectivePermissions(userId), method, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
|
||||||
|
export function decideApiRequest(
|
||||||
|
effective: EffectivePermissions,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): { allowed: boolean; reason?: string } {
|
||||||
|
const { isOwner, grants } = effective;
|
||||||
if (isOwner) return { allowed: true };
|
if (isOwner) return { allowed: true };
|
||||||
|
|
||||||
const permission = permissionForApiPath(path);
|
const permission = permissionForApiPath(path);
|
||||||
@@ -170,7 +198,12 @@ export async function isApiRequestAllowed(
|
|||||||
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
|
||||||
*/
|
*/
|
||||||
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
|
||||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
return decideWsProvider(await getEffectivePermissions(userId), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
||||||
|
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
|
||||||
|
const { isOwner, grants } = effective;
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
|
|
||||||
const permission = permissionForWsProvider(provider);
|
const permission = permissionForWsProvider(provider);
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ const CORE_REGISTRY: Permission[] = [
|
|||||||
// to present the authority the account already has, so denying it would only force the holder to keep
|
// to present the authority the account already has, so denying it would only force the holder to keep
|
||||||
// using a password in places a password should not go. What a key can then DO is decided by the same
|
// using a password in places a password should not go. What a key can then DO is decided by the same
|
||||||
// permission checks as any other request from that user; nothing here widens them.
|
// permission checks as any other request from that user; nothing here widens them.
|
||||||
api: ['/user', '/dock', '/api-keys'],
|
// `/theme-favorites` sits here rather than on `/themes`, which is unauthenticated so the sign-in screen
|
||||||
|
// can paint. A starred theme is the same shape as a dock preference: the caller's own taste, granting
|
||||||
|
// nothing, and not a sensible thing to deny an account that can already choose a theme.
|
||||||
|
api: ['/user', '/dock', '/api-keys', '/theme-favorites'],
|
||||||
routes: ['/settings/profile'],
|
routes: ['/settings/profile'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anth
|
|||||||
import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
||||||
import { affordanceBlock } from '../../affordances/load';
|
import { affordanceBlock } from '../../affordances/load';
|
||||||
import { skillsBlock } from '../../skills/load';
|
import { skillsBlock } from '../../skills/load';
|
||||||
|
import { commanderBlock } from '../../api/command-hq/commander-block';
|
||||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
||||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||||
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
|
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
|
||||||
@@ -540,6 +541,11 @@ function createSession(
|
|||||||
params: ClaudeSpawnStreamingParams,
|
params: ClaudeSpawnStreamingParams,
|
||||||
onEvent: (event: ChatEvent) => void,
|
onEvent: (event: ChatEvent) => void,
|
||||||
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
|
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
|
||||||
|
/**
|
||||||
|
* The commander block, or null. Passed IN because building it needs two database reads and this function
|
||||||
|
* is synchronous — hoisted to `spawnClaudeStreaming`, which is async and already has the account.
|
||||||
|
*/
|
||||||
|
commander: string | null,
|
||||||
): PersistentSession {
|
): PersistentSession {
|
||||||
const { sessionKey } = params;
|
const { sessionKey } = params;
|
||||||
const input = makeInputQueue();
|
const input = makeInputQueue();
|
||||||
@@ -608,7 +614,15 @@ function createSession(
|
|||||||
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
|
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
|
||||||
// written an hour ago should be offered on the next session.
|
// written an hour ago should be offered on the next session.
|
||||||
const skills = skillsBlock();
|
const skills = skillsBlock();
|
||||||
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
|
/*
|
||||||
|
And, for a commander, the two paths only this session can be told.
|
||||||
|
|
||||||
|
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
|
||||||
|
preamble, which is where this used to live and which an agent arming itself never sees. See
|
||||||
|
`api/command-hq/commander-block.ts`.
|
||||||
|
*/
|
||||||
|
// `commander` is a parameter, not computed here — see the note on it.
|
||||||
|
const preamble = [affordances, skills, commander].filter(Boolean).join('\n\n') || null;
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
||||||
@@ -880,7 +894,16 @@ export async function spawnClaudeStreaming(
|
|||||||
await applyModelChange(session, params.model);
|
await applyModelChange(session, params.model);
|
||||||
await applyPlanModeChange(session, params.planMode);
|
await applyPlanModeChange(session, params.planMode);
|
||||||
} else {
|
} else {
|
||||||
session = createSession(params, onEvent, onNotify);
|
/*
|
||||||
|
The commander block is built here, before the session exists, because it needs the database and
|
||||||
|
`createSession` is synchronous. It resolves the row for this transcript id and returns null for
|
||||||
|
anything that is not a commander — which is every ordinary chat, at the cost of one indexed lookup.
|
||||||
|
*/
|
||||||
|
const commander = await commanderBlock({
|
||||||
|
userId: params.userId,
|
||||||
|
sessionId: getClaudeSession(params.sessionKey, params.userId) ?? params.resumeSessionId ?? null,
|
||||||
|
});
|
||||||
|
session = createSession(params, onEvent, onNotify, commander);
|
||||||
}
|
}
|
||||||
session.pushTurn(params.prompt, params.images);
|
session.pushTurn(params.prompt, params.images);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type ResumeState = {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
total: number;
|
total: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
|
||||||
|
isCommander: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
|
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
|
||||||
@@ -104,6 +106,9 @@ const ChatPanelInner = ({
|
|||||||
promptPrefix={promptPrefix}
|
promptPrefix={promptPrefix}
|
||||||
panelId={panelId}
|
panelId={panelId}
|
||||||
sessionId={sessionKey ?? undefined}
|
sessionId={sessionKey ?? undefined}
|
||||||
|
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
|
||||||
|
// set it via `context`; either being true is enough — see `manager.isCommander`.
|
||||||
|
isCommander={resume?.isCommander === true}
|
||||||
{...chatContext}
|
{...chatContext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,6 +226,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
|||||||
messages: detail.messages as unknown as ChatMessage[],
|
messages: detail.messages as unknown as ChatMessage[],
|
||||||
total: detail.total,
|
total: detail.total,
|
||||||
offset: detail.offset,
|
offset: detail.offset,
|
||||||
|
isCommander: detail.isCommander === true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
|
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ type EmbeddableChatProps = {
|
|||||||
chat?: UseChatType;
|
chat?: UseChatType;
|
||||||
context?: string;
|
context?: string;
|
||||||
contextId?: string;
|
contextId?: string;
|
||||||
|
/**
|
||||||
|
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
|
||||||
|
*
|
||||||
|
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
|
||||||
|
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
|
||||||
|
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
|
||||||
|
*/
|
||||||
|
isCommander?: boolean;
|
||||||
onMessageComplete?: () => void;
|
onMessageComplete?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
/*
|
||||||
|
The rule, as a pure function, because the interesting part is not the DOM.
|
||||||
|
|
||||||
|
History must never clobber text the person typed, and must still be walkable once a recalled prompt is
|
||||||
|
sitting in the box. Those two pull in opposite directions and the second is what a naive "only when empty"
|
||||||
|
check breaks.
|
||||||
|
*/
|
||||||
|
const mayRecall = (value: string, recalling: boolean): boolean => value.trim() === '' || recalling;
|
||||||
|
|
||||||
|
describe('when history may take the arrow key', () => {
|
||||||
|
it('takes it on an empty composer', () => {
|
||||||
|
expect(mayRecall('', false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bug this rule exists for: the caret is on the first line of a one-line draft, so every position
|
||||||
|
// test passes, and Up used to replace what you were writing.
|
||||||
|
it('leaves a draft alone', () => {
|
||||||
|
expect(mayRecall('half a thought', false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without this, you could step back exactly once and then be refused, because the recalled prompt is
|
||||||
|
// itself text in the box.
|
||||||
|
it('keeps walking once it is in the history', () => {
|
||||||
|
expect(mayRecall('a prompt from earlier', true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats whitespace as empty', () => {
|
||||||
|
expect(mayRecall(' ', false)).toBe(true);
|
||||||
|
expect(mayRecall('\n\t ', false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('protects a draft that merely starts with whitespace', () => {
|
||||||
|
expect(mayRecall(' still typing', false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,8 +49,19 @@ type UseEmbeddableChatParams = {
|
|||||||
chat?: UseChatType;
|
chat?: UseChatType;
|
||||||
context?: string;
|
context?: string;
|
||||||
contextId?: string;
|
contextId?: string;
|
||||||
|
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
|
||||||
|
isCommander?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys that are held rather than typed.
|
||||||
|
*
|
||||||
|
* A modifier press is not editing, and ending a recall on one would break the ordinary act of holding Shift
|
||||||
|
* between two presses of Up. `Dead` is a pending accent on layouts that compose — also not a keystroke you
|
||||||
|
* have finished making.
|
||||||
|
*/
|
||||||
|
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'Dead']);
|
||||||
|
|
||||||
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
|
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
|
||||||
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
|
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
|
||||||
|
|
||||||
@@ -389,15 +400,26 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
/**
|
/**
|
||||||
* Recall a previous prompt, the way a shell does.
|
* Recall a previous prompt, the way a shell does.
|
||||||
*
|
*
|
||||||
* Up only reaches the history from the FIRST line and Down only from the last, so in a multi-line draft
|
* **Only from an empty composer.** The caret-on-the-first-line test below is not enough on its own, and
|
||||||
* the arrows still move the caret — there is a line above to go to, and taking the key would strand the
|
* that was the bug: with a one-line draft you were typing, the caret IS on the first line, so Up replaced
|
||||||
* cursor. On the first line there is nowhere up to go, which is exactly when history is what you meant.
|
* what you had written with something from last week. A draft is not recoverable from anywhere, and
|
||||||
* `step` returning null (empty history, or already at the oldest) leaves the key alone too.
|
* history is — so the tie goes to the draft, always.
|
||||||
|
*
|
||||||
|
* `isRecalling` is what keeps the rule from making the feature useless. Once a recalled prompt is in the
|
||||||
|
* box the box is not empty, so a plain emptiness test would allow one step back and then refuse; walking
|
||||||
|
* further is still allowed because that text came from the history rather than from you.
|
||||||
|
*
|
||||||
|
* Whitespace counts as empty. A composer holding two spaces is not a draft anybody would miss, and an
|
||||||
|
* invisible character silently blocking Up is worse than losing it.
|
||||||
|
*
|
||||||
|
* The line tests stay on top of that: in a recalled prompt spanning three lines, Up from the middle moves
|
||||||
|
* the caret, because there is a line above to go to and taking the key would strand the cursor.
|
||||||
*/
|
*/
|
||||||
const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => {
|
const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => {
|
||||||
const field = ev.currentTarget;
|
const field = ev.currentTarget;
|
||||||
const { selectionStart, selectionEnd, value } = field;
|
const { selectionStart, selectionEnd, value } = field;
|
||||||
if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it
|
if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it
|
||||||
|
if (value.trim() !== '' && !history.isRecalling()) return;
|
||||||
const onEdge =
|
const onEdge =
|
||||||
direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n');
|
direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n');
|
||||||
if (!onEdge) return;
|
if (!onEdge) return;
|
||||||
@@ -429,6 +451,18 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
}
|
}
|
||||||
if (ev.key === 'ArrowUp') return recallPrompt(ev, -1);
|
if (ev.key === 'ArrowUp') return recallPrompt(ev, -1);
|
||||||
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
|
if (ev.key === 'ArrowDown') return recallPrompt(ev, 1);
|
||||||
|
/*
|
||||||
|
Anything else ends the recall, and Left/Right are the ones that matter.
|
||||||
|
|
||||||
|
A recalled prompt you have started moving around in is a prompt you have adopted — so the next Up must
|
||||||
|
not walk further back over it, for the same reason Up does not overwrite a fresh draft. Arrowing left
|
||||||
|
into the middle of it is the clearest signal of intent there is, and it types nothing, so a rule based
|
||||||
|
on the text changing would miss it entirely.
|
||||||
|
|
||||||
|
Bare modifiers are excluded: holding Shift to capitalise the next letter is not editing, and treating
|
||||||
|
it as such would end the recall between two presses of Up.
|
||||||
|
*/
|
||||||
|
if (!MODIFIER_KEYS.has(ev.key)) history.leave();
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
// Enter on an EMPTY composer with something queued means "don't wait — say it now". The keystroke
|
// Enter on an EMPTY composer with something queued means "don't wait — say it now". The keystroke
|
||||||
@@ -579,6 +613,22 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
|
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
|
||||||
// composer, so the option was never rendered anywhere at all.
|
// composer, so the option was never rendered anywhere at all.
|
||||||
context: params.context,
|
context: params.context,
|
||||||
|
/*
|
||||||
|
One answer to "is this a commander", from either of the two things that can know.
|
||||||
|
|
||||||
|
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
|
||||||
|
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
|
||||||
|
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
|
||||||
|
would lose the brand-new composer, which has no session yet.
|
||||||
|
*/
|
||||||
|
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||||
|
/*
|
||||||
|
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
|
||||||
|
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
|
||||||
|
conversation and is not interchangeable; using it here would key a commander's record on an id nothing
|
||||||
|
else knows, which is the bug that made a working commander render as an empty pane.
|
||||||
|
*/
|
||||||
|
claudeSessionId: chat.claudeSessionId,
|
||||||
messages,
|
messages,
|
||||||
historicalCount,
|
historicalCount,
|
||||||
streamingText,
|
streamingText,
|
||||||
|
|||||||
@@ -103,3 +103,44 @@ describe('what gets remembered', () => {
|
|||||||
expect(current.step(-1, '')).toBe('three');
|
expect(current.step(-1, '')).toBe('three');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('leaving the history', () => {
|
||||||
|
// The rule this exists for: a recalled prompt you have started editing — or merely arrowed into — is
|
||||||
|
// yours now, and the next Up must not walk further back over it.
|
||||||
|
test('a keystroke after a recall ends it, so Up no longer walks', () => {
|
||||||
|
const { current } = mount();
|
||||||
|
current.record('one');
|
||||||
|
current.record('two');
|
||||||
|
|
||||||
|
expect(current.step(-1, '')).toBe('two');
|
||||||
|
expect(current.isRecalling()).toBe(true);
|
||||||
|
|
||||||
|
current.leave();
|
||||||
|
expect(current.isRecalling()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The composer's guard is "empty, or recalling". Once we have left, a non-empty box is a draft — so the
|
||||||
|
// two halves together are what stop Up from eating an edited prompt.
|
||||||
|
test('after leaving, the recalled text is treated as a draft', () => {
|
||||||
|
const { current } = mount();
|
||||||
|
current.record('one');
|
||||||
|
current.step(-1, '');
|
||||||
|
current.leave();
|
||||||
|
|
||||||
|
const mayRecall = (value: string) => value.trim() === '' || current.isRecalling();
|
||||||
|
expect(mayRecall('one, edited')).toBe(false);
|
||||||
|
expect(mayRecall('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The stashed draft goes with it. Handing an older draft back after you have started editing something
|
||||||
|
// else would paste the wrong text over the right one.
|
||||||
|
test('the stashed draft does not come back later', () => {
|
||||||
|
const { current } = mount();
|
||||||
|
current.record('sent');
|
||||||
|
expect(current.step(-1, 'a draft')).toBe('sent');
|
||||||
|
current.leave();
|
||||||
|
// Entering again stashes what is there NOW, and Down returns that rather than the abandoned draft.
|
||||||
|
expect(current.step(-1, 'something else')).toBe('sent');
|
||||||
|
expect(current.step(1, 'sent')).toBe('something else');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ export type PromptHistory = {
|
|||||||
* whatever it normally does.
|
* whatever it normally does.
|
||||||
*/
|
*/
|
||||||
step: (direction: -1 | 1, current: string) => string | null;
|
step: (direction: -1 | 1, current: string) => string | null;
|
||||||
|
/**
|
||||||
|
* Are we currently walking the history, rather than composing?
|
||||||
|
*
|
||||||
|
* The caller needs this to answer "may Up take this key" without also making the history unusable. The
|
||||||
|
* rule is that history must never clobber text the PERSON typed — but once a recalled prompt is in the
|
||||||
|
* box, the box is not empty any more, and a plain emptiness test would allow exactly one step back and
|
||||||
|
* then refuse. This is the difference between "there is text" and "there is text of yours".
|
||||||
|
*/
|
||||||
|
isRecalling: () => boolean;
|
||||||
|
/**
|
||||||
|
* Stop being in the history — the recalled text is now the person's own draft.
|
||||||
|
*
|
||||||
|
* Called on any keystroke that is not Up or Down. The moment you edit a recalled prompt, or even just
|
||||||
|
* move the caret through it with Left, you have adopted it: pressing Up after that would throw away
|
||||||
|
* something you were working on, which is the same fault as recalling over a fresh draft.
|
||||||
|
*
|
||||||
|
* The stashed draft goes too. It belonged to the composing session this leaves, and handing it back after
|
||||||
|
* you have started editing something else would paste an older draft over a newer one.
|
||||||
|
*/
|
||||||
|
leave: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePromptHistory(): PromptHistory {
|
export function usePromptHistory(): PromptHistory {
|
||||||
@@ -87,5 +107,12 @@ export function usePromptHistory(): PromptHistory {
|
|||||||
return list[at - 1] ?? null;
|
return list[at - 1] ?? null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { record, step };
|
const isRecalling = useCallback(() => indexRef.current !== null, []);
|
||||||
|
|
||||||
|
const leave = useCallback(() => {
|
||||||
|
indexRef.current = null;
|
||||||
|
draftRef.current = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { record, step, isRecalling, leave };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
|
|||||||
export type BackgroundJobSpec = {
|
export type BackgroundJobSpec = {
|
||||||
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
|
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
|
||||||
mode: 'command' | 'prompt';
|
mode: 'command' | 'prompt';
|
||||||
|
/**
|
||||||
|
* Where a COMMANDER's loop must record itself. Absent for an ordinary chat's background job.
|
||||||
|
*
|
||||||
|
* Two paths, both handed over rather than guessed, because the loop cannot derive either: it does not know
|
||||||
|
* which commander it belongs to, and nothing outside it knows its pid or what it spawns. Without this
|
||||||
|
* record a commander cannot be stopped or fully deleted — see `api/command-hq/spawned.ts`.
|
||||||
|
*/
|
||||||
|
record?: { loopPidPath: string; spawnedPath: string };
|
||||||
/** The literal command, or the intent. */
|
/** The literal command, or the intent. */
|
||||||
body: string;
|
body: string;
|
||||||
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
|
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
|
||||||
@@ -141,9 +149,40 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A commander's loop must make itself findable. Nothing else can: the platform has no session-to-process
|
||||||
|
map, a spawned agent's id is not recoverable from its process or its transcript, and the loop is the only
|
||||||
|
party that knows either. Without these two lines a commander cannot be stopped or deleted completely —
|
||||||
|
its loop outlives it, still polling and still able to spawn.
|
||||||
|
|
||||||
|
Numbered after whatever came before, like the rules above, and only present when a record path was
|
||||||
|
supplied — an ordinary chat's background job has no commander to belong to.
|
||||||
|
*/
|
||||||
|
// The number the NEXT rule would have taken, so nothing lands on a fraction and the output rule below
|
||||||
|
// simply moves down one when this is present.
|
||||||
|
const nextRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||||
|
if (spec.record) {
|
||||||
|
rules.push(
|
||||||
|
``,
|
||||||
|
`${nextRuleNumber}. RECORD YOURSELF, first thing, before the wait begins:`,
|
||||||
|
``,
|
||||||
|
` mkdir -p "$(dirname '${spec.record.loopPidPath}')"`,
|
||||||
|
` echo $$ > '${spec.record.loopPidPath}'`,
|
||||||
|
``,
|
||||||
|
` And every time you spawn an agent, append its session id — the \`session_id\` on the`,
|
||||||
|
` \`{"type":"system","subtype":"init"}\` event, which you are already parsing — one per line:`,
|
||||||
|
``,
|
||||||
|
` echo "<session_id>" >> '${spec.record.spawnedPath}'`,
|
||||||
|
``,
|
||||||
|
` This is not bookkeeping. It is the only record of what you are and what you started: the`,
|
||||||
|
` platform cannot find your process, and a spawned agent's id is unrecoverable once it exits.`,
|
||||||
|
` Without it, deleting this commander leaves you running.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
|
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
|
||||||
// which number it lands on, matching whichever rules already came before it in this particular prompt.
|
// which number it lands on, matching whichever rules already came before it in this particular prompt.
|
||||||
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
const outputRuleNumber = nextRuleNumber + (spec.record ? 1 : 0);
|
||||||
rules.push(
|
rules.push(
|
||||||
``,
|
``,
|
||||||
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
|
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ type BackgroundJobDialogProps = {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
|
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
|
||||||
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
|
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Where a commander's loop must record its pid and what it spawns. Absent for any other chat.
|
||||||
|
*
|
||||||
|
* Passed in rather than derived here: the paths are rooted in the platform's data directory and keyed on
|
||||||
|
* the commander's session id, neither of which a dialog knows.
|
||||||
|
*/
|
||||||
|
recordPaths?: { loopPidPath: string; spawnedPath: string };
|
||||||
/**
|
/**
|
||||||
* Is this composer running inside a Command HQ commander?
|
* Is this composer running inside a Command HQ commander?
|
||||||
*
|
*
|
||||||
@@ -55,6 +62,7 @@ export const BackgroundJobDialog = ({
|
|||||||
cwd,
|
cwd,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isCommander = false,
|
isCommander = false,
|
||||||
|
recordPaths,
|
||||||
}: BackgroundJobDialogProps) => {
|
}: BackgroundJobDialogProps) => {
|
||||||
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
@@ -102,6 +110,10 @@ export const BackgroundJobDialog = ({
|
|||||||
onFire,
|
onFire,
|
||||||
duration: timed ? minutes : null,
|
duration: timed ? minutes : null,
|
||||||
cwd,
|
cwd,
|
||||||
|
// Only a commander gets the record instruction: it is what makes stopping and deleting one possible,
|
||||||
|
// and an ordinary chat's background job has no commander to belong to. `record` is undefined until the
|
||||||
|
// session has an id, which is the same moment it becomes a commander — see NewCommander.
|
||||||
|
...(recordPaths ? { record: recordPaths } : {}),
|
||||||
};
|
};
|
||||||
// The intent is what the transcript shows; the preamble rides underneath it.
|
// The intent is what the transcript shows; the preamble rides underneath it.
|
||||||
onSubmit(
|
onSubmit(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useE
|
|||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||||
|
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
|
||||||
import { armBackgroundJob } from '../../../background-job-origin';
|
import { armBackgroundJob } from '../../../background-job-origin';
|
||||||
import { useWorkspace } from '../../../components/Workspace';
|
import { useWorkspace } from '../../../components/Workspace';
|
||||||
import { AttachmentList } from './AttachmentList';
|
import { AttachmentList } from './AttachmentList';
|
||||||
@@ -19,6 +20,9 @@ type InputAreaProps = {
|
|||||||
|
|
||||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||||
|
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||||
|
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
|
||||||
const {
|
const {
|
||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
@@ -199,12 +203,17 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
|
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
|
||||||
promise the platform has to keep rather than only a sentence in the prompt.
|
promise the platform has to keep rather than only a sentence in the prompt.
|
||||||
|
|
||||||
|
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
|
||||||
|
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
|
||||||
|
earlier. `manager.isCommander` is true when either the screen or the session says so.
|
||||||
|
|
||||||
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
|
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
|
||||||
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
|
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
|
||||||
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
|
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
|
||||||
server. Only the radio button was missing, so the feature looked unbuilt.
|
server. Only the radio button was missing, so the feature looked unbuilt.
|
||||||
*/
|
*/
|
||||||
isCommander={manager.context === 'command-hq'}
|
isCommander={manager.isCommander}
|
||||||
|
recordPaths={recordPaths}
|
||||||
onSubmit={(intent, preamble, armWatcher) => {
|
onSubmit={(intent, preamble, armWatcher) => {
|
||||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
||||||
// background jobs an agent starts on its own.
|
// background jobs an agent starts on its own.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
type RecordPaths = { loopPidPath: string; spawnedPath: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this commander's loop must record its pid and what it spawns.
|
||||||
|
*
|
||||||
|
* Fetched rather than derived: both are rooted in `DATA_PATH`, a server fact. Cached indefinitely because
|
||||||
|
* they are a pure function of the session id — nothing can change them but a different commander.
|
||||||
|
*
|
||||||
|
* Null for anything that is not a commander, and for a commander that has no id yet. The composer passes
|
||||||
|
* whatever this returns straight into the background-job prompt, so absent means the loop is simply not
|
||||||
|
* asked to record — correct for an ordinary chat, and the reason a commander must have its id before its
|
||||||
|
* first background job.
|
||||||
|
*/
|
||||||
|
export function useCommanderRecordPaths(sessionId: string | null): RecordPaths | undefined {
|
||||||
|
const client = useClient();
|
||||||
|
const { data } = useQuery<RecordPaths>({
|
||||||
|
queryKey: ['command-hq', 'record-paths', sessionId],
|
||||||
|
queryFn: () => client.get<RecordPaths>(`/command-hq/commanders/${sessionId}/record-paths`),
|
||||||
|
enabled: !!sessionId,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { Plus, Radar } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||||
@@ -55,23 +55,21 @@ export const CommandHqCategories = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
/*
|
||||||
<div className="flex items-center gap-3 px-4 py-4">
|
One scrollable list, and a heading that does not scroll with it.
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
|
|
||||||
<Radar className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold leading-tight">Command HQ</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">Watchers & long-running jobs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
This was a nav COLUMN with the screen's branding at the top — a 9px Radar tile, "Command HQ", and
|
||||||
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
"Watchers & long-running jobs" — costing about seventy pixels. All three are gone: the panel now sits
|
||||||
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
at the FOOT of the left column rather than owning it, so the vertical room is the whole budget, and the
|
||||||
</nav>
|
screen's own header already says Command HQ two inches above. Repeating it in a filter panel was
|
||||||
|
branding where a list should be.
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-between px-4 pb-1">
|
`All commanders` and `Uncategorised` are inside the scroll box with the categories rather than in a
|
||||||
|
separate nav above it. They are the same kind of thing — a filter over the list — and splitting them
|
||||||
|
into two nav blocks made the second look like a different mechanism.
|
||||||
|
*/
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-muted/30">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -83,7 +81,9 @@ export const CommandHqCategories = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||||
|
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
||||||
|
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Row
|
<Row
|
||||||
key={category.id}
|
key={category.id}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { ChatMessage } from '../Chat/types';
|
|||||||
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
||||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||||
import { formatCommanderCost, formatTokens } from './format';
|
import { formatCommanderCost, formatTokens } from './format';
|
||||||
|
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||||
|
|
||||||
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
|
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
|
||||||
// read the route directly, per `docs/navigation-audit.md`):
|
// read the route directly, per `docs/navigation-audit.md`):
|
||||||
@@ -32,6 +33,37 @@ function EmptyState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instructions shown in an empty new-commander pane.
|
||||||
|
*
|
||||||
|
* Deliberately plain and deliberately unfinished — see `newCommanderGuide.ts`. A `provisional` section
|
||||||
|
* carries a visible marker so a guide that is still being written cannot be mistaken for one that is done,
|
||||||
|
* which matters because this ships on every deploy whether the copy is ready or not.
|
||||||
|
*/
|
||||||
|
const NewCommanderGuide = () => (
|
||||||
|
<div className="shrink-0 overflow-y-auto border-b border-border px-5 py-4">
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||||
|
{NEW_COMMANDER_GUIDE.map((section) => (
|
||||||
|
<section key={section.title}>
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
{section.title}
|
||||||
|
{section.provisional && (
|
||||||
|
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
|
||||||
|
draft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{section.body.map((paragraph) => (
|
||||||
|
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
||||||
*
|
*
|
||||||
@@ -103,6 +135,9 @@ function NewCommander({
|
|||||||
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
|
`useEmbeddableChat(params)` builds the manager from these props, and `manager.context` is what the
|
||||||
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
|
composer reads to decide whether to offer "exit and hand back, then re-arm". Passing it to the hook
|
||||||
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
|
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
|
||||||
|
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
|
||||||
|
message to start", which is true and tells a first-time user nothing about what to send. */}
|
||||||
|
{chat.messages.length === 0 && <NewCommanderGuide />}
|
||||||
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
|
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -140,7 +140,16 @@ export const CommandHqGrid = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3">
|
{/*
|
||||||
|
One per line, full width — a grid of one.
|
||||||
|
|
||||||
|
It was `sm:grid-cols-2 xl:grid-cols-3`, which was reasonable when this panel owned 34% of the screen
|
||||||
|
and is not now that it owns part of a 24% column: two columns of card inside it left each one too
|
||||||
|
narrow for a title, so every card truncated and the cost line wrapped. Nothing about the card changed
|
||||||
|
— same rounded pill, same `p-4`, same `gap-3` between them — it simply stops being asked to share a
|
||||||
|
row.
|
||||||
|
*/}
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
{commanders.map((commander) => (
|
{commanders.map((commander) => (
|
||||||
<Card
|
<Card
|
||||||
key={commander.id}
|
key={commander.id}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
What the pane says before a new commander has been spoken to.
|
||||||
|
|
||||||
|
── Why this is data, and why it is a skeleton ──
|
||||||
|
|
||||||
|
The creation flow is being rebuilt. Starting a commander currently means knowing to open the background-job
|
||||||
|
dialog and arm a watcher from it, which is not discoverable and reads as configuration when it is really
|
||||||
|
just "talk to it" — so the dialog stops being the way in, and this pane explains the way in instead.
|
||||||
|
|
||||||
|
The copy is NOT written yet, by agreement: the instructions have to describe a flow that does not exist
|
||||||
|
yet, and writing them before the flow is decided produces text that is wrong in a way nobody notices.
|
||||||
|
What is here is the structure plus the parts that are already TRUE regardless of what the flow becomes —
|
||||||
|
what a commander is, and that it begins as an ordinary conversation.
|
||||||
|
|
||||||
|
Sections marked `provisional` render with a visible marker, so a half-written guide cannot ship looking
|
||||||
|
finished. Removing the flag is the last step of writing one, not a formatting choice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GuideSection = {
|
||||||
|
title: string;
|
||||||
|
/** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */
|
||||||
|
body: string[];
|
||||||
|
/** True until the copy is settled. Renders a marker; the whole point is that it is visible. */
|
||||||
|
provisional?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NEW_COMMANDER_GUIDE: GuideSection[] = [
|
||||||
|
{
|
||||||
|
title: 'What a commander is',
|
||||||
|
body: [
|
||||||
|
'An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.',
|
||||||
|
'It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Starting one',
|
||||||
|
provisional: true,
|
||||||
|
body: [
|
||||||
|
'Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.',
|
||||||
|
'TODO: the concrete steps, once the creation flow is settled. This replaces "open the background-job dialog and arm a watcher", which nobody could be expected to guess.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what
|
||||||
|
// the flow accepts, and the flow is being rebuilt. Written now, all three would need rewriting — and a
|
||||||
|
// wrong example is worse than none, because a user copies it and blames themselves when it does not work.
|
||||||
|
//
|
||||||
|
// Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is
|
||||||
|
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
|
||||||
|
// the boundary is visible.
|
||||||
|
title: 'Examples of a first message',
|
||||||
|
provisional: true,
|
||||||
|
body: [
|
||||||
|
'TODO: two or three example opening prompts, once the creation flow is settled. They are what turns "describe the job" from an instruction into something a first-time user can act on.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'While it runs',
|
||||||
|
provisional: true,
|
||||||
|
body: [
|
||||||
|
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
+2
-8
@@ -33,10 +33,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
|
|||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||||
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
||||||
<Crumb
|
<Crumb to={basePath} className="flex items-center gap-1 text-duck-teal cursor-pointer font-medium">
|
||||||
to={basePath}
|
|
||||||
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
|
||||||
>
|
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
|
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
|
||||||
</Crumb>
|
</Crumb>
|
||||||
@@ -52,10 +49,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
|
|||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className="text-duck-dark font-semibold">{segment}</span>
|
<span className="text-duck-dark font-semibold">{segment}</span>
|
||||||
) : (
|
) : (
|
||||||
<Crumb
|
<Crumb to={segmentPath} className="text-duck-teal cursor-pointer font-medium">
|
||||||
to={segmentPath}
|
|
||||||
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
|
||||||
>
|
|
||||||
{segment}
|
{segment}
|
||||||
</Crumb>
|
</Crumb>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
@@ -200,6 +201,26 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
|
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||||
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/*
|
||||||
|
Tell the session LISTS that this conversation exists.
|
||||||
|
|
||||||
|
The chat header resolves its own title by finding this session in the cached list —
|
||||||
|
`sessions.find((s) => s.id === sessionId)`. A newborn conversation is not in that list: it has
|
||||||
|
`staleTime: 30s` and nothing invalidated it, so for up to half a minute the header read "New chat" and
|
||||||
|
the rename control was absent, because rename is a per-row action on a list the session was not in yet.
|
||||||
|
Navigating away and back fixed it, which is what made it look like a rendering bug rather than a stale
|
||||||
|
cache.
|
||||||
|
|
||||||
|
Fired when the transcript id arrives — the START of the turn, not the end — because that is the moment
|
||||||
|
the session becomes real on disk and the server can already name it from the first user message. Fired
|
||||||
|
again when a turn finishes, because the harness writes its own `ai-title` while the turn runs.
|
||||||
|
*/
|
||||||
|
const announceSession = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['CLAUDE_SESSIONS'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
// Fetch the next older window and prepend it. The scroll container restores its position from the
|
// Fetch the next older window and prepend it. The scroll container restores its position from the
|
||||||
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
|
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
|
||||||
@@ -343,6 +364,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
claudeSessionIdRef.current = msg.claudeSessionId;
|
claudeSessionIdRef.current = msg.claudeSessionId;
|
||||||
setClaudeSessionId(msg.claudeSessionId);
|
setClaudeSessionId(msg.claudeSessionId);
|
||||||
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
||||||
|
// The lists do not know this conversation exists yet — see `announceSession`.
|
||||||
|
announceSession();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'assistant:delta':
|
case 'assistant:delta':
|
||||||
@@ -420,6 +443,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
const hadTools = toolCallsInTurnRef.current;
|
const hadTools = toolCallsInTurnRef.current;
|
||||||
toolCallsInTurnRef.current = false;
|
toolCallsInTurnRef.current = false;
|
||||||
onTurnCompleteRef.current?.(hadTools);
|
onTurnCompleteRef.current?.(hadTools);
|
||||||
|
// Every turn end: the harness writes its own `ai-title` while a turn runs, so the name the lists are
|
||||||
|
// showing may now be bettered by the one on disk.
|
||||||
|
announceSession();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
|
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
|
||||||
partCount?: number;
|
partCount?: number;
|
||||||
|
/**
|
||||||
|
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
|
||||||
|
*
|
||||||
|
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
|
||||||
|
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
|
||||||
|
* to decide it from the route, so the same commander opened in /chat quietly lost it.
|
||||||
|
*/
|
||||||
|
isCommander?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||||
|
|||||||
@@ -41,4 +41,208 @@
|
|||||||
@import './themes/vantablack.css';
|
@import './themes/vantablack.css';
|
||||||
@import './themes/white.css';
|
@import './themes/white.css';
|
||||||
|
|
||||||
|
/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */
|
||||||
|
@import './themes/aamis.css';
|
||||||
|
@import './themes/ado.css';
|
||||||
|
@import './themes/adrift.css';
|
||||||
|
@import './themes/aetheria.css';
|
||||||
|
@import './themes/akaito.css';
|
||||||
|
@import './themes/all-hallows-eve.css';
|
||||||
|
@import './themes/amberbyte.css';
|
||||||
|
@import './themes/amekoji.css';
|
||||||
|
@import './themes/andromeda.css';
|
||||||
|
@import './themes/anonymous.css';
|
||||||
|
@import './themes/apocalypse.css';
|
||||||
|
@import './themes/arc-blueberry.css';
|
||||||
|
@import './themes/archriot.css';
|
||||||
|
@import './themes/archwave.css';
|
||||||
|
@import './themes/artzen.css';
|
||||||
|
@import './themes/ash.css';
|
||||||
|
@import './themes/astrodark.css';
|
||||||
|
@import './themes/atari.css';
|
||||||
|
@import './themes/atelier.css';
|
||||||
|
@import './themes/aura.css';
|
||||||
|
@import './themes/ayu-dark.css';
|
||||||
|
@import './themes/ayu-light.css';
|
||||||
|
@import './themes/ayu-mirage.css';
|
||||||
|
@import './themes/azure.css';
|
||||||
|
@import './themes/azure-glow.css';
|
||||||
|
@import './themes/bad-hand.css';
|
||||||
|
@import './themes/batman.css';
|
||||||
|
@import './themes/batou.css';
|
||||||
|
@import './themes/bauhaus.css';
|
||||||
|
@import './themes/beachvan.css';
|
||||||
|
@import './themes/beta.css';
|
||||||
|
@import './themes/biscuit-de-mar-dark.css';
|
||||||
|
@import './themes/black-arch.css';
|
||||||
|
@import './themes/black-gold.css';
|
||||||
|
@import './themes/black-money.css';
|
||||||
|
@import './themes/blackturq.css';
|
||||||
|
@import './themes/blackwall.css';
|
||||||
|
@import './themes/blueridge.css';
|
||||||
|
@import './themes/brutalism.css';
|
||||||
|
@import './themes/caroline-skyline.css';
|
||||||
|
@import './themes/catppuccin-mocha.css';
|
||||||
|
@import './themes/cattpuccin-glass.css';
|
||||||
|
@import './themes/city-783.css';
|
||||||
|
@import './themes/copper-night.css';
|
||||||
|
@import './themes/corporate.css';
|
||||||
|
@import './themes/cpunk.css';
|
||||||
|
@import './themes/crimson.css';
|
||||||
|
@import './themes/crimson-gold.css';
|
||||||
|
@import './themes/cyberpunk-2.css';
|
||||||
|
@import './themes/dark-xp.css';
|
||||||
|
@import './themes/dayfox.css';
|
||||||
|
@import './themes/dead-space.css';
|
||||||
|
@import './themes/deckard.css';
|
||||||
|
@import './themes/delete.css';
|
||||||
|
@import './themes/delorean.css';
|
||||||
|
@import './themes/demon.css';
|
||||||
|
@import './themes/desert-gleam.css';
|
||||||
|
@import './themes/desert-twilight.css';
|
||||||
|
@import './themes/dotrb.css';
|
||||||
|
@import './themes/drac.css';
|
||||||
|
@import './themes/dracula.css';
|
||||||
|
@import './themes/dragon.css';
|
||||||
|
@import './themes/dreamwave.css';
|
||||||
|
@import './themes/elysian.css';
|
||||||
|
@import './themes/ember-n-ash.css';
|
||||||
|
@import './themes/eva-unit-01.css';
|
||||||
|
@import './themes/event-horizon.css';
|
||||||
|
@import './themes/everblush.css';
|
||||||
|
@import './themes/evergarden.css';
|
||||||
|
@import './themes/farline.css';
|
||||||
|
@import './themes/felix.css';
|
||||||
|
@import './themes/fiery-ocean.css';
|
||||||
|
@import './themes/fireside.css';
|
||||||
|
@import './themes/firesky.css';
|
||||||
|
@import './themes/flat-dracula.css';
|
||||||
|
@import './themes/flexoki-dark.css';
|
||||||
|
@import './themes/florida-man.css';
|
||||||
|
@import './themes/fluid-glass.css';
|
||||||
|
@import './themes/forest-green.css';
|
||||||
|
@import './themes/frost.css';
|
||||||
|
@import './themes/futurism.css';
|
||||||
|
@import './themes/ghost-pastel.css';
|
||||||
|
@import './themes/glory-antic.css';
|
||||||
|
@import './themes/gold-rush.css';
|
||||||
|
@import './themes/gornergrat-station.css';
|
||||||
|
@import './themes/gotham-city.css';
|
||||||
|
@import './themes/greek-noir.css';
|
||||||
|
@import './themes/green-city.css';
|
||||||
|
@import './themes/green-garden.css';
|
||||||
|
@import './themes/gruber-tsoding.css';
|
||||||
|
@import './themes/grudark.css';
|
||||||
|
@import './themes/harbor.css';
|
||||||
|
@import './themes/harbor-dark.css';
|
||||||
|
@import './themes/hex.css';
|
||||||
|
@import './themes/himalaya.css';
|
||||||
|
@import './themes/hinterlands.css';
|
||||||
|
@import './themes/hollow-knight.css';
|
||||||
|
@import './themes/infernium.css';
|
||||||
|
@import './themes/infernium-dark.css';
|
||||||
|
@import './themes/inkypinky.css';
|
||||||
|
@import './themes/kanso.css';
|
||||||
|
@import './themes/killer-klowns.css';
|
||||||
|
@import './themes/kimiko.css';
|
||||||
|
@import './themes/koyanagi.css';
|
||||||
|
@import './themes/kurayami.css';
|
||||||
|
@import './themes/lain.css';
|
||||||
|
@import './themes/lairetam.css';
|
||||||
|
@import './themes/latch.css';
|
||||||
|
@import './themes/latch-dark.css';
|
||||||
|
@import './themes/lilly.css';
|
||||||
|
@import './themes/lowlight.css';
|
||||||
|
@import './themes/lunar.css';
|
||||||
|
@import './themes/manga.css';
|
||||||
|
@import './themes/mapquest.css';
|
||||||
|
@import './themes/mars.css';
|
||||||
|
@import './themes/mecha-no-onna.css';
|
||||||
|
@import './themes/memento-mori.css';
|
||||||
|
@import './themes/midnight.css';
|
||||||
|
@import './themes/miles-morales.css';
|
||||||
|
@import './themes/milkmatcha-light.css';
|
||||||
|
@import './themes/mocha.css';
|
||||||
|
@import './themes/monochrome.css';
|
||||||
|
@import './themes/monokai.css';
|
||||||
|
@import './themes/monokai-dark.css';
|
||||||
|
@import './themes/monolith.css';
|
||||||
|
@import './themes/monolith-2.css';
|
||||||
|
@import './themes/moodpeak.css';
|
||||||
|
@import './themes/moon-orbit.css';
|
||||||
|
@import './themes/nagai-poolside.css';
|
||||||
|
@import './themes/nagai-twilight.css';
|
||||||
|
@import './themes/nebulite.css';
|
||||||
|
@import './themes/neo-sploosh.css';
|
||||||
|
@import './themes/neon-street.css';
|
||||||
|
@import './themes/neovoid.css';
|
||||||
|
@import './themes/nes.css';
|
||||||
|
@import './themes/night-owl.css';
|
||||||
|
@import './themes/nyc.css';
|
||||||
|
@import './themes/one-dark-pro.css';
|
||||||
|
@import './themes/oxford.css';
|
||||||
|
@import './themes/oxocarbon-dark.css';
|
||||||
|
@import './themes/pandora.css';
|
||||||
|
@import './themes/phosphor-os.css';
|
||||||
|
@import './themes/pina.css';
|
||||||
|
@import './themes/pink-blood.css';
|
||||||
|
@import './themes/pmndrs.css';
|
||||||
|
@import './themes/pulsar.css';
|
||||||
|
@import './themes/purple-moon.css';
|
||||||
|
@import './themes/purplewave.css';
|
||||||
|
@import './themes/rainy-night.css';
|
||||||
|
@import './themes/rdddcs.css';
|
||||||
|
@import './themes/red-monarch.css';
|
||||||
|
@import './themes/retropc.css';
|
||||||
|
@import './themes/reverie.css';
|
||||||
|
@import './themes/robco.css';
|
||||||
|
@import './themes/robco-mojave.css';
|
||||||
|
@import './themes/rose-pine-dark.css';
|
||||||
|
@import './themes/roseofdune.css';
|
||||||
|
@import './themes/royal.css';
|
||||||
|
@import './themes/rustleaf.css';
|
||||||
|
@import './themes/saga.css';
|
||||||
|
@import './themes/sakura.css';
|
||||||
|
@import './themes/sapphire.css';
|
||||||
|
@import './themes/seashells.css';
|
||||||
|
@import './themes/serenity.css';
|
||||||
|
@import './themes/shadesofjade.css';
|
||||||
|
@import './themes/snow.css';
|
||||||
|
@import './themes/snow-black.css';
|
||||||
|
@import './themes/solarized.css';
|
||||||
|
@import './themes/solarized-light.css';
|
||||||
|
@import './themes/solarized-osaka.css';
|
||||||
|
@import './themes/space-bound.css';
|
||||||
|
@import './themes/space-monkey.css';
|
||||||
|
@import './themes/spark.css';
|
||||||
|
@import './themes/spectra.css';
|
||||||
|
@import './themes/stellar.css';
|
||||||
|
@import './themes/stillmoon.css';
|
||||||
|
@import './themes/sunkissed.css';
|
||||||
|
@import './themes/sunset.css';
|
||||||
|
@import './themes/synthetica.css';
|
||||||
|
@import './themes/synthwave-84.css';
|
||||||
|
@import './themes/taikami.css';
|
||||||
|
@import './themes/tarot.css';
|
||||||
|
@import './themes/temerald.css';
|
||||||
|
@import './themes/terramour.css';
|
||||||
|
@import './themes/thegreek.css';
|
||||||
|
@import './themes/turbonite.css';
|
||||||
|
@import './themes/tycho.css';
|
||||||
|
@import './themes/type-17.css';
|
||||||
|
@import './themes/unicorn-dream.css';
|
||||||
|
@import './themes/van-gogh.css';
|
||||||
|
@import './themes/velocity.css';
|
||||||
|
@import './themes/velvet-night.css';
|
||||||
|
@import './themes/vesper.css';
|
||||||
|
@import './themes/vhs80.css';
|
||||||
|
@import './themes/vice-city.css';
|
||||||
|
@import './themes/void.css';
|
||||||
|
@import './themes/vurple.css';
|
||||||
|
@import './themes/wasteland.css';
|
||||||
|
@import './themes/waveform-dark.css';
|
||||||
|
@import './themes/white-gold.css';
|
||||||
|
@import './themes/x-1632.css';
|
||||||
|
@import './themes/yuugure.css';
|
||||||
|
|
||||||
@import './chrome.css';
|
@import './chrome.css';
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Aamis — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/vyrx-dev/omarchy-aamis-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='aamis'] {
|
||||||
|
--background: #080808;
|
||||||
|
--foreground: #eadccc;
|
||||||
|
--card: #0f0f0f;
|
||||||
|
--card-foreground: #eadccc;
|
||||||
|
--popover: #0f0f0f;
|
||||||
|
--popover-foreground: #eadccc;
|
||||||
|
--muted: #202020;
|
||||||
|
--muted-foreground: #a89f93;
|
||||||
|
--accent: #202020;
|
||||||
|
--accent-foreground: #eadccc;
|
||||||
|
--primary: #eacea7;
|
||||||
|
--primary-foreground: #080808;
|
||||||
|
--secondary: #202020;
|
||||||
|
--secondary-foreground: #eadccc;
|
||||||
|
--cta: #eacea7;
|
||||||
|
--cta-foreground: #080808;
|
||||||
|
--ctahover: #edd4b2;
|
||||||
|
--success: #cea37f;
|
||||||
|
--success-foreground: #080808;
|
||||||
|
--warning: #f4bb54;
|
||||||
|
--warning-foreground: #080808;
|
||||||
|
--destructive: #e25d6c;
|
||||||
|
--destructive-foreground: #080808;
|
||||||
|
--info: #e2be8a;
|
||||||
|
--info-foreground: #080808;
|
||||||
|
--busy: #eb8c60;
|
||||||
|
--busy-foreground: #080808;
|
||||||
|
--busy-ink: #eb8c60;
|
||||||
|
--border: #3a3734;
|
||||||
|
--input: #3a3734;
|
||||||
|
--ring: #eacea7;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #eacea7;
|
||||||
|
--chart-secondary: #ede4c8;
|
||||||
|
--status-success: #cea37f;
|
||||||
|
--status-warning: #f4bb54;
|
||||||
|
--status-in-progress: #e2be8a;
|
||||||
|
--page-bg: #080808;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(15 15 15 / 0.72);
|
||||||
|
--panel-border: rgb(58 55 52 / 0.9);
|
||||||
|
--panel-border-bare: rgb(58 55 52 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(15 15 15 / 0.95);
|
||||||
|
--panel-raised-border: rgb(58 55 52 / 1);
|
||||||
|
--panel-accent: #eacea7;
|
||||||
|
--panel-header-bg: rgb(32 32 32 / 0.6);
|
||||||
|
--panel-header-fg: #eadccc;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #0f0f0f;
|
||||||
|
--glass-bg: rgb(15 15 15 / 0.55);
|
||||||
|
--glass-border: rgb(58 55 52 / 0.7);
|
||||||
|
--dock-bg: rgb(15 15 15 / 0.7);
|
||||||
|
--dock-border: rgb(58 55 52 / 0.8);
|
||||||
|
--dock-tooltip-bg: #202020;
|
||||||
|
--header-bg: rgb(15 15 15 / 0.8);
|
||||||
|
--header-border: rgb(58 55 52 / 0.9);
|
||||||
|
--header-fg: #eadccc;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(114 107 100 / 0.07);
|
||||||
|
--card-grid-color: rgb(114 107 100 / 0.05);
|
||||||
|
--panel-grid-color: rgb(114 107 100 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(112 106 106 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(112 106 106 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(112 106 106 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(112 106 106 / 0.7);
|
||||||
|
--brand: #eacea7;
|
||||||
|
--brand-foreground: #080808;
|
||||||
|
--brand-primary: #eacea7;
|
||||||
|
--brand-accent: #e8ab3b;
|
||||||
|
--brand-warm: #eb8c60;
|
||||||
|
--brand-deep: #080808;
|
||||||
|
--brand-muted: #706a6a;
|
||||||
|
--ink: #eadccc;
|
||||||
|
--surface-warm: #0f0f0f;
|
||||||
|
--naturegreen: #cea37f;
|
||||||
|
--naturegreen-foreground: #080808;
|
||||||
|
--duck-dark: #eadccc;
|
||||||
|
--duck-teal: #e8ab3b;
|
||||||
|
--duck-yellow: #f4bb54;
|
||||||
|
--duck-orange: #eb8c60;
|
||||||
|
--duck-forest: #cea37f;
|
||||||
|
--duck-beige: #252422;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Ado — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/errantProgrammer/omarchy-ado-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='ado'] {
|
||||||
|
--background: #070708;
|
||||||
|
--foreground: #dfdee6;
|
||||||
|
--card: #0d0d0f;
|
||||||
|
--card-foreground: #dfdee6;
|
||||||
|
--popover: #0d0d0f;
|
||||||
|
--popover-foreground: #dfdee6;
|
||||||
|
--muted: #1e1e20;
|
||||||
|
--muted-foreground: #a09fa6;
|
||||||
|
--accent: #1e1e20;
|
||||||
|
--accent-foreground: #dfdee6;
|
||||||
|
--primary: #a2e0ee;
|
||||||
|
--primary-foreground: #070708;
|
||||||
|
--secondary: #1e1e20;
|
||||||
|
--secondary-foreground: #dfdee6;
|
||||||
|
--cta: #a2e0ee;
|
||||||
|
--cta-foreground: #070708;
|
||||||
|
--ctahover: #ade4f0;
|
||||||
|
--success: #014761;
|
||||||
|
--success-foreground: #dfdee6;
|
||||||
|
--warning: #8bb0c6;
|
||||||
|
--warning-foreground: #070708;
|
||||||
|
--destructive: #903034;
|
||||||
|
--destructive-foreground: #dfdee6;
|
||||||
|
--info: #4545f5;
|
||||||
|
--info-foreground: #dfdee6;
|
||||||
|
--busy: #8e707d;
|
||||||
|
--busy-foreground: #070708;
|
||||||
|
--busy-ink: #8e707d;
|
||||||
|
--border: #363639;
|
||||||
|
--input: #363639;
|
||||||
|
--ring: #a2e0ee;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #a2e0ee;
|
||||||
|
--chart-secondary: #25516a;
|
||||||
|
--status-success: #014761;
|
||||||
|
--status-warning: #8bb0c6;
|
||||||
|
--status-in-progress: #4545f5;
|
||||||
|
--page-bg: #070708;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(13 13 15 / 0.72);
|
||||||
|
--panel-border: rgb(54 54 57 / 0.9);
|
||||||
|
--panel-border-bare: rgb(54 54 57 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(13 13 15 / 0.95);
|
||||||
|
--panel-raised-border: rgb(54 54 57 / 1);
|
||||||
|
--panel-accent: #a2e0ee;
|
||||||
|
--panel-header-bg: rgb(30 30 32 / 0.6);
|
||||||
|
--panel-header-fg: #dfdee6;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #0d0d0f;
|
||||||
|
--glass-bg: rgb(13 13 15 / 0.55);
|
||||||
|
--glass-border: rgb(54 54 57 / 0.7);
|
||||||
|
--dock-bg: rgb(13 13 15 / 0.7);
|
||||||
|
--dock-border: rgb(54 54 57 / 0.8);
|
||||||
|
--dock-tooltip-bg: #1e1e20;
|
||||||
|
--header-bg: rgb(13 13 15 / 0.8);
|
||||||
|
--header-border: rgb(54 54 57 / 0.9);
|
||||||
|
--header-fg: #dfdee6;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(107 107 112 / 0.07);
|
||||||
|
--card-grid-color: rgb(107 107 112 / 0.05);
|
||||||
|
--panel-grid-color: rgb(107 107 112 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(144 142 170 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(144 142 170 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(144 142 170 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(144 142 170 / 0.7);
|
||||||
|
--brand: #a2e0ee;
|
||||||
|
--brand-foreground: #070708;
|
||||||
|
--brand-primary: #a2e0ee;
|
||||||
|
--brand-accent: #45c1dd;
|
||||||
|
--brand-warm: #8e707d;
|
||||||
|
--brand-deep: #070708;
|
||||||
|
--brand-muted: #908eaa;
|
||||||
|
--ink: #dfdee6;
|
||||||
|
--surface-warm: #0d0d0f;
|
||||||
|
--naturegreen: #014761;
|
||||||
|
--naturegreen-foreground: #dfdee6;
|
||||||
|
--duck-dark: #dfdee6;
|
||||||
|
--duck-teal: #45c1dd;
|
||||||
|
--duck-yellow: #8bb0c6;
|
||||||
|
--duck-orange: #8e707d;
|
||||||
|
--duck-forest: #014761;
|
||||||
|
--duck-beige: #222225;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Adrift — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/jaredb1011/omarchy-adrift-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='adrift'] {
|
||||||
|
--background: #0e1715;
|
||||||
|
--foreground: #ddd6c2;
|
||||||
|
--card: #1a2927;
|
||||||
|
--card-foreground: #ddd6c2;
|
||||||
|
--popover: #1a2927;
|
||||||
|
--popover-foreground: #ddd6c2;
|
||||||
|
--muted: #2a3836;
|
||||||
|
--muted-foreground: #a3a294;
|
||||||
|
--accent: #2a3836;
|
||||||
|
--accent-foreground: #ddd6c2;
|
||||||
|
--primary: #94d0d0;
|
||||||
|
--primary-foreground: #0e1715;
|
||||||
|
--secondary: #2a3836;
|
||||||
|
--secondary-foreground: #ddd6c2;
|
||||||
|
--cta: #94d0d0;
|
||||||
|
--cta-foreground: #0e1715;
|
||||||
|
--ctahover: #a1d6d6;
|
||||||
|
--success: #66987b;
|
||||||
|
--success-foreground: #0e1715;
|
||||||
|
--warning: #bfa26b;
|
||||||
|
--warning-foreground: #0e1715;
|
||||||
|
--destructive: #cb645d;
|
||||||
|
--destructive-foreground: #0e1715;
|
||||||
|
--info: #a9bfbf;
|
||||||
|
--info-foreground: #0e1715;
|
||||||
|
--busy: #c58364;
|
||||||
|
--busy-foreground: #0e1715;
|
||||||
|
--busy-ink: #c58364;
|
||||||
|
--border: #404b46;
|
||||||
|
--input: #404b46;
|
||||||
|
--ring: #94d0d0;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #94d0d0;
|
||||||
|
--chart-secondary: #9a5840;
|
||||||
|
--status-success: #66987b;
|
||||||
|
--status-warning: #bfa26b;
|
||||||
|
--status-in-progress: #a9bfbf;
|
||||||
|
--page-bg: #0e1715;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(26 41 39 / 0.72);
|
||||||
|
--panel-border: rgb(64 75 70 / 0.9);
|
||||||
|
--panel-border-bare: rgb(64 75 70 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(26 41 39 / 0.95);
|
||||||
|
--panel-raised-border: rgb(64 75 70 / 1);
|
||||||
|
--panel-accent: #94d0d0;
|
||||||
|
--panel-header-bg: rgb(42 56 54 / 0.6);
|
||||||
|
--panel-header-fg: #ddd6c2;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1a2927;
|
||||||
|
--glass-bg: rgb(26 41 39 / 0.55);
|
||||||
|
--glass-border: rgb(64 75 70 / 0.7);
|
||||||
|
--dock-bg: rgb(26 41 39 / 0.7);
|
||||||
|
--dock-border: rgb(64 75 70 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2a3836;
|
||||||
|
--header-bg: rgb(26 41 39 / 0.8);
|
||||||
|
--header-border: rgb(64 75 70 / 0.9);
|
||||||
|
--header-fg: #ddd6c2;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(114 119 109 / 0.07);
|
||||||
|
--card-grid-color: rgb(114 119 109 / 0.05);
|
||||||
|
--panel-grid-color: rgb(114 119 109 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(181 181 181 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(181 181 181 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(181 181 181 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(181 181 181 / 0.7);
|
||||||
|
--brand: #94d0d0;
|
||||||
|
--brand-foreground: #0e1715;
|
||||||
|
--brand-primary: #94d0d0;
|
||||||
|
--brand-accent: #a9bfbf;
|
||||||
|
--brand-warm: #c58364;
|
||||||
|
--brand-deep: #0e1715;
|
||||||
|
--brand-muted: #b5b5b5;
|
||||||
|
--ink: #ddd6c2;
|
||||||
|
--surface-warm: #1a2927;
|
||||||
|
--naturegreen: #66987b;
|
||||||
|
--naturegreen-foreground: #0e1715;
|
||||||
|
--duck-dark: #ddd6c2;
|
||||||
|
--duck-teal: #a9bfbf;
|
||||||
|
--duck-yellow: #bfa26b;
|
||||||
|
--duck-orange: #c58364;
|
||||||
|
--duck-forest: #66987b;
|
||||||
|
--duck-beige: #2e3a37;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Aetheria — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/JJDizz1L/aetheria (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='aetheria'] {
|
||||||
|
--background: #080510;
|
||||||
|
--foreground: #14b9b5;
|
||||||
|
--card: #0e091d;
|
||||||
|
--card-foreground: #14b9b5;
|
||||||
|
--popover: #0e091d;
|
||||||
|
--popover-foreground: #14b9b5;
|
||||||
|
--muted: #1f1a2d;
|
||||||
|
--muted-foreground: #309396;
|
||||||
|
--accent: #1f1a2d;
|
||||||
|
--accent-foreground: #14b9b5;
|
||||||
|
--primary: #ffbe74;
|
||||||
|
--primary-foreground: #080510;
|
||||||
|
--secondary: #1f1a2d;
|
||||||
|
--secondary-foreground: #14b9b5;
|
||||||
|
--cta: #ffbe74;
|
||||||
|
--cta-foreground: #080510;
|
||||||
|
--ctahover: #ffc685;
|
||||||
|
--success: #e20342;
|
||||||
|
--success-foreground: #f9f9f9;
|
||||||
|
--warning: #7cd699;
|
||||||
|
--warning-foreground: #080510;
|
||||||
|
--destructive: #c8e967;
|
||||||
|
--destructive-foreground: #080510;
|
||||||
|
--info: #be3f50;
|
||||||
|
--info-foreground: #f3f3f3;
|
||||||
|
--busy: #a2e080;
|
||||||
|
--busy-foreground: #080510;
|
||||||
|
--busy-ink: #a2e080;
|
||||||
|
--border: #0f2b3b;
|
||||||
|
--input: #0f2b3b;
|
||||||
|
--ring: #ffbe74;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #ffbe74;
|
||||||
|
--chart-secondary: #9147a8;
|
||||||
|
--status-success: #e20342;
|
||||||
|
--status-warning: #7cd699;
|
||||||
|
--status-in-progress: #be3f50;
|
||||||
|
--page-bg: #080510;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(14 9 29 / 0.72);
|
||||||
|
--panel-border: rgb(15 43 59 / 0.9);
|
||||||
|
--panel-border-bare: rgb(15 43 59 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(14 9 29 / 0.95);
|
||||||
|
--panel-raised-border: rgb(15 43 59 / 1);
|
||||||
|
--panel-accent: #ffbe74;
|
||||||
|
--panel-header-bg: rgb(31 26 45 / 0.6);
|
||||||
|
--panel-header-fg: #14b9b5;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #0e091d;
|
||||||
|
--glass-bg: rgb(14 9 29 / 0.55);
|
||||||
|
--glass-border: rgb(15 43 59 / 0.7);
|
||||||
|
--dock-bg: rgb(14 9 29 / 0.7);
|
||||||
|
--dock-border: rgb(15 43 59 / 0.8);
|
||||||
|
--dock-tooltip-bg: #1f1a2d;
|
||||||
|
--header-bg: rgb(14 9 29 / 0.8);
|
||||||
|
--header-border: rgb(15 43 59 / 0.9);
|
||||||
|
--header-fg: #14b9b5;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(41 105 113 / 0.07);
|
||||||
|
--card-grid-color: rgb(41 105 113 / 0.05);
|
||||||
|
--panel-grid-color: rgb(41 105 113 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(197 50 83 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(197 50 83 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(197 50 83 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(197 50 83 / 0.7);
|
||||||
|
--brand: #ffbe74;
|
||||||
|
--brand-foreground: #080510;
|
||||||
|
--brand-primary: #ffbe74;
|
||||||
|
--brand-accent: #ff7f41;
|
||||||
|
--brand-warm: #a2e080;
|
||||||
|
--brand-deep: #080510;
|
||||||
|
--brand-muted: #c53253;
|
||||||
|
--ink: #14b9b5;
|
||||||
|
--surface-warm: #0e091d;
|
||||||
|
--naturegreen: #e20342;
|
||||||
|
--naturegreen-foreground: #f9f9f9;
|
||||||
|
--duck-dark: #14b9b5;
|
||||||
|
--duck-teal: #ff7f41;
|
||||||
|
--duck-yellow: #7cd699;
|
||||||
|
--duck-orange: #a2e080;
|
||||||
|
--duck-forest: #e20342;
|
||||||
|
--duck-beige: #0f1b2c;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Akaito — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Light theme (light), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/stannorbvb-cmd/akaito (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='akaito'] {
|
||||||
|
--background: #f3e4cb;
|
||||||
|
--foreground: #4d2e1a;
|
||||||
|
--card: #eadcc4;
|
||||||
|
--card-foreground: #4d2e1a;
|
||||||
|
--popover: #eadcc4;
|
||||||
|
--popover-foreground: #4d2e1a;
|
||||||
|
--muted: #e4d6bf;
|
||||||
|
--muted-foreground: #6f5845;
|
||||||
|
--accent: #e4d6bf;
|
||||||
|
--accent-foreground: #4d2e1a;
|
||||||
|
--primary: #a32f1a;
|
||||||
|
--primary-foreground: #dbcdb7;
|
||||||
|
--secondary: #e4d6bf;
|
||||||
|
--secondary-foreground: #4d2e1a;
|
||||||
|
--cta: #a32f1a;
|
||||||
|
--cta-foreground: #dbcdb7;
|
||||||
|
--ctahover: #8f2917;
|
||||||
|
--success: #a46d2d;
|
||||||
|
--success-foreground: #0b0a09;
|
||||||
|
--warning: #a8611f;
|
||||||
|
--warning-foreground: #faf8f4;
|
||||||
|
--destructive: #a4373c;
|
||||||
|
--destructive-foreground: #e0d5c2;
|
||||||
|
--info: #a32f1a;
|
||||||
|
--info-foreground: #dbcdb7;
|
||||||
|
--busy: #a64c2e;
|
||||||
|
--busy-foreground: #ece5d9;
|
||||||
|
--busy-ink: #a64c2e;
|
||||||
|
--border: #d8c7b0;
|
||||||
|
--input: #d3c2ac;
|
||||||
|
--ring: #a32f1a;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #a32f1a;
|
||||||
|
--chart-secondary: #9c3521;
|
||||||
|
--status-success: #a46d2d;
|
||||||
|
--status-warning: #a8611f;
|
||||||
|
--status-in-progress: #a32f1a;
|
||||||
|
--page-bg: #f3e4cb;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(234 220 196 / 0.72);
|
||||||
|
--panel-border: rgb(216 199 176 / 0.9);
|
||||||
|
--panel-border-bare: rgb(216 199 176 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(234 220 196 / 0.95);
|
||||||
|
--panel-raised-border: rgb(216 199 176 / 1);
|
||||||
|
--panel-accent: #a32f1a;
|
||||||
|
--panel-header-bg: rgb(228 214 191 / 0.6);
|
||||||
|
--panel-header-fg: #4d2e1a;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #eadcc4;
|
||||||
|
--glass-bg: rgb(234 220 196 / 0.55);
|
||||||
|
--glass-border: rgb(216 199 176 / 0.7);
|
||||||
|
--dock-bg: rgb(234 220 196 / 0.7);
|
||||||
|
--dock-border: rgb(216 199 176 / 0.8);
|
||||||
|
--dock-tooltip-bg: #e4d6bf;
|
||||||
|
--header-bg: rgb(234 220 196 / 0.8);
|
||||||
|
--header-border: rgb(216 199 176 / 0.9);
|
||||||
|
--header-fg: #4d2e1a;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(139 120 101 / 0.07);
|
||||||
|
--card-grid-color: rgb(139 120 101 / 0.05);
|
||||||
|
--panel-grid-color: rgb(139 120 101 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(159 130 83 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(159 130 83 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(159 130 83 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(159 130 83 / 0.7);
|
||||||
|
--brand: #a32f1a;
|
||||||
|
--brand-foreground: #dbcdb7;
|
||||||
|
--brand-primary: #a32f1a;
|
||||||
|
--brand-accent: #755833;
|
||||||
|
--brand-warm: #a64c2e;
|
||||||
|
--brand-deep: #e4d6bf;
|
||||||
|
--brand-muted: #9f8253;
|
||||||
|
--ink: #4d2e1a;
|
||||||
|
--surface-warm: #eadcc4;
|
||||||
|
--naturegreen: #a46d2d;
|
||||||
|
--naturegreen-foreground: #0b0a09;
|
||||||
|
--duck-dark: #4d2e1a;
|
||||||
|
--duck-teal: #755833;
|
||||||
|
--duck-yellow: #a8611f;
|
||||||
|
--duck-orange: #a64c2e;
|
||||||
|
--duck-forest: #a46d2d;
|
||||||
|
--duck-beige: #dacbb3;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
All Hallows Eve — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/guilhermetk/omarchy-all-hallows-eve-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='all-hallows-eve'] {
|
||||||
|
--background: #000000;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--card: #000000;
|
||||||
|
--card-foreground: #ffffff;
|
||||||
|
--popover: #000000;
|
||||||
|
--popover-foreground: #ffffff;
|
||||||
|
--muted: #121212;
|
||||||
|
--muted-foreground: #b3b3b3;
|
||||||
|
--accent: #121212;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--primary: #cccc33;
|
||||||
|
--primary-foreground: #000000;
|
||||||
|
--secondary: #121212;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--cta: #cccc33;
|
||||||
|
--cta-foreground: #000000;
|
||||||
|
--ctahover: #d2d24b;
|
||||||
|
--success: #66cc33;
|
||||||
|
--success-foreground: #000000;
|
||||||
|
--warning: #cc7833;
|
||||||
|
--warning-foreground: #000000;
|
||||||
|
--destructive: #c83730;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--info: #3387cc;
|
||||||
|
--info-foreground: #000000;
|
||||||
|
--busy: #ca5832;
|
||||||
|
--busy-foreground: #000000;
|
||||||
|
--busy-ink: #ca5832;
|
||||||
|
--border: #323232;
|
||||||
|
--input: #323232;
|
||||||
|
--ring: #cccc33;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #cccc33;
|
||||||
|
--chart-secondary: #9933cc;
|
||||||
|
--status-success: #66cc33;
|
||||||
|
--status-warning: #cc7833;
|
||||||
|
--status-in-progress: #3387cc;
|
||||||
|
--page-bg: #000000;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(0 0 0 / 0.72);
|
||||||
|
--panel-border: rgb(50 50 50 / 0.9);
|
||||||
|
--panel-border-bare: rgb(50 50 50 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(0 0 0 / 0.95);
|
||||||
|
--panel-raised-border: rgb(50 50 50 / 1);
|
||||||
|
--panel-accent: #cccc33;
|
||||||
|
--panel-header-bg: rgb(18 18 18 / 0.6);
|
||||||
|
--panel-header-fg: #ffffff;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #000000;
|
||||||
|
--glass-bg: rgb(0 0 0 / 0.55);
|
||||||
|
--glass-border: rgb(50 50 50 / 0.7);
|
||||||
|
--dock-bg: rgb(0 0 0 / 0.7);
|
||||||
|
--dock-border: rgb(50 50 50 / 0.8);
|
||||||
|
--dock-tooltip-bg: #121212;
|
||||||
|
--header-bg: rgb(0 0 0 / 0.8);
|
||||||
|
--header-border: rgb(50 50 50 / 0.9);
|
||||||
|
--header-fg: #ffffff;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(115 115 115 / 0.07);
|
||||||
|
--card-grid-color: rgb(115 115 115 / 0.05);
|
||||||
|
--panel-grid-color: rgb(115 115 115 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(64 64 64 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(64 64 64 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(64 64 64 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(64 64 64 / 0.7);
|
||||||
|
--brand: #cccc33;
|
||||||
|
--brand-foreground: #000000;
|
||||||
|
--brand-primary: #cccc33;
|
||||||
|
--brand-accent: #cccc33;
|
||||||
|
--brand-warm: #ca5832;
|
||||||
|
--brand-deep: #000000;
|
||||||
|
--brand-muted: #404040;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--surface-warm: #000000;
|
||||||
|
--naturegreen: #66cc33;
|
||||||
|
--naturegreen-foreground: #000000;
|
||||||
|
--duck-dark: #ffffff;
|
||||||
|
--duck-teal: #cccc33;
|
||||||
|
--duck-yellow: #cc7833;
|
||||||
|
--duck-orange: #ca5832;
|
||||||
|
--duck-forest: #66cc33;
|
||||||
|
--duck-beige: #1a1a1a;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Amberbyte — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/tahfizhabib/omarchy-amberbyte-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='amberbyte'] {
|
||||||
|
--background: #0f090a;
|
||||||
|
--foreground: #f2e8e8;
|
||||||
|
--card: #1b1112;
|
||||||
|
--card-foreground: #f2e8e8;
|
||||||
|
--popover: #1b1112;
|
||||||
|
--popover-foreground: #f2e8e8;
|
||||||
|
--muted: #2b2223;
|
||||||
|
--muted-foreground: #b2a8a8;
|
||||||
|
--accent: #2b2223;
|
||||||
|
--accent-foreground: #f2e8e8;
|
||||||
|
--primary: #f7b3b3;
|
||||||
|
--primary-foreground: #0f090a;
|
||||||
|
--secondary: #2b2223;
|
||||||
|
--secondary-foreground: #f2e8e8;
|
||||||
|
--cta: #f7b3b3;
|
||||||
|
--cta-foreground: #0f090a;
|
||||||
|
--ctahover: #f8bcbc;
|
||||||
|
--success: #c05a5a;
|
||||||
|
--success-foreground: #0f090a;
|
||||||
|
--warning: #d66b6b;
|
||||||
|
--warning-foreground: #0f090a;
|
||||||
|
--destructive: #b44a4a;
|
||||||
|
--destructive-foreground: #f5eded;
|
||||||
|
--info: #d66b6b;
|
||||||
|
--info-foreground: #0f090a;
|
||||||
|
--busy: #c55b5b;
|
||||||
|
--busy-foreground: #0f090a;
|
||||||
|
--busy-ink: #c55b5b;
|
||||||
|
--border: #453b3c;
|
||||||
|
--input: #453b3c;
|
||||||
|
--ring: #f7b3b3;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #f7b3b3;
|
||||||
|
--chart-secondary: #d66b6b;
|
||||||
|
--status-success: #c05a5a;
|
||||||
|
--status-warning: #d66b6b;
|
||||||
|
--status-in-progress: #d66b6b;
|
||||||
|
--page-bg: #0f090a;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(27 17 18 / 0.72);
|
||||||
|
--panel-border: rgb(69 59 60 / 0.9);
|
||||||
|
--panel-border-bare: rgb(69 59 60 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(27 17 18 / 0.95);
|
||||||
|
--panel-raised-border: rgb(69 59 60 / 1);
|
||||||
|
--panel-accent: #f7b3b3;
|
||||||
|
--panel-header-bg: rgb(43 34 35 / 0.6);
|
||||||
|
--panel-header-fg: #f2e8e8;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1b1112;
|
||||||
|
--glass-bg: rgb(27 17 18 / 0.55);
|
||||||
|
--glass-border: rgb(69 59 60 / 0.7);
|
||||||
|
--dock-bg: rgb(27 17 18 / 0.7);
|
||||||
|
--dock-border: rgb(69 59 60 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2b2223;
|
||||||
|
--header-bg: rgb(27 17 18 / 0.8);
|
||||||
|
--header-border: rgb(69 59 60 / 0.9);
|
||||||
|
--header-fg: #f2e8e8;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(124 114 114 / 0.07);
|
||||||
|
--card-grid-color: rgb(124 114 114 / 0.05);
|
||||||
|
--panel-grid-color: rgb(124 114 114 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(113 103 104 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(113 103 104 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(113 103 104 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(113 103 104 / 0.7);
|
||||||
|
--brand: #f7b3b3;
|
||||||
|
--brand-foreground: #0f090a;
|
||||||
|
--brand-primary: #f7b3b3;
|
||||||
|
--brand-accent: #f48a8a;
|
||||||
|
--brand-warm: #c55b5b;
|
||||||
|
--brand-deep: #0f090a;
|
||||||
|
--brand-muted: #716768;
|
||||||
|
--ink: #f2e8e8;
|
||||||
|
--surface-warm: #1b1112;
|
||||||
|
--naturegreen: #c05a5a;
|
||||||
|
--naturegreen-foreground: #0f090a;
|
||||||
|
--duck-dark: #f2e8e8;
|
||||||
|
--duck-teal: #f48a8a;
|
||||||
|
--duck-yellow: #d66b6b;
|
||||||
|
--duck-orange: #c55b5b;
|
||||||
|
--duck-forest: #c05a5a;
|
||||||
|
--duck-beige: #312727;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Amekoji — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/atif-1402/omarchy-amekoji-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='amekoji'] {
|
||||||
|
--background: #131617;
|
||||||
|
--foreground: #dcd6d6;
|
||||||
|
--card: #232829;
|
||||||
|
--card-foreground: #dcd6d6;
|
||||||
|
--popover: #232829;
|
||||||
|
--popover-foreground: #dcd6d6;
|
||||||
|
--muted: #323738;
|
||||||
|
--muted-foreground: #a5a2a2;
|
||||||
|
--accent: #323738;
|
||||||
|
--accent-foreground: #dcd6d6;
|
||||||
|
--primary: #98b3b3;
|
||||||
|
--primary-foreground: #131617;
|
||||||
|
--secondary: #323738;
|
||||||
|
--secondary-foreground: #dcd6d6;
|
||||||
|
--cta: #98b3b3;
|
||||||
|
--cta-foreground: #131617;
|
||||||
|
--ctahover: #a4bcbc;
|
||||||
|
--success: #98b3a3;
|
||||||
|
--success-foreground: #131617;
|
||||||
|
--warning: #93b3a6;
|
||||||
|
--warning-foreground: #131617;
|
||||||
|
--destructive: #b39a98;
|
||||||
|
--destructive-foreground: #131617;
|
||||||
|
--info: #85abbc;
|
||||||
|
--info-foreground: #131617;
|
||||||
|
--busy: #a3a79f;
|
||||||
|
--busy-foreground: #131617;
|
||||||
|
--busy-ink: #a3a79f;
|
||||||
|
--border: #474a4b;
|
||||||
|
--input: #474a4b;
|
||||||
|
--ring: #98b3b3;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #98b3b3;
|
||||||
|
--chart-secondary: #bea7b0;
|
||||||
|
--status-success: #98b3a3;
|
||||||
|
--status-warning: #93b3a6;
|
||||||
|
--status-in-progress: #85abbc;
|
||||||
|
--page-bg: #131617;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(35 40 41 / 0.72);
|
||||||
|
--panel-border: rgb(71 74 75 / 0.9);
|
||||||
|
--panel-border-bare: rgb(71 74 75 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(35 40 41 / 0.95);
|
||||||
|
--panel-raised-border: rgb(71 74 75 / 1);
|
||||||
|
--panel-accent: #98b3b3;
|
||||||
|
--panel-header-bg: rgb(50 55 56 / 0.6);
|
||||||
|
--panel-header-fg: #dcd6d6;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #232829;
|
||||||
|
--glass-bg: rgb(35 40 41 / 0.55);
|
||||||
|
--glass-border: rgb(71 74 75 / 0.7);
|
||||||
|
--dock-bg: rgb(35 40 41 / 0.7);
|
||||||
|
--dock-border: rgb(71 74 75 / 0.8);
|
||||||
|
--dock-tooltip-bg: #323738;
|
||||||
|
--header-bg: rgb(35 40 41 / 0.8);
|
||||||
|
--header-border: rgb(71 74 75 / 0.9);
|
||||||
|
--header-fg: #dcd6d6;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(118 118 119 / 0.07);
|
||||||
|
--card-grid-color: rgb(118 118 119 / 0.05);
|
||||||
|
--panel-grid-color: rgb(118 118 119 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(120 133 135 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(120 133 135 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(120 133 135 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(120 133 135 / 0.7);
|
||||||
|
--brand: #98b3b3;
|
||||||
|
--brand-foreground: #131617;
|
||||||
|
--brand-primary: #98b3b3;
|
||||||
|
--brand-accent: #98b3b3;
|
||||||
|
--brand-warm: #a3a79f;
|
||||||
|
--brand-deep: #131617;
|
||||||
|
--brand-muted: #788587;
|
||||||
|
--ink: #dcd6d6;
|
||||||
|
--surface-warm: #232829;
|
||||||
|
--naturegreen: #98b3a3;
|
||||||
|
--naturegreen-foreground: #131617;
|
||||||
|
--duck-dark: #dcd6d6;
|
||||||
|
--duck-teal: #98b3b3;
|
||||||
|
--duck-yellow: #93b3a6;
|
||||||
|
--duck-orange: #a3a79f;
|
||||||
|
--duck-forest: #98b3a3;
|
||||||
|
--duck-beige: #36393a;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Andromeda — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/signaldirective/andromeda (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='andromeda'] {
|
||||||
|
--background: #131519;
|
||||||
|
--foreground: #e5e5e5;
|
||||||
|
--card: #23262e;
|
||||||
|
--card-foreground: #e5e5e5;
|
||||||
|
--popover: #23262e;
|
||||||
|
--popover-foreground: #e5e5e5;
|
||||||
|
--muted: #32353d;
|
||||||
|
--muted-foreground: #abacae;
|
||||||
|
--accent: #32353d;
|
||||||
|
--accent-foreground: #e5e5e5;
|
||||||
|
--primary: #00e8c6;
|
||||||
|
--primary-foreground: #131519;
|
||||||
|
--secondary: #32353d;
|
||||||
|
--secondary-foreground: #e5e5e5;
|
||||||
|
--cta: #00e8c6;
|
||||||
|
--cta-foreground: #131519;
|
||||||
|
--ctahover: #1febcd;
|
||||||
|
--success: #00e8c6;
|
||||||
|
--success-foreground: #131519;
|
||||||
|
--warning: #ffe66d;
|
||||||
|
--warning-foreground: #131519;
|
||||||
|
--destructive: #f92672;
|
||||||
|
--destructive-foreground: #131519;
|
||||||
|
--info: #7cb7ff;
|
||||||
|
--info-foreground: #131519;
|
||||||
|
--busy: #fc8670;
|
||||||
|
--busy-foreground: #131519;
|
||||||
|
--busy-ink: #fc8670;
|
||||||
|
--border: #494c52;
|
||||||
|
--input: #494c52;
|
||||||
|
--ring: #00e8c6;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #00e8c6;
|
||||||
|
--chart-secondary: #c74ded;
|
||||||
|
--status-success: #00e8c6;
|
||||||
|
--status-warning: #ffe66d;
|
||||||
|
--status-in-progress: #7cb7ff;
|
||||||
|
--page-bg: #131519;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(35 38 46 / 0.72);
|
||||||
|
--panel-border: rgb(73 76 82 / 0.9);
|
||||||
|
--panel-border-bare: rgb(73 76 82 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(35 38 46 / 0.95);
|
||||||
|
--panel-raised-border: rgb(73 76 82 / 1);
|
||||||
|
--panel-accent: #00e8c6;
|
||||||
|
--panel-header-bg: rgb(50 53 61 / 0.6);
|
||||||
|
--panel-header-fg: #e5e5e5;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #23262e;
|
||||||
|
--glass-bg: rgb(35 38 46 / 0.55);
|
||||||
|
--glass-border: rgb(73 76 82 / 0.7);
|
||||||
|
--dock-bg: rgb(35 38 46 / 0.7);
|
||||||
|
--dock-border: rgb(73 76 82 / 0.8);
|
||||||
|
--dock-tooltip-bg: #32353d;
|
||||||
|
--header-bg: rgb(35 38 46 / 0.8);
|
||||||
|
--header-border: rgb(73 76 82 / 0.9);
|
||||||
|
--header-fg: #e5e5e5;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(122 124 128 / 0.07);
|
||||||
|
--card-grid-color: rgb(122 124 128 / 0.05);
|
||||||
|
--panel-grid-color: rgb(122 124 128 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(102 102 102 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(102 102 102 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(102 102 102 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(102 102 102 / 0.7);
|
||||||
|
--brand: #00e8c6;
|
||||||
|
--brand-foreground: #131519;
|
||||||
|
--brand-primary: #00e8c6;
|
||||||
|
--brand-accent: #96e072;
|
||||||
|
--brand-warm: #fc8670;
|
||||||
|
--brand-deep: #131519;
|
||||||
|
--brand-muted: #666666;
|
||||||
|
--ink: #e5e5e5;
|
||||||
|
--surface-warm: #23262e;
|
||||||
|
--naturegreen: #00e8c6;
|
||||||
|
--naturegreen-foreground: #131519;
|
||||||
|
--duck-dark: #e5e5e5;
|
||||||
|
--duck-teal: #96e072;
|
||||||
|
--duck-yellow: #ffe66d;
|
||||||
|
--duck-orange: #fc8670;
|
||||||
|
--duck-forest: #00e8c6;
|
||||||
|
--duck-beige: #363940;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Anonymous — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/j4v3l/omarchy-anonymous-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='anonymous'] {
|
||||||
|
--background: #000000;
|
||||||
|
--foreground: #fefefe;
|
||||||
|
--card: #000000;
|
||||||
|
--card-foreground: #fefefe;
|
||||||
|
--popover: #000000;
|
||||||
|
--popover-foreground: #fefefe;
|
||||||
|
--muted: #121212;
|
||||||
|
--muted-foreground: #b2b2b2;
|
||||||
|
--accent: #121212;
|
||||||
|
--accent-foreground: #fefefe;
|
||||||
|
--primary: #bdbdbd;
|
||||||
|
--primary-foreground: #000000;
|
||||||
|
--secondary: #121212;
|
||||||
|
--secondary-foreground: #fefefe;
|
||||||
|
--cta: #bdbdbd;
|
||||||
|
--cta-foreground: #000000;
|
||||||
|
--ctahover: #c5c5c5;
|
||||||
|
--success: #bdbdbd;
|
||||||
|
--success-foreground: #000000;
|
||||||
|
--warning: #cfcfcf;
|
||||||
|
--warning-foreground: #000000;
|
||||||
|
--destructive: #fefefe;
|
||||||
|
--destructive-foreground: #000000;
|
||||||
|
--info: #bdbdbd;
|
||||||
|
--info-foreground: #000000;
|
||||||
|
--busy: #e7e7e7;
|
||||||
|
--busy-foreground: #000000;
|
||||||
|
--busy-ink: #e7e7e7;
|
||||||
|
--border: #323232;
|
||||||
|
--input: #323232;
|
||||||
|
--ring: #bdbdbd;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #bdbdbd;
|
||||||
|
--chart-secondary: #bdbdbd;
|
||||||
|
--status-success: #bdbdbd;
|
||||||
|
--status-warning: #cfcfcf;
|
||||||
|
--status-in-progress: #bdbdbd;
|
||||||
|
--page-bg: #000000;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(0 0 0 / 0.72);
|
||||||
|
--panel-border: rgb(50 50 50 / 0.9);
|
||||||
|
--panel-border-bare: rgb(50 50 50 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(0 0 0 / 0.95);
|
||||||
|
--panel-raised-border: rgb(50 50 50 / 1);
|
||||||
|
--panel-accent: #bdbdbd;
|
||||||
|
--panel-header-bg: rgb(18 18 18 / 0.6);
|
||||||
|
--panel-header-fg: #fefefe;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #000000;
|
||||||
|
--glass-bg: rgb(0 0 0 / 0.55);
|
||||||
|
--glass-border: rgb(50 50 50 / 0.7);
|
||||||
|
--dock-bg: rgb(0 0 0 / 0.7);
|
||||||
|
--dock-border: rgb(50 50 50 / 0.8);
|
||||||
|
--dock-tooltip-bg: #121212;
|
||||||
|
--header-bg: rgb(0 0 0 / 0.8);
|
||||||
|
--header-border: rgb(50 50 50 / 0.9);
|
||||||
|
--header-fg: #fefefe;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(114 114 114 / 0.07);
|
||||||
|
--card-grid-color: rgb(114 114 114 / 0.05);
|
||||||
|
--panel-grid-color: rgb(114 114 114 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(26 26 26 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(26 26 26 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(26 26 26 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(26 26 26 / 0.7);
|
||||||
|
--brand: #bdbdbd;
|
||||||
|
--brand-foreground: #000000;
|
||||||
|
--brand-primary: #bdbdbd;
|
||||||
|
--brand-accent: #bdbdbd;
|
||||||
|
--brand-warm: #e7e7e7;
|
||||||
|
--brand-deep: #000000;
|
||||||
|
--brand-muted: #1a1a1a;
|
||||||
|
--ink: #fefefe;
|
||||||
|
--surface-warm: #000000;
|
||||||
|
--naturegreen: #bdbdbd;
|
||||||
|
--naturegreen-foreground: #000000;
|
||||||
|
--duck-dark: #fefefe;
|
||||||
|
--duck-teal: #bdbdbd;
|
||||||
|
--duck-yellow: #cfcfcf;
|
||||||
|
--duck-orange: #e7e7e7;
|
||||||
|
--duck-forest: #bdbdbd;
|
||||||
|
--duck-beige: #191919;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Apocalypse — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/atif-1402/omarchy-apocalypse-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='apocalypse'] {
|
||||||
|
--background: #090707;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--card: #110d0c;
|
||||||
|
--card-foreground: #ffffff;
|
||||||
|
--popover: #110d0c;
|
||||||
|
--popover-foreground: #ffffff;
|
||||||
|
--muted: #221e1d;
|
||||||
|
--muted-foreground: #b8b6b6;
|
||||||
|
--accent: #221e1d;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--primary: #e7e9e9;
|
||||||
|
--primary-foreground: #090707;
|
||||||
|
--secondary: #221e1d;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--cta: #e7e9e9;
|
||||||
|
--cta-foreground: #090707;
|
||||||
|
--ctahover: #eaecec;
|
||||||
|
--success: #98a09b;
|
||||||
|
--success-foreground: #090707;
|
||||||
|
--warning: #d3c4b8;
|
||||||
|
--warning-foreground: #090707;
|
||||||
|
--destructive: #ab7c7c;
|
||||||
|
--destructive-foreground: #090707;
|
||||||
|
--info: #a8a8ae;
|
||||||
|
--info-foreground: #090707;
|
||||||
|
--busy: #bfa09a;
|
||||||
|
--busy-foreground: #090707;
|
||||||
|
--busy-ink: #bfa09a;
|
||||||
|
--border: #403c3c;
|
||||||
|
--input: #403c3c;
|
||||||
|
--ring: #e7e9e9;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #e7e9e9;
|
||||||
|
--chart-secondary: #b6afb5;
|
||||||
|
--status-success: #98a09b;
|
||||||
|
--status-warning: #d3c4b8;
|
||||||
|
--status-in-progress: #a8a8ae;
|
||||||
|
--page-bg: #090707;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(17 13 12 / 0.72);
|
||||||
|
--panel-border: rgb(64 60 60 / 0.9);
|
||||||
|
--panel-border-bare: rgb(64 60 60 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(17 13 12 / 0.95);
|
||||||
|
--panel-raised-border: rgb(64 60 60 / 1);
|
||||||
|
--panel-accent: #e7e9e9;
|
||||||
|
--panel-header-bg: rgb(34 30 29 / 0.6);
|
||||||
|
--panel-header-fg: #ffffff;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #110d0c;
|
||||||
|
--glass-bg: rgb(17 13 12 / 0.55);
|
||||||
|
--glass-border: rgb(64 60 60 / 0.7);
|
||||||
|
--dock-bg: rgb(17 13 12 / 0.7);
|
||||||
|
--dock-border: rgb(64 60 60 / 0.8);
|
||||||
|
--dock-tooltip-bg: #221e1d;
|
||||||
|
--header-bg: rgb(17 13 12 / 0.8);
|
||||||
|
--header-border: rgb(64 60 60 / 0.9);
|
||||||
|
--header-fg: #ffffff;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(124 122 121 / 0.07);
|
||||||
|
--card-grid-color: rgb(124 122 121 / 0.05);
|
||||||
|
--panel-grid-color: rgb(124 122 121 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(140 123 118 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(140 123 118 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(140 123 118 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(140 123 118 / 0.7);
|
||||||
|
--brand: #e7e9e9;
|
||||||
|
--brand-foreground: #090707;
|
||||||
|
--brand-primary: #e7e9e9;
|
||||||
|
--brand-accent: #b8bdbc;
|
||||||
|
--brand-warm: #bfa09a;
|
||||||
|
--brand-deep: #090707;
|
||||||
|
--brand-muted: #8c7b76;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--surface-warm: #110d0c;
|
||||||
|
--naturegreen: #98a09b;
|
||||||
|
--naturegreen-foreground: #090707;
|
||||||
|
--duck-dark: #ffffff;
|
||||||
|
--duck-teal: #b8bdbc;
|
||||||
|
--duck-yellow: #d3c4b8;
|
||||||
|
--duck-orange: #bfa09a;
|
||||||
|
--duck-forest: #98a09b;
|
||||||
|
--duck-beige: #292524;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Arc Blueberry — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/vale-c/omarchy-arc-blueberry (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='arc-blueberry'] {
|
||||||
|
--background: #090b13;
|
||||||
|
--foreground: #bcc1dc;
|
||||||
|
--card: #111422;
|
||||||
|
--card-foreground: #bcc1dc;
|
||||||
|
--popover: #111422;
|
||||||
|
--popover-foreground: #bcc1dc;
|
||||||
|
--muted: #222431;
|
||||||
|
--muted-foreground: #898da4;
|
||||||
|
--accent: #222431;
|
||||||
|
--accent-foreground: #bcc1dc;
|
||||||
|
--primary: #0fffeb;
|
||||||
|
--primary-foreground: #090b13;
|
||||||
|
--secondary: #222431;
|
||||||
|
--secondary-foreground: #bcc1dc;
|
||||||
|
--cta: #0fffeb;
|
||||||
|
--cta-foreground: #090b13;
|
||||||
|
--ctahover: #2cffed;
|
||||||
|
--success: #3cec85;
|
||||||
|
--success-foreground: #090b13;
|
||||||
|
--warning: #eacd61;
|
||||||
|
--warning-foreground: #090b13;
|
||||||
|
--destructive: #e35535;
|
||||||
|
--destructive-foreground: #090b13;
|
||||||
|
--info: #69c3ff;
|
||||||
|
--info-foreground: #090b13;
|
||||||
|
--busy: #e7914b;
|
||||||
|
--busy-foreground: #090b13;
|
||||||
|
--busy-ink: #e7914b;
|
||||||
|
--border: #333646;
|
||||||
|
--input: #333646;
|
||||||
|
--ring: #0fffeb;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #0fffeb;
|
||||||
|
--chart-secondary: #f38cec;
|
||||||
|
--status-success: #3cec85;
|
||||||
|
--status-warning: #eacd61;
|
||||||
|
--status-in-progress: #69c3ff;
|
||||||
|
--page-bg: #090b13;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(17 20 34 / 0.72);
|
||||||
|
--panel-border: rgb(51 54 70 / 0.9);
|
||||||
|
--panel-border-bare: rgb(51 54 70 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(17 20 34 / 0.95);
|
||||||
|
--panel-raised-border: rgb(51 54 70 / 1);
|
||||||
|
--panel-accent: #0fffeb;
|
||||||
|
--panel-header-bg: rgb(34 36 49 / 0.6);
|
||||||
|
--panel-header-fg: #bcc1dc;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #111422;
|
||||||
|
--glass-bg: rgb(17 20 34 / 0.55);
|
||||||
|
--glass-border: rgb(51 54 70 / 0.7);
|
||||||
|
--dock-bg: rgb(17 20 34 / 0.7);
|
||||||
|
--dock-border: rgb(51 54 70 / 0.8);
|
||||||
|
--dock-tooltip-bg: #222431;
|
||||||
|
--header-bg: rgb(17 20 34 / 0.8);
|
||||||
|
--header-border: rgb(51 54 70 / 0.9);
|
||||||
|
--header-fg: #bcc1dc;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(94 98 118 / 0.07);
|
||||||
|
--card-grid-color: rgb(94 98 118 / 0.05);
|
||||||
|
--panel-grid-color: rgb(94 98 118 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(49 59 98 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(49 59 98 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(49 59 98 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(49 59 98 / 0.7);
|
||||||
|
--brand: #0fffeb;
|
||||||
|
--brand-foreground: #090b13;
|
||||||
|
--brand-primary: #0fffeb;
|
||||||
|
--brand-accent: #22ecdb;
|
||||||
|
--brand-warm: #e7914b;
|
||||||
|
--brand-deep: #090b13;
|
||||||
|
--brand-muted: #313b62;
|
||||||
|
--ink: #bcc1dc;
|
||||||
|
--surface-warm: #111422;
|
||||||
|
--naturegreen: #3cec85;
|
||||||
|
--naturegreen-foreground: #090b13;
|
||||||
|
--duck-dark: #bcc1dc;
|
||||||
|
--duck-teal: #22ecdb;
|
||||||
|
--duck-yellow: #eacd61;
|
||||||
|
--duck-orange: #e7914b;
|
||||||
|
--duck-forest: #3cec85;
|
||||||
|
--duck-beige: #222535;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Archriot — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/CyphrRiot/omarchy-archriot-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='archriot'] {
|
||||||
|
--background: #0e0f15;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--card: #1a1b26;
|
||||||
|
--card-foreground: #ffffff;
|
||||||
|
--popover: #1a1b26;
|
||||||
|
--popover-foreground: #ffffff;
|
||||||
|
--muted: #2a2b35;
|
||||||
|
--muted-foreground: #babbbe;
|
||||||
|
--accent: #2a2b35;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--primary: #73daca;
|
||||||
|
--primary-foreground: #0e0f15;
|
||||||
|
--secondary: #2a2b35;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--cta: #73daca;
|
||||||
|
--cta-foreground: #0e0f15;
|
||||||
|
--ctahover: #84ded0;
|
||||||
|
--success: #9ece6a;
|
||||||
|
--success-foreground: #0e0f15;
|
||||||
|
--warning: #e0af68;
|
||||||
|
--warning-foreground: #0e0f15;
|
||||||
|
--destructive: #ff7a93;
|
||||||
|
--destructive-foreground: #0e0f15;
|
||||||
|
--info: #7da6ff;
|
||||||
|
--info-foreground: #0e0f15;
|
||||||
|
--busy: #f0957e;
|
||||||
|
--busy-foreground: #0e0f15;
|
||||||
|
--busy-ink: #f0957e;
|
||||||
|
--border: #474851;
|
||||||
|
--input: #474851;
|
||||||
|
--ring: #73daca;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #73daca;
|
||||||
|
--chart-secondary: #bb9af7;
|
||||||
|
--status-success: #9ece6a;
|
||||||
|
--status-warning: #e0af68;
|
||||||
|
--status-in-progress: #7da6ff;
|
||||||
|
--page-bg: #0e0f15;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(26 27 38 / 0.72);
|
||||||
|
--panel-border: rgb(71 72 81 / 0.9);
|
||||||
|
--panel-border-bare: rgb(71 72 81 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(26 27 38 / 0.95);
|
||||||
|
--panel-raised-border: rgb(71 72 81 / 1);
|
||||||
|
--panel-accent: #73daca;
|
||||||
|
--panel-header-bg: rgb(42 43 53 / 0.6);
|
||||||
|
--panel-header-fg: #ffffff;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1a1b26;
|
||||||
|
--glass-bg: rgb(26 27 38 / 0.55);
|
||||||
|
--glass-border: rgb(71 72 81 / 0.7);
|
||||||
|
--dock-bg: rgb(26 27 38 / 0.7);
|
||||||
|
--dock-border: rgb(71 72 81 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2a2b35;
|
||||||
|
--header-bg: rgb(26 27 38 / 0.8);
|
||||||
|
--header-border: rgb(71 72 81 / 0.9);
|
||||||
|
--header-fg: #ffffff;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(129 130 136 / 0.07);
|
||||||
|
--card-grid-color: rgb(129 130 136 / 0.05);
|
||||||
|
--panel-grid-color: rgb(129 130 136 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(86 95 137 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(86 95 137 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(86 95 137 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(86 95 137 / 0.7);
|
||||||
|
--brand: #73daca;
|
||||||
|
--brand-foreground: #0e0f15;
|
||||||
|
--brand-primary: #73daca;
|
||||||
|
--brand-accent: #0db9d7;
|
||||||
|
--brand-warm: #f0957e;
|
||||||
|
--brand-deep: #0e0f15;
|
||||||
|
--brand-muted: #565f89;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--surface-warm: #1a1b26;
|
||||||
|
--naturegreen: #9ece6a;
|
||||||
|
--naturegreen-foreground: #0e0f15;
|
||||||
|
--duck-dark: #ffffff;
|
||||||
|
--duck-teal: #0db9d7;
|
||||||
|
--duck-yellow: #e0af68;
|
||||||
|
--duck-orange: #f0957e;
|
||||||
|
--duck-forest: #9ece6a;
|
||||||
|
--duck-beige: #31323c;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Archwave — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/davidguttman/archwave (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='archwave'] {
|
||||||
|
--background: #0e0719;
|
||||||
|
--foreground: #d4a5ff;
|
||||||
|
--card: #1a0d2e;
|
||||||
|
--card-foreground: #d4a5ff;
|
||||||
|
--popover: #1a0d2e;
|
||||||
|
--popover-foreground: #d4a5ff;
|
||||||
|
--muted: #2a1e3d;
|
||||||
|
--muted-foreground: #a17ec3;
|
||||||
|
--accent: #2a1e3d;
|
||||||
|
--accent-foreground: #d4a5ff;
|
||||||
|
--primary: #8ffef4;
|
||||||
|
--primary-foreground: #0e0719;
|
||||||
|
--secondary: #2a1e3d;
|
||||||
|
--secondary-foreground: #d4a5ff;
|
||||||
|
--cta: #8ffef4;
|
||||||
|
--cta-foreground: #0e0719;
|
||||||
|
--ctahover: #9cfef5;
|
||||||
|
--success: #5ffbf1;
|
||||||
|
--success-foreground: #0e0719;
|
||||||
|
--warning: #f9f871;
|
||||||
|
--warning-foreground: #0e0719;
|
||||||
|
--destructive: #ff6ec7;
|
||||||
|
--destructive-foreground: #0e0719;
|
||||||
|
--info: #8b9aff;
|
||||||
|
--info-foreground: #0e0719;
|
||||||
|
--busy: #fcb39c;
|
||||||
|
--busy-foreground: #0e0719;
|
||||||
|
--busy-ink: #fcb39c;
|
||||||
|
--border: #3e2b57;
|
||||||
|
--input: #3e2b57;
|
||||||
|
--ring: #8ffef4;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #8ffef4;
|
||||||
|
--chart-secondary: #f4a5ff;
|
||||||
|
--status-success: #5ffbf1;
|
||||||
|
--status-warning: #f9f871;
|
||||||
|
--status-in-progress: #8b9aff;
|
||||||
|
--page-bg: #0e0719;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(26 13 46 / 0.72);
|
||||||
|
--panel-border: rgb(62 43 87 / 0.9);
|
||||||
|
--panel-border-bare: rgb(62 43 87 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(26 13 46 / 0.95);
|
||||||
|
--panel-raised-border: rgb(62 43 87 / 1);
|
||||||
|
--panel-accent: #8ffef4;
|
||||||
|
--panel-header-bg: rgb(42 30 61 / 0.6);
|
||||||
|
--panel-header-fg: #d4a5ff;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1a0d2e;
|
||||||
|
--glass-bg: rgb(26 13 46 / 0.55);
|
||||||
|
--glass-border: rgb(62 43 87 / 0.7);
|
||||||
|
--dock-bg: rgb(26 13 46 / 0.7);
|
||||||
|
--dock-border: rgb(62 43 87 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2a1e3d;
|
||||||
|
--header-bg: rgb(26 13 46 / 0.8);
|
||||||
|
--header-border: rgb(62 43 87 / 0.9);
|
||||||
|
--header-fg: #d4a5ff;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(117 90 146 / 0.07);
|
||||||
|
--card-grid-color: rgb(117 90 146 / 0.05);
|
||||||
|
--panel-grid-color: rgb(117 90 146 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(84 58 110 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(84 58 110 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(84 58 110 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(84 58 110 / 0.7);
|
||||||
|
--brand: #8ffef4;
|
||||||
|
--brand-foreground: #0e0719;
|
||||||
|
--brand-primary: #8ffef4;
|
||||||
|
--brand-accent: #5ffbf1;
|
||||||
|
--brand-warm: #fcb39c;
|
||||||
|
--brand-deep: #0e0719;
|
||||||
|
--brand-muted: #543a6e;
|
||||||
|
--ink: #d4a5ff;
|
||||||
|
--surface-warm: #1a0d2e;
|
||||||
|
--naturegreen: #5ffbf1;
|
||||||
|
--naturegreen-foreground: #0e0719;
|
||||||
|
--duck-dark: #d4a5ff;
|
||||||
|
--duck-teal: #5ffbf1;
|
||||||
|
--duck-yellow: #f9f871;
|
||||||
|
--duck-orange: #fcb39c;
|
||||||
|
--duck-forest: #5ffbf1;
|
||||||
|
--duck-beige: #2d1c43;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Artzen — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/tahfizhabib/omarchy-artzen-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='artzen'] {
|
||||||
|
--background: #0d0f11;
|
||||||
|
--foreground: #fdf9f8;
|
||||||
|
--card: #181c1f;
|
||||||
|
--card-foreground: #fdf9f8;
|
||||||
|
--popover: #181c1f;
|
||||||
|
--popover-foreground: #fdf9f8;
|
||||||
|
--muted: #282c2f;
|
||||||
|
--muted-foreground: #b8b7b7;
|
||||||
|
--accent: #282c2f;
|
||||||
|
--accent-foreground: #fdf9f8;
|
||||||
|
--primary: #ecbeb9;
|
||||||
|
--primary-foreground: #0d0f11;
|
||||||
|
--secondary: #282c2f;
|
||||||
|
--secondary-foreground: #fdf9f8;
|
||||||
|
--cta: #ecbeb9;
|
||||||
|
--cta-foreground: #0d0f11;
|
||||||
|
--ctahover: #eec6c1;
|
||||||
|
--success: #9f6769;
|
||||||
|
--success-foreground: #000000;
|
||||||
|
--warning: #bb6d6c;
|
||||||
|
--warning-foreground: #0d0f11;
|
||||||
|
--destructive: #9b584d;
|
||||||
|
--destructive-foreground: #fdf9f8;
|
||||||
|
--info: #b97670;
|
||||||
|
--info-foreground: #0d0f11;
|
||||||
|
--busy: #ab635d;
|
||||||
|
--busy-foreground: #060606;
|
||||||
|
--busy-ink: #af6b65;
|
||||||
|
--border: #45474a;
|
||||||
|
--input: #45474a;
|
||||||
|
--ring: #ecbeb9;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #ecbeb9;
|
||||||
|
--chart-secondary: #9a8c8a;
|
||||||
|
--status-success: #9f6769;
|
||||||
|
--status-warning: #bb6d6c;
|
||||||
|
--status-in-progress: #b97670;
|
||||||
|
--page-bg: #0d0f11;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(24 28 31 / 0.72);
|
||||||
|
--panel-border: rgb(69 71 74 / 0.9);
|
||||||
|
--panel-border-bare: rgb(69 71 74 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(24 28 31 / 0.95);
|
||||||
|
--panel-raised-border: rgb(69 71 74 / 1);
|
||||||
|
--panel-accent: #ecbeb9;
|
||||||
|
--panel-header-bg: rgb(40 44 47 / 0.6);
|
||||||
|
--panel-header-fg: #fdf9f8;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #181c1f;
|
||||||
|
--glass-bg: rgb(24 28 31 / 0.55);
|
||||||
|
--glass-border: rgb(69 71 74 / 0.7);
|
||||||
|
--dock-bg: rgb(24 28 31 / 0.7);
|
||||||
|
--dock-border: rgb(69 71 74 / 0.8);
|
||||||
|
--dock-tooltip-bg: #282c2f;
|
||||||
|
--header-bg: rgb(24 28 31 / 0.8);
|
||||||
|
--header-border: rgb(69 71 74 / 0.9);
|
||||||
|
--header-fg: #fdf9f8;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(127 127 129 / 0.07);
|
||||||
|
--card-grid-color: rgb(127 127 129 / 0.05);
|
||||||
|
--panel-grid-color: rgb(127 127 129 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(177 141 133 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(177 141 133 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(177 141 133 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(177 141 133 / 0.7);
|
||||||
|
--brand: #ecbeb9;
|
||||||
|
--brand-foreground: #0d0f11;
|
||||||
|
--brand-primary: #ecbeb9;
|
||||||
|
--brand-accent: #da7a6f;
|
||||||
|
--brand-warm: #ab635d;
|
||||||
|
--brand-deep: #0d0f11;
|
||||||
|
--brand-muted: #b18d85;
|
||||||
|
--ink: #fdf9f8;
|
||||||
|
--surface-warm: #181c1f;
|
||||||
|
--naturegreen: #9f6769;
|
||||||
|
--naturegreen-foreground: #000000;
|
||||||
|
--duck-dark: #fdf9f8;
|
||||||
|
--duck-teal: #da7a6f;
|
||||||
|
--duck-yellow: #bb6d6c;
|
||||||
|
--duck-orange: #ab635d;
|
||||||
|
--duck-forest: #9f6769;
|
||||||
|
--duck-beige: #2f3235;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Ash — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/bjarneo/omarchy-ash-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='ash'] {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #e0e0e0;
|
||||||
|
--card: #121212;
|
||||||
|
--card-foreground: #e0e0e0;
|
||||||
|
--popover: #121212;
|
||||||
|
--popover-foreground: #e0e0e0;
|
||||||
|
--muted: #232323;
|
||||||
|
--muted-foreground: #a2a2a2;
|
||||||
|
--accent: #232323;
|
||||||
|
--accent-foreground: #e0e0e0;
|
||||||
|
--primary: #9e9e9e;
|
||||||
|
--primary-foreground: #0a0a0a;
|
||||||
|
--secondary: #232323;
|
||||||
|
--secondary-foreground: #e0e0e0;
|
||||||
|
--cta: #9e9e9e;
|
||||||
|
--cta-foreground: #0a0a0a;
|
||||||
|
--ctahover: #aaaaaa;
|
||||||
|
--success: #767676;
|
||||||
|
--success-foreground: #040404;
|
||||||
|
--warning: #9e9e9e;
|
||||||
|
--warning-foreground: #0a0a0a;
|
||||||
|
--destructive: #8a8a8a;
|
||||||
|
--destructive-foreground: #0a0a0a;
|
||||||
|
--info: #626262;
|
||||||
|
--info-foreground: #e0e0e0;
|
||||||
|
--busy: #949494;
|
||||||
|
--busy-foreground: #0a0a0a;
|
||||||
|
--busy-ink: #949494;
|
||||||
|
--border: #3a3a3a;
|
||||||
|
--input: #3a3a3a;
|
||||||
|
--ring: #9e9e9e;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #9e9e9e;
|
||||||
|
--chart-secondary: #8a8a8a;
|
||||||
|
--status-success: #767676;
|
||||||
|
--status-warning: #9e9e9e;
|
||||||
|
--status-in-progress: #626262;
|
||||||
|
--page-bg: #0a0a0a;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(18 18 18 / 0.72);
|
||||||
|
--panel-border: rgb(58 58 58 / 0.9);
|
||||||
|
--panel-border-bare: rgb(58 58 58 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(18 18 18 / 0.95);
|
||||||
|
--panel-raised-border: rgb(58 58 58 / 1);
|
||||||
|
--panel-accent: #9e9e9e;
|
||||||
|
--panel-header-bg: rgb(35 35 35 / 0.6);
|
||||||
|
--panel-header-fg: #e0e0e0;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #121212;
|
||||||
|
--glass-bg: rgb(18 18 18 / 0.55);
|
||||||
|
--glass-border: rgb(58 58 58 / 0.7);
|
||||||
|
--dock-bg: rgb(18 18 18 / 0.7);
|
||||||
|
--dock-border: rgb(58 58 58 / 0.8);
|
||||||
|
--dock-tooltip-bg: #232323;
|
||||||
|
--header-bg: rgb(18 18 18 / 0.8);
|
||||||
|
--header-border: rgb(58 58 58 / 0.9);
|
||||||
|
--header-fg: #e0e0e0;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(111 111 111 / 0.07);
|
||||||
|
--card-grid-color: rgb(111 111 111 / 0.05);
|
||||||
|
--panel-grid-color: rgb(111 111 111 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(138 138 138 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(138 138 138 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(138 138 138 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(138 138 138 / 0.7);
|
||||||
|
--brand: #9e9e9e;
|
||||||
|
--brand-foreground: #0a0a0a;
|
||||||
|
--brand-primary: #9e9e9e;
|
||||||
|
--brand-accent: #767676;
|
||||||
|
--brand-warm: #949494;
|
||||||
|
--brand-deep: #0a0a0a;
|
||||||
|
--brand-muted: #8a8a8a;
|
||||||
|
--ink: #e0e0e0;
|
||||||
|
--surface-warm: #121212;
|
||||||
|
--naturegreen: #767676;
|
||||||
|
--naturegreen-foreground: #040404;
|
||||||
|
--duck-dark: #e0e0e0;
|
||||||
|
--duck-teal: #767676;
|
||||||
|
--duck-yellow: #9e9e9e;
|
||||||
|
--duck-orange: #949494;
|
||||||
|
--duck-forest: #767676;
|
||||||
|
--duck-beige: #272727;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Astrodark — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark) — the source repo's own colors.toml says so directly.
|
||||||
|
|
||||||
|
Source: https://github.com/JamsMendez/omarchy-astrodark-theme (colors.toml (Omarchy schema)), read by scripts/port-community-themes.ts. Values
|
||||||
|
came straight from the repo's own colors.toml — the same schema and reader the official 22 themes
|
||||||
|
use, so nothing here is reconstructed. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='astrodark'] {
|
||||||
|
--background: #0b0c0f;
|
||||||
|
--foreground: #9b9fa9;
|
||||||
|
--card: #1a1d23;
|
||||||
|
--card-foreground: #9b9fa9;
|
||||||
|
--popover: #1a1d23;
|
||||||
|
--popover-foreground: #9b9fa9;
|
||||||
|
--muted: #26343f;
|
||||||
|
--muted-foreground: #b8bcc6;
|
||||||
|
--accent: #26343f;
|
||||||
|
--accent-foreground: #9b9fa9;
|
||||||
|
--primary: #5eb7ff;
|
||||||
|
--primary-foreground: #0b0c0f;
|
||||||
|
--secondary: #26343f;
|
||||||
|
--secondary-foreground: #9b9fa9;
|
||||||
|
--cta: #5eb7ff;
|
||||||
|
--cta-foreground: #0b0c0f;
|
||||||
|
--ctahover: #71c0ff;
|
||||||
|
--success: #87c05f;
|
||||||
|
--success-foreground: #0b0c0f;
|
||||||
|
--warning: #dfab25;
|
||||||
|
--warning-foreground: #0b0c0f;
|
||||||
|
--destructive: #ff838b;
|
||||||
|
--destructive-foreground: #0b0c0f;
|
||||||
|
--info: #5eb7ff;
|
||||||
|
--info-foreground: #0b0c0f;
|
||||||
|
--busy: #e8925a;
|
||||||
|
--busy-foreground: #0b0c0f;
|
||||||
|
--busy-ink: #e8925a;
|
||||||
|
--border: #464a51;
|
||||||
|
--input: #464a51;
|
||||||
|
--ring: #5eb7ff;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #5eb7ff;
|
||||||
|
--chart-secondary: #dd97f1;
|
||||||
|
--status-success: #87c05f;
|
||||||
|
--status-warning: #dfab25;
|
||||||
|
--status-in-progress: #5eb7ff;
|
||||||
|
--page-bg: #0b0c0f;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(26 29 35 / 0.72);
|
||||||
|
--panel-border: rgb(70 74 81 / 0.9);
|
||||||
|
--panel-border-bare: rgb(70 74 81 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(26 29 35 / 0.95);
|
||||||
|
--panel-raised-border: rgb(70 74 81 / 1);
|
||||||
|
--panel-accent: #5eb7ff;
|
||||||
|
--panel-header-bg: rgb(38 52 63 / 0.6);
|
||||||
|
--panel-header-fg: #9b9fa9;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1a1d23;
|
||||||
|
--glass-bg: rgb(26 29 35 / 0.55);
|
||||||
|
--glass-border: rgb(70 74 81 / 0.7);
|
||||||
|
--dock-bg: rgb(26 29 35 / 0.7);
|
||||||
|
--dock-border: rgb(70 74 81 / 0.8);
|
||||||
|
--dock-tooltip-bg: #26343f;
|
||||||
|
--header-bg: rgb(26 29 35 / 0.8);
|
||||||
|
--header-border: rgb(70 74 81 / 0.9);
|
||||||
|
--header-fg: #9b9fa9;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(107 111 121 / 0.07);
|
||||||
|
--card-grid-color: rgb(107 111 121 / 0.05);
|
||||||
|
--panel-grid-color: rgb(107 111 121 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(52 54 58 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(52 54 58 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(52 54 58 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(52 54 58 / 0.7);
|
||||||
|
--brand: #5eb7ff;
|
||||||
|
--brand-foreground: #0b0c0f;
|
||||||
|
--brand-primary: #5eb7ff;
|
||||||
|
--brand-accent: #4ac2b8;
|
||||||
|
--brand-warm: #e8925a;
|
||||||
|
--brand-deep: #0b0c0f;
|
||||||
|
--brand-muted: #34363a;
|
||||||
|
--ink: #9b9fa9;
|
||||||
|
--surface-warm: #1a1d23;
|
||||||
|
--naturegreen: #87c05f;
|
||||||
|
--naturegreen-foreground: #0b0c0f;
|
||||||
|
--duck-dark: #9b9fa9;
|
||||||
|
--duck-teal: #4ac2b8;
|
||||||
|
--duck-yellow: #dfab25;
|
||||||
|
--duck-orange: #e8925a;
|
||||||
|
--duck-forest: #87c05f;
|
||||||
|
--duck-beige: #272a30;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Atari — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/atif-1402/omarchy-atari-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='atari'] {
|
||||||
|
--background: #171317;
|
||||||
|
--foreground: #dcd6d6;
|
||||||
|
--card: #292329;
|
||||||
|
--card-foreground: #dcd6d6;
|
||||||
|
--popover: #292329;
|
||||||
|
--popover-foreground: #dcd6d6;
|
||||||
|
--muted: #383238;
|
||||||
|
--muted-foreground: #a6a0a2;
|
||||||
|
--accent: #383238;
|
||||||
|
--accent-foreground: #dcd6d6;
|
||||||
|
--primary: #dff1e8;
|
||||||
|
--primary-foreground: #171317;
|
||||||
|
--secondary: #383238;
|
||||||
|
--secondary-foreground: #dcd6d6;
|
||||||
|
--cta: #dff1e8;
|
||||||
|
--cta-foreground: #171317;
|
||||||
|
--ctahover: #e3f3eb;
|
||||||
|
--success: #79be9a;
|
||||||
|
--success-foreground: #171317;
|
||||||
|
--warning: #c3b283;
|
||||||
|
--warning-foreground: #171317;
|
||||||
|
--destructive: #b9746f;
|
||||||
|
--destructive-foreground: #171317;
|
||||||
|
--info: #9db9b2;
|
||||||
|
--info-foreground: #171317;
|
||||||
|
--busy: #be9379;
|
||||||
|
--busy-foreground: #171317;
|
||||||
|
--busy-ink: #be9379;
|
||||||
|
--border: #4c464b;
|
||||||
|
--input: #4c464b;
|
||||||
|
--ring: #dff1e8;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #dff1e8;
|
||||||
|
--chart-secondary: #cd9b98;
|
||||||
|
--status-success: #79be9a;
|
||||||
|
--status-warning: #c3b283;
|
||||||
|
--status-in-progress: #9db9b2;
|
||||||
|
--page-bg: #171317;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(41 35 41 / 0.72);
|
||||||
|
--panel-border: rgb(76 70 75 / 0.9);
|
||||||
|
--panel-border-bare: rgb(76 70 75 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(41 35 41 / 0.95);
|
||||||
|
--panel-raised-border: rgb(76 70 75 / 1);
|
||||||
|
--panel-accent: #dff1e8;
|
||||||
|
--panel-header-bg: rgb(56 50 56 / 0.6);
|
||||||
|
--panel-header-fg: #dcd6d6;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #292329;
|
||||||
|
--glass-bg: rgb(41 35 41 / 0.55);
|
||||||
|
--glass-border: rgb(76 70 75 / 0.7);
|
||||||
|
--dock-bg: rgb(41 35 41 / 0.7);
|
||||||
|
--dock-border: rgb(76 70 75 / 0.8);
|
||||||
|
--dock-tooltip-bg: #383238;
|
||||||
|
--header-bg: rgb(41 35 41 / 0.8);
|
||||||
|
--header-border: rgb(76 70 75 / 0.9);
|
||||||
|
--header-fg: #dcd6d6;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(122 116 119 / 0.07);
|
||||||
|
--card-grid-color: rgb(122 116 119 / 0.05);
|
||||||
|
--panel-grid-color: rgb(122 116 119 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(135 120 135 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(135 120 135 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(135 120 135 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(135 120 135 / 0.7);
|
||||||
|
--brand: #dff1e8;
|
||||||
|
--brand-foreground: #171317;
|
||||||
|
--brand-primary: #dff1e8;
|
||||||
|
--brand-accent: #a2d2b9;
|
||||||
|
--brand-warm: #be9379;
|
||||||
|
--brand-deep: #171317;
|
||||||
|
--brand-muted: #877887;
|
||||||
|
--ink: #dcd6d6;
|
||||||
|
--surface-warm: #292329;
|
||||||
|
--naturegreen: #79be9a;
|
||||||
|
--naturegreen-foreground: #171317;
|
||||||
|
--duck-dark: #dcd6d6;
|
||||||
|
--duck-teal: #a2d2b9;
|
||||||
|
--duck-yellow: #c3b283;
|
||||||
|
--duck-orange: #be9379;
|
||||||
|
--duck-forest: #79be9a;
|
||||||
|
--duck-beige: #3b353a;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Atelier — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/atif-1402/omarchy-atelier-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='atelier'] {
|
||||||
|
--background: #13130f;
|
||||||
|
--foreground: #dcd9d6;
|
||||||
|
--card: #22221b;
|
||||||
|
--card-foreground: #dcd9d6;
|
||||||
|
--popover: #22221b;
|
||||||
|
--popover-foreground: #dcd9d6;
|
||||||
|
--muted: #31312b;
|
||||||
|
--muted-foreground: #a4a29e;
|
||||||
|
--accent: #31312b;
|
||||||
|
--accent-foreground: #dcd9d6;
|
||||||
|
--primary: #7bbf7a;
|
||||||
|
--primary-foreground: #13130f;
|
||||||
|
--secondary: #31312b;
|
||||||
|
--secondary-foreground: #dcd9d6;
|
||||||
|
--cta: #7bbf7a;
|
||||||
|
--cta-foreground: #13130f;
|
||||||
|
--ctahover: #8bc78a;
|
||||||
|
--success: #7d9726;
|
||||||
|
--success-foreground: #13130f;
|
||||||
|
--warning: #a5980d;
|
||||||
|
--warning-foreground: #13130f;
|
||||||
|
--destructive: #ba6236;
|
||||||
|
--destructive-foreground: #0d0d0b;
|
||||||
|
--info: #36a166;
|
||||||
|
--info-foreground: #13130f;
|
||||||
|
--busy: #b07d22;
|
||||||
|
--busy-foreground: #13130f;
|
||||||
|
--busy-ink: #b07d22;
|
||||||
|
--border: #464640;
|
||||||
|
--input: #464640;
|
||||||
|
--ring: #7bbf7a;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #7bbf7a;
|
||||||
|
--chart-secondary: #9d6c7c;
|
||||||
|
--status-success: #7d9726;
|
||||||
|
--status-warning: #a5980d;
|
||||||
|
--status-in-progress: #36a166;
|
||||||
|
--page-bg: #13130f;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(34 34 27 / 0.72);
|
||||||
|
--panel-border: rgb(70 70 64 / 0.9);
|
||||||
|
--panel-border-bare: rgb(70 70 64 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(34 34 27 / 0.95);
|
||||||
|
--panel-raised-border: rgb(70 70 64 / 1);
|
||||||
|
--panel-accent: #7bbf7a;
|
||||||
|
--panel-header-bg: rgb(49 49 43 / 0.6);
|
||||||
|
--panel-header-fg: #dcd9d6;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #22221b;
|
||||||
|
--glass-bg: rgb(34 34 27 / 0.55);
|
||||||
|
--glass-border: rgb(70 70 64 / 0.7);
|
||||||
|
--dock-bg: rgb(34 34 27 / 0.7);
|
||||||
|
--dock-border: rgb(70 70 64 / 0.8);
|
||||||
|
--dock-tooltip-bg: #31312b;
|
||||||
|
--header-bg: rgb(34 34 27 / 0.8);
|
||||||
|
--header-border: rgb(70 70 64 / 0.9);
|
||||||
|
--header-fg: #dcd9d6;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(118 116 111 / 0.07);
|
||||||
|
--card-grid-color: rgb(118 116 111 / 0.05);
|
||||||
|
--panel-grid-color: rgb(118 116 111 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(108 107 90 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(108 107 90 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(108 107 90 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(108 107 90 / 0.7);
|
||||||
|
--brand: #7bbf7a;
|
||||||
|
--brand-foreground: #13130f;
|
||||||
|
--brand-primary: #7bbf7a;
|
||||||
|
--brand-accent: #5b9d48;
|
||||||
|
--brand-warm: #b07d22;
|
||||||
|
--brand-deep: #13130f;
|
||||||
|
--brand-muted: #6c6b5a;
|
||||||
|
--ink: #dcd9d6;
|
||||||
|
--surface-warm: #22221b;
|
||||||
|
--naturegreen: #7d9726;
|
||||||
|
--naturegreen-foreground: #13130f;
|
||||||
|
--duck-dark: #dcd9d6;
|
||||||
|
--duck-teal: #5b9d48;
|
||||||
|
--duck-yellow: #a5980d;
|
||||||
|
--duck-orange: #b07d22;
|
||||||
|
--duck-forest: #7d9726;
|
||||||
|
--duck-beige: #35342e;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Aura — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/bjarneo/omarchy-aura-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='aura'] {
|
||||||
|
--background: #16171e;
|
||||||
|
--foreground: #f8f8f2;
|
||||||
|
--card: #282a36;
|
||||||
|
--card-foreground: #f8f8f2;
|
||||||
|
--popover: #282a36;
|
||||||
|
--popover-foreground: #f8f8f2;
|
||||||
|
--muted: #373944;
|
||||||
|
--muted-foreground: #bababa;
|
||||||
|
--accent: #373944;
|
||||||
|
--accent-foreground: #f8f8f2;
|
||||||
|
--primary: #bde0fe;
|
||||||
|
--primary-foreground: #16171e;
|
||||||
|
--secondary: #373944;
|
||||||
|
--secondary-foreground: #f8f8f2;
|
||||||
|
--cta: #bde0fe;
|
||||||
|
--cta-foreground: #16171e;
|
||||||
|
--ctahover: #c5e4fe;
|
||||||
|
--success: #8be9fd;
|
||||||
|
--success-foreground: #16171e;
|
||||||
|
--warning: #cbc3e3;
|
||||||
|
--warning-foreground: #16171e;
|
||||||
|
--destructive: #ffafcc;
|
||||||
|
--destructive-foreground: #16171e;
|
||||||
|
--info: #8be9fd;
|
||||||
|
--info-foreground: #16171e;
|
||||||
|
--busy: #e5b9d8;
|
||||||
|
--busy-foreground: #16171e;
|
||||||
|
--busy-ink: #e5b9d8;
|
||||||
|
--border: #51525b;
|
||||||
|
--input: #51525b;
|
||||||
|
--ring: #bde0fe;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #bde0fe;
|
||||||
|
--chart-secondary: #ffc8dd;
|
||||||
|
--status-success: #8be9fd;
|
||||||
|
--status-warning: #cbc3e3;
|
||||||
|
--status-in-progress: #8be9fd;
|
||||||
|
--page-bg: #16171e;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(40 42 54 / 0.72);
|
||||||
|
--panel-border: rgb(81 82 91 / 0.9);
|
||||||
|
--panel-border-bare: rgb(81 82 91 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(40 42 54 / 0.95);
|
||||||
|
--panel-raised-border: rgb(81 82 91 / 1);
|
||||||
|
--panel-accent: #bde0fe;
|
||||||
|
--panel-header-bg: rgb(55 57 68 / 0.6);
|
||||||
|
--panel-header-fg: #f8f8f2;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #282a36;
|
||||||
|
--glass-bg: rgb(40 42 54 / 0.55);
|
||||||
|
--glass-border: rgb(81 82 91 / 0.7);
|
||||||
|
--dock-bg: rgb(40 42 54 / 0.7);
|
||||||
|
--dock-border: rgb(81 82 91 / 0.8);
|
||||||
|
--dock-tooltip-bg: #373944;
|
||||||
|
--header-bg: rgb(40 42 54 / 0.8);
|
||||||
|
--header-border: rgb(81 82 91 / 0.9);
|
||||||
|
--header-fg: #f8f8f2;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(134 135 139 / 0.07);
|
||||||
|
--card-grid-color: rgb(134 135 139 / 0.05);
|
||||||
|
--panel-grid-color: rgb(134 135 139 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(68 71 90 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(68 71 90 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(68 71 90 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(68 71 90 / 0.7);
|
||||||
|
--brand: #bde0fe;
|
||||||
|
--brand-foreground: #16171e;
|
||||||
|
--brand-primary: #bde0fe;
|
||||||
|
--brand-accent: #8be9fd;
|
||||||
|
--brand-warm: #e5b9d8;
|
||||||
|
--brand-deep: #16171e;
|
||||||
|
--brand-muted: #44475a;
|
||||||
|
--ink: #f8f8f2;
|
||||||
|
--surface-warm: #282a36;
|
||||||
|
--naturegreen: #8be9fd;
|
||||||
|
--naturegreen-foreground: #16171e;
|
||||||
|
--duck-dark: #f8f8f2;
|
||||||
|
--duck-teal: #8be9fd;
|
||||||
|
--duck-yellow: #cbc3e3;
|
||||||
|
--duck-orange: #e5b9d8;
|
||||||
|
--duck-forest: #8be9fd;
|
||||||
|
--duck-beige: #3d3f49;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Ayu Dark — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/fdidron/omarchy-ayu-dark-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='ayu-dark'] {
|
||||||
|
--background: #06080b;
|
||||||
|
--foreground: #bfbdb6;
|
||||||
|
--card: #0b0e14;
|
||||||
|
--card-foreground: #bfbdb6;
|
||||||
|
--popover: #0b0e14;
|
||||||
|
--popover-foreground: #bfbdb6;
|
||||||
|
--muted: #1c1f24;
|
||||||
|
--muted-foreground: #898985;
|
||||||
|
--accent: #1c1f24;
|
||||||
|
--accent-foreground: #bfbdb6;
|
||||||
|
--primary: #95e6cb;
|
||||||
|
--primary-foreground: #06080b;
|
||||||
|
--secondary: #1c1f24;
|
||||||
|
--secondary-foreground: #bfbdb6;
|
||||||
|
--cta: #95e6cb;
|
||||||
|
--cta-foreground: #06080b;
|
||||||
|
--ctahover: #a2e9d1;
|
||||||
|
--success: #7fd962;
|
||||||
|
--success-foreground: #06080b;
|
||||||
|
--warning: #f9af4f;
|
||||||
|
--warning-foreground: #06080b;
|
||||||
|
--destructive: #ea6c73;
|
||||||
|
--destructive-foreground: #06080b;
|
||||||
|
--info: #53bdfa;
|
||||||
|
--info-foreground: #06080b;
|
||||||
|
--busy: #f28e61;
|
||||||
|
--busy-foreground: #06080b;
|
||||||
|
--busy-ink: #f28e61;
|
||||||
|
--border: #2e3034;
|
||||||
|
--input: #2e3034;
|
||||||
|
--ring: #95e6cb;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #95e6cb;
|
||||||
|
--chart-secondary: #cda1fa;
|
||||||
|
--status-success: #7fd962;
|
||||||
|
--status-warning: #f9af4f;
|
||||||
|
--status-in-progress: #53bdfa;
|
||||||
|
--page-bg: #06080b;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(11 14 20 / 0.72);
|
||||||
|
--panel-border: rgb(46 48 52 / 0.9);
|
||||||
|
--panel-border-bare: rgb(46 48 52 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(11 14 20 / 0.95);
|
||||||
|
--panel-raised-border: rgb(46 48 52 / 1);
|
||||||
|
--panel-accent: #95e6cb;
|
||||||
|
--panel-header-bg: rgb(28 31 36 / 0.6);
|
||||||
|
--panel-header-fg: #bfbdb6;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #0b0e14;
|
||||||
|
--glass-bg: rgb(11 14 20 / 0.55);
|
||||||
|
--glass-border: rgb(46 48 52 / 0.7);
|
||||||
|
--dock-bg: rgb(11 14 20 / 0.7);
|
||||||
|
--dock-border: rgb(46 48 52 / 0.8);
|
||||||
|
--dock-tooltip-bg: #1c1f24;
|
||||||
|
--header-bg: rgb(11 14 20 / 0.8);
|
||||||
|
--header-border: rgb(46 48 52 / 0.9);
|
||||||
|
--header-fg: #bfbdb6;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(96 97 97 / 0.07);
|
||||||
|
--card-grid-color: rgb(96 97 97 / 0.05);
|
||||||
|
--panel-grid-color: rgb(96 97 97 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(104 104 104 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(104 104 104 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(104 104 104 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(104 104 104 / 0.7);
|
||||||
|
--brand: #95e6cb;
|
||||||
|
--brand-foreground: #06080b;
|
||||||
|
--brand-primary: #95e6cb;
|
||||||
|
--brand-accent: #90e1c6;
|
||||||
|
--brand-warm: #f28e61;
|
||||||
|
--brand-deep: #06080b;
|
||||||
|
--brand-muted: #686868;
|
||||||
|
--ink: #bfbdb6;
|
||||||
|
--surface-warm: #0b0e14;
|
||||||
|
--naturegreen: #7fd962;
|
||||||
|
--naturegreen-foreground: #06080b;
|
||||||
|
--duck-dark: #bfbdb6;
|
||||||
|
--duck-teal: #90e1c6;
|
||||||
|
--duck-yellow: #f9af4f;
|
||||||
|
--duck-orange: #f28e61;
|
||||||
|
--duck-forest: #7fd962;
|
||||||
|
--duck-beige: #1d2024;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Ayu Light — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Light theme (light), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/fdidron/omarchy-ayu-light-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='ayu-light'] {
|
||||||
|
--background: #f8f9fa;
|
||||||
|
--foreground: #5c6166;
|
||||||
|
--card: #eff0f1;
|
||||||
|
--card-foreground: #5c6166;
|
||||||
|
--popover: #eff0f1;
|
||||||
|
--popover-foreground: #5c6166;
|
||||||
|
--muted: #e9eaeb;
|
||||||
|
--muted-foreground: #65686a;
|
||||||
|
--accent: #e9eaeb;
|
||||||
|
--accent-foreground: #5c6166;
|
||||||
|
--primary: #3095db;
|
||||||
|
--primary-foreground: #272727;
|
||||||
|
--secondary: #e9eaeb;
|
||||||
|
--secondary-foreground: #5c6166;
|
||||||
|
--cta: #3095db;
|
||||||
|
--cta-foreground: #272727;
|
||||||
|
--ctahover: #2a83c1;
|
||||||
|
--success: #6cbf43;
|
||||||
|
--success-foreground: #3c3f42;
|
||||||
|
--warning: #eca944;
|
||||||
|
--warning-foreground: #43464a;
|
||||||
|
--destructive: #ea6c6d;
|
||||||
|
--destructive-foreground: #2d2d2d;
|
||||||
|
--info: #3199e1;
|
||||||
|
--info-foreground: #272727;
|
||||||
|
--busy: #eb8b59;
|
||||||
|
--busy-foreground: #373a3d;
|
||||||
|
--busy-ink: #a5613e;
|
||||||
|
--border: #dedfe1;
|
||||||
|
--input: #d3d4d6;
|
||||||
|
--ring: #3095db;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #3095db;
|
||||||
|
--chart-secondary: #9e75c7;
|
||||||
|
--status-success: #6cbf43;
|
||||||
|
--status-warning: #eca944;
|
||||||
|
--status-in-progress: #3199e1;
|
||||||
|
--page-bg: #f8f9fa;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(239 240 241 / 0.72);
|
||||||
|
--panel-border: rgb(222 223 225 / 0.9);
|
||||||
|
--panel-border-bare: rgb(222 223 225 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(239 240 241 / 0.95);
|
||||||
|
--panel-raised-border: rgb(222 223 225 / 1);
|
||||||
|
--panel-accent: #3095db;
|
||||||
|
--panel-header-bg: rgb(233 234 235 / 0.6);
|
||||||
|
--panel-header-fg: #5c6166;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #eff0f1;
|
||||||
|
--glass-bg: rgb(239 240 241 / 0.55);
|
||||||
|
--glass-border: rgb(222 223 225 / 0.7);
|
||||||
|
--dock-bg: rgb(239 240 241 / 0.7);
|
||||||
|
--dock-border: rgb(222 223 225 / 0.8);
|
||||||
|
--dock-tooltip-bg: #e9eaeb;
|
||||||
|
--header-bg: rgb(239 240 241 / 0.8);
|
||||||
|
--header-border: rgb(222 223 225 / 0.9);
|
||||||
|
--header-fg: #5c6166;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(134 136 137 / 0.07);
|
||||||
|
--card-grid-color: rgb(134 136 137 / 0.05);
|
||||||
|
--panel-grid-color: rgb(134 136 137 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(104 104 104 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(104 104 104 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(104 104 104 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(104 104 104 / 0.7);
|
||||||
|
--brand: #3095db;
|
||||||
|
--brand-foreground: #272727;
|
||||||
|
--brand-primary: #3095db;
|
||||||
|
--brand-accent: #46ba94;
|
||||||
|
--brand-warm: #eb8b59;
|
||||||
|
--brand-deep: #e9eaeb;
|
||||||
|
--brand-muted: #686868;
|
||||||
|
--ink: #5c6166;
|
||||||
|
--surface-warm: #eff0f1;
|
||||||
|
--naturegreen: #6cbf43;
|
||||||
|
--naturegreen-foreground: #3c3f42;
|
||||||
|
--duck-dark: #5c6166;
|
||||||
|
--duck-teal: #46ba94;
|
||||||
|
--duck-yellow: #eca944;
|
||||||
|
--duck-orange: #eb8b59;
|
||||||
|
--duck-forest: #6cbf43;
|
||||||
|
--duck-beige: #e0e2e3;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Ayu Mirage — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/fdidron/omarchy-ayu-mirage-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='ayu-mirage'] {
|
||||||
|
--background: #11141a;
|
||||||
|
--foreground: #cccac2;
|
||||||
|
--card: #1f2430;
|
||||||
|
--card-foreground: #cccac2;
|
||||||
|
--popover: #1f2430;
|
||||||
|
--popover-foreground: #cccac2;
|
||||||
|
--muted: #2f333e;
|
||||||
|
--muted-foreground: #9b9b99;
|
||||||
|
--accent: #2f333e;
|
||||||
|
--accent-foreground: #cccac2;
|
||||||
|
--primary: #95e6cb;
|
||||||
|
--primary-foreground: #11141a;
|
||||||
|
--secondary: #2f333e;
|
||||||
|
--secondary-foreground: #cccac2;
|
||||||
|
--cta: #95e6cb;
|
||||||
|
--cta-foreground: #11141a;
|
||||||
|
--ctahover: #a2e9d1;
|
||||||
|
--success: #87d96c;
|
||||||
|
--success-foreground: #11141a;
|
||||||
|
--warning: #facc6e;
|
||||||
|
--warning-foreground: #11141a;
|
||||||
|
--destructive: #ed8274;
|
||||||
|
--destructive-foreground: #11141a;
|
||||||
|
--info: #6dcbfa;
|
||||||
|
--info-foreground: #11141a;
|
||||||
|
--busy: #f4a771;
|
||||||
|
--busy-foreground: #11141a;
|
||||||
|
--busy-ink: #f4a771;
|
||||||
|
--border: #41444d;
|
||||||
|
--input: #41444d;
|
||||||
|
--ring: #95e6cb;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #95e6cb;
|
||||||
|
--chart-secondary: #dabafa;
|
||||||
|
--status-success: #87d96c;
|
||||||
|
--status-warning: #facc6e;
|
||||||
|
--status-in-progress: #6dcbfa;
|
||||||
|
--page-bg: #11141a;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(31 36 48 / 0.72);
|
||||||
|
--panel-border: rgb(65 68 77 / 0.9);
|
||||||
|
--panel-border-bare: rgb(65 68 77 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(31 36 48 / 0.95);
|
||||||
|
--panel-raised-border: rgb(65 68 77 / 1);
|
||||||
|
--panel-accent: #95e6cb;
|
||||||
|
--panel-header-bg: rgb(47 51 62 / 0.6);
|
||||||
|
--panel-header-fg: #cccac2;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1f2430;
|
||||||
|
--glass-bg: rgb(31 36 48 / 0.55);
|
||||||
|
--glass-border: rgb(65 68 77 / 0.7);
|
||||||
|
--dock-bg: rgb(31 36 48 / 0.7);
|
||||||
|
--dock-border: rgb(65 68 77 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2f333e;
|
||||||
|
--header-bg: rgb(31 36 48 / 0.8);
|
||||||
|
--header-border: rgb(65 68 77 / 0.9);
|
||||||
|
--header-fg: #cccac2;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(109 111 114 / 0.07);
|
||||||
|
--card-grid-color: rgb(109 111 114 / 0.05);
|
||||||
|
--panel-grid-color: rgb(109 111 114 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(104 104 104 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(104 104 104 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(104 104 104 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(104 104 104 / 0.7);
|
||||||
|
--brand: #95e6cb;
|
||||||
|
--brand-foreground: #11141a;
|
||||||
|
--brand-primary: #95e6cb;
|
||||||
|
--brand-accent: #90e1c6;
|
||||||
|
--brand-warm: #f4a771;
|
||||||
|
--brand-deep: #11141a;
|
||||||
|
--brand-muted: #686868;
|
||||||
|
--ink: #cccac2;
|
||||||
|
--surface-warm: #1f2430;
|
||||||
|
--naturegreen: #87d96c;
|
||||||
|
--naturegreen-foreground: #11141a;
|
||||||
|
--duck-dark: #cccac2;
|
||||||
|
--duck-teal: #90e1c6;
|
||||||
|
--duck-yellow: #facc6e;
|
||||||
|
--duck-orange: #f4a771;
|
||||||
|
--duck-forest: #87d96c;
|
||||||
|
--duck-beige: #30353f;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Azure Glow — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/Hydradevx/omarchy-azure-glow-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='azure-glow'] {
|
||||||
|
--background: #06080e;
|
||||||
|
--foreground: #a8dfff;
|
||||||
|
--card: #0a0f1a;
|
||||||
|
--card-foreground: #a8dfff;
|
||||||
|
--popover: #0a0f1a;
|
||||||
|
--popover-foreground: #a8dfff;
|
||||||
|
--muted: #1b202a;
|
||||||
|
--muted-foreground: #79a1ba;
|
||||||
|
--accent: #1b202a;
|
||||||
|
--accent-foreground: #a8dfff;
|
||||||
|
--primary: #99eeff;
|
||||||
|
--primary-foreground: #06080e;
|
||||||
|
--secondary: #1b202a;
|
||||||
|
--secondary-foreground: #a8dfff;
|
||||||
|
--cta: #99eeff;
|
||||||
|
--cta-foreground: #06080e;
|
||||||
|
--ctahover: #a5f0ff;
|
||||||
|
--success: #00e0b8;
|
||||||
|
--success-foreground: #06080e;
|
||||||
|
--warning: #33ccff;
|
||||||
|
--warning-foreground: #06080e;
|
||||||
|
--destructive: #0099cc;
|
||||||
|
--destructive-foreground: #06080e;
|
||||||
|
--info: #00aaff;
|
||||||
|
--info-foreground: #06080e;
|
||||||
|
--busy: #1ab3e6;
|
||||||
|
--busy-foreground: #06080e;
|
||||||
|
--busy-ink: #1ab3e6;
|
||||||
|
--border: #293847;
|
||||||
|
--input: #293847;
|
||||||
|
--ring: #99eeff;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #99eeff;
|
||||||
|
--chart-secondary: #3399ff;
|
||||||
|
--status-success: #00e0b8;
|
||||||
|
--status-warning: #33ccff;
|
||||||
|
--status-in-progress: #00aaff;
|
||||||
|
--page-bg: #06080e;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(10 15 26 / 0.72);
|
||||||
|
--panel-border: rgb(41 56 71 / 0.9);
|
||||||
|
--panel-border-bare: rgb(41 56 71 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(10 15 26 / 0.95);
|
||||||
|
--panel-raised-border: rgb(41 56 71 / 1);
|
||||||
|
--panel-accent: #99eeff;
|
||||||
|
--panel-header-bg: rgb(27 32 42 / 0.6);
|
||||||
|
--panel-header-fg: #a8dfff;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #0a0f1a;
|
||||||
|
--glass-bg: rgb(10 15 26 / 0.55);
|
||||||
|
--glass-border: rgb(41 56 71 / 0.7);
|
||||||
|
--dock-bg: rgb(10 15 26 / 0.7);
|
||||||
|
--dock-border: rgb(41 56 71 / 0.8);
|
||||||
|
--dock-tooltip-bg: #1b202a;
|
||||||
|
--header-bg: rgb(10 15 26 / 0.8);
|
||||||
|
--header-border: rgb(41 56 71 / 0.9);
|
||||||
|
--header-fg: #a8dfff;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(81 109 129 / 0.07);
|
||||||
|
--card-grid-color: rgb(81 109 129 / 0.05);
|
||||||
|
--panel-grid-color: rgb(81 109 129 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(18 50 71 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(18 50 71 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(18 50 71 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(18 50 71 / 0.7);
|
||||||
|
--brand: #99eeff;
|
||||||
|
--brand-foreground: #06080e;
|
||||||
|
--brand-primary: #99eeff;
|
||||||
|
--brand-accent: #66e0ff;
|
||||||
|
--brand-warm: #1ab3e6;
|
||||||
|
--brand-deep: #06080e;
|
||||||
|
--brand-muted: #123247;
|
||||||
|
--ink: #a8dfff;
|
||||||
|
--surface-warm: #0a0f1a;
|
||||||
|
--naturegreen: #00e0b8;
|
||||||
|
--naturegreen-foreground: #06080e;
|
||||||
|
--duck-dark: #a8dfff;
|
||||||
|
--duck-teal: #66e0ff;
|
||||||
|
--duck-yellow: #33ccff;
|
||||||
|
--duck-orange: #1ab3e6;
|
||||||
|
--duck-forest: #00e0b8;
|
||||||
|
--duck-beige: #1a2431;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Azure — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/tahfizhabib/omarchy-azure-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='azure'] {
|
||||||
|
--background: #0d141f;
|
||||||
|
--foreground: #eff3f8;
|
||||||
|
--card: #182538;
|
||||||
|
--card-foreground: #eff3f8;
|
||||||
|
--popover: #182538;
|
||||||
|
--popover-foreground: #eff3f8;
|
||||||
|
--muted: #283446;
|
||||||
|
--muted-foreground: #afb5be;
|
||||||
|
--accent: #283446;
|
||||||
|
--accent-foreground: #eff3f8;
|
||||||
|
--primary: #cad4e7;
|
||||||
|
--primary-foreground: #0d141f;
|
||||||
|
--secondary: #283446;
|
||||||
|
--secondary-foreground: #eff3f8;
|
||||||
|
--cta: #cad4e7;
|
||||||
|
--cta-foreground: #0d141f;
|
||||||
|
--ctahover: #d0d9ea;
|
||||||
|
--success: #61a7d6;
|
||||||
|
--success-foreground: #0d141f;
|
||||||
|
--warning: #6cabda;
|
||||||
|
--warning-foreground: #0d141f;
|
||||||
|
--destructive: #969293;
|
||||||
|
--destructive-foreground: #0d141f;
|
||||||
|
--info: #8da1c8;
|
||||||
|
--info-foreground: #0d141f;
|
||||||
|
--busy: #819fb7;
|
||||||
|
--busy-foreground: #0d141f;
|
||||||
|
--busy-ink: #819fb7;
|
||||||
|
--border: #424d5e;
|
||||||
|
--input: #424d5e;
|
||||||
|
--ring: #cad4e7;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #cad4e7;
|
||||||
|
--chart-secondary: #a0adc5;
|
||||||
|
--status-success: #61a7d6;
|
||||||
|
--status-warning: #6cabda;
|
||||||
|
--status-in-progress: #8da1c8;
|
||||||
|
--page-bg: #0d141f;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(24 37 56 / 0.72);
|
||||||
|
--panel-border: rgb(66 77 94 / 0.9);
|
||||||
|
--panel-border-bare: rgb(66 77 94 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(24 37 56 / 0.95);
|
||||||
|
--panel-raised-border: rgb(66 77 94 / 1);
|
||||||
|
--panel-accent: #cad4e7;
|
||||||
|
--panel-header-bg: rgb(40 52 70 / 0.6);
|
||||||
|
--panel-header-fg: #eff3f8;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #182538;
|
||||||
|
--glass-bg: rgb(24 37 56 / 0.55);
|
||||||
|
--glass-border: rgb(66 77 94 / 0.7);
|
||||||
|
--dock-bg: rgb(24 37 56 / 0.7);
|
||||||
|
--dock-border: rgb(66 77 94 / 0.8);
|
||||||
|
--dock-tooltip-bg: #283446;
|
||||||
|
--header-bg: rgb(24 37 56 / 0.8);
|
||||||
|
--header-border: rgb(66 77 94 / 0.9);
|
||||||
|
--header-fg: #eff3f8;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(121 130 142 / 0.07);
|
||||||
|
--card-grid-color: rgb(121 130 142 / 0.05);
|
||||||
|
--panel-grid-color: rgb(121 130 142 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(73 88 109 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(73 88 109 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(73 88 109 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(73 88 109 / 0.7);
|
||||||
|
--brand: #cad4e7;
|
||||||
|
--brand-foreground: #0d141f;
|
||||||
|
--brand-primary: #cad4e7;
|
||||||
|
--brand-accent: #5693c4;
|
||||||
|
--brand-warm: #819fb7;
|
||||||
|
--brand-deep: #0d141f;
|
||||||
|
--brand-muted: #49586d;
|
||||||
|
--ink: #eff3f8;
|
||||||
|
--surface-warm: #182538;
|
||||||
|
--naturegreen: #61a7d6;
|
||||||
|
--naturegreen-foreground: #0d141f;
|
||||||
|
--duck-dark: #eff3f8;
|
||||||
|
--duck-teal: #5693c4;
|
||||||
|
--duck-yellow: #6cabda;
|
||||||
|
--duck-orange: #819fb7;
|
||||||
|
--duck-forest: #61a7d6;
|
||||||
|
--duck-beige: #2e3a4b;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Bad Hand — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/bjornramberg/omarchy-bad-hand-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='bad-hand'] {
|
||||||
|
--background: #110b09;
|
||||||
|
--foreground: #bdb09d;
|
||||||
|
--card: #1e1411;
|
||||||
|
--card-foreground: #bdb09d;
|
||||||
|
--popover: #1e1411;
|
||||||
|
--popover-foreground: #bdb09d;
|
||||||
|
--muted: #2e2422;
|
||||||
|
--muted-foreground: #988e81;
|
||||||
|
--accent: #2e2422;
|
||||||
|
--accent-foreground: #bdb09d;
|
||||||
|
--primary: #f5efe1;
|
||||||
|
--primary-foreground: #110b09;
|
||||||
|
--secondary: #2e2422;
|
||||||
|
--secondary-foreground: #bdb09d;
|
||||||
|
--cta: #f5efe1;
|
||||||
|
--cta-foreground: #110b09;
|
||||||
|
--ctahover: #f6f1e5;
|
||||||
|
--success: #921416;
|
||||||
|
--success-foreground: #c2b6a4;
|
||||||
|
--warning: #856839;
|
||||||
|
--warning-foreground: #f3f3f3;
|
||||||
|
--destructive: #5a0a0a;
|
||||||
|
--destructive-foreground: #bdb09d;
|
||||||
|
--info: #4d3a2b;
|
||||||
|
--info-foreground: #bdb09d;
|
||||||
|
--busy: #703922;
|
||||||
|
--busy-foreground: #c2b6a4;
|
||||||
|
--busy-ink: #9b7464;
|
||||||
|
--border: #3d332c;
|
||||||
|
--input: #3d332c;
|
||||||
|
--ring: #f5efe1;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #f5efe1;
|
||||||
|
--chart-secondary: #c2b28c;
|
||||||
|
--status-success: #921416;
|
||||||
|
--status-warning: #856839;
|
||||||
|
--status-in-progress: #4d3a2b;
|
||||||
|
--page-bg: #110b09;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(30 20 17 / 0.72);
|
||||||
|
--panel-border: rgb(61 51 44 / 0.9);
|
||||||
|
--panel-border-bare: rgb(61 51 44 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(30 20 17 / 0.95);
|
||||||
|
--panel-raised-border: rgb(61 51 44 / 1);
|
||||||
|
--panel-accent: #f5efe1;
|
||||||
|
--panel-header-bg: rgb(46 36 34 / 0.6);
|
||||||
|
--panel-header-fg: #bdb09d;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1e1411;
|
||||||
|
--glass-bg: rgb(30 20 17 / 0.55);
|
||||||
|
--glass-border: rgb(61 51 44 / 0.7);
|
||||||
|
--dock-bg: rgb(30 20 17 / 0.7);
|
||||||
|
--dock-border: rgb(61 51 44 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2e2422;
|
||||||
|
--header-bg: rgb(30 20 17 / 0.8);
|
||||||
|
--header-border: rgb(61 51 44 / 0.9);
|
||||||
|
--header-fg: #bdb09d;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(110 98 89 / 0.07);
|
||||||
|
--card-grid-color: rgb(110 98 89 / 0.05);
|
||||||
|
--panel-grid-color: rgb(110 98 89 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(53 41 36 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(53 41 36 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(53 41 36 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(53 41 36 / 0.7);
|
||||||
|
--brand: #f5efe1;
|
||||||
|
--brand-foreground: #110b09;
|
||||||
|
--brand-primary: #f5efe1;
|
||||||
|
--brand-accent: #e4d8c1;
|
||||||
|
--brand-warm: #703922;
|
||||||
|
--brand-deep: #110b09;
|
||||||
|
--brand-muted: #352924;
|
||||||
|
--ink: #bdb09d;
|
||||||
|
--surface-warm: #1e1411;
|
||||||
|
--naturegreen: #921416;
|
||||||
|
--naturegreen-foreground: #c2b6a4;
|
||||||
|
--duck-dark: #bdb09d;
|
||||||
|
--duck-teal: #e4d8c1;
|
||||||
|
--duck-yellow: #856839;
|
||||||
|
--duck-orange: #703922;
|
||||||
|
--duck-forest: #921416;
|
||||||
|
--duck-beige: #2e241f;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Batman — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/OldJobobo/omarchy-batman-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='batman'] {
|
||||||
|
--background: #0f1011;
|
||||||
|
--foreground: #a7a8a3;
|
||||||
|
--card: #1b1d1e;
|
||||||
|
--card-foreground: #a7a8a3;
|
||||||
|
--popover: #1b1d1e;
|
||||||
|
--popover-foreground: #a7a8a3;
|
||||||
|
--muted: #2b2d2e;
|
||||||
|
--muted-foreground: #949592;
|
||||||
|
--accent: #2b2d2e;
|
||||||
|
--accent-foreground: #a7a8a3;
|
||||||
|
--primary: #a2a2a5;
|
||||||
|
--primary-foreground: #0f1011;
|
||||||
|
--secondary: #2b2d2e;
|
||||||
|
--secondary-foreground: #a7a8a3;
|
||||||
|
--cta: #a2a2a5;
|
||||||
|
--cta-foreground: #0f1011;
|
||||||
|
--ctahover: #adadb0;
|
||||||
|
--success: #c8be46;
|
||||||
|
--success-foreground: #0f1011;
|
||||||
|
--warning: #f3fd21;
|
||||||
|
--warning-foreground: #0f1011;
|
||||||
|
--destructive: #e6db43;
|
||||||
|
--destructive-foreground: #0f1011;
|
||||||
|
--info: #737074;
|
||||||
|
--info-foreground: #f9f9f9;
|
||||||
|
--busy: #edec32;
|
||||||
|
--busy-foreground: #0f1011;
|
||||||
|
--busy-ink: #edec32;
|
||||||
|
--border: #363838;
|
||||||
|
--input: #363838;
|
||||||
|
--ring: #a2a2a5;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #a2a2a5;
|
||||||
|
--chart-secondary: #737271;
|
||||||
|
--status-success: #c8be46;
|
||||||
|
--status-warning: #f3fd21;
|
||||||
|
--status-in-progress: #737074;
|
||||||
|
--page-bg: #0f1011;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(27 29 30 / 0.72);
|
||||||
|
--panel-border: rgb(54 56 56 / 0.9);
|
||||||
|
--panel-border-bare: rgb(54 56 56 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(27 29 30 / 0.95);
|
||||||
|
--panel-raised-border: rgb(54 56 56 / 1);
|
||||||
|
--panel-accent: #a2a2a5;
|
||||||
|
--panel-header-bg: rgb(43 45 46 / 0.6);
|
||||||
|
--panel-header-fg: #a7a8a3;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1b1d1e;
|
||||||
|
--glass-bg: rgb(27 29 30 / 0.55);
|
||||||
|
--glass-border: rgb(54 56 56 / 0.7);
|
||||||
|
--dock-bg: rgb(27 29 30 / 0.7);
|
||||||
|
--dock-border: rgb(54 56 56 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2b2d2e;
|
||||||
|
--header-bg: rgb(27 29 30 / 0.8);
|
||||||
|
--header-border: rgb(54 56 56 / 0.9);
|
||||||
|
--header-fg: #a7a8a3;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(102 104 102 / 0.07);
|
||||||
|
--card-grid-color: rgb(102 104 102 / 0.05);
|
||||||
|
--panel-grid-color: rgb(102 104 102 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(109 111 110 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(109 111 110 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(109 111 110 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(109 111 110 / 0.7);
|
||||||
|
--brand: #a2a2a5;
|
||||||
|
--brand-foreground: #0f1011;
|
||||||
|
--brand-primary: #a2a2a5;
|
||||||
|
--brand-accent: #615f5e;
|
||||||
|
--brand-warm: #edec32;
|
||||||
|
--brand-deep: #0f1011;
|
||||||
|
--brand-muted: #6d6f6e;
|
||||||
|
--ink: #a7a8a3;
|
||||||
|
--surface-warm: #1b1d1e;
|
||||||
|
--naturegreen: #c8be46;
|
||||||
|
--naturegreen-foreground: #0f1011;
|
||||||
|
--duck-dark: #a7a8a3;
|
||||||
|
--duck-teal: #615f5e;
|
||||||
|
--duck-yellow: #f3fd21;
|
||||||
|
--duck-orange: #edec32;
|
||||||
|
--duck-forest: #c8be46;
|
||||||
|
--duck-beige: #292b2b;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Batou — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/HANCORE-linux/omarchy-batou-theme (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='batou'] {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #a4a4a4;
|
||||||
|
--card: #121212;
|
||||||
|
--card-foreground: #a4a4a4;
|
||||||
|
--popover: #121212;
|
||||||
|
--popover-foreground: #a4a4a4;
|
||||||
|
--muted: #232323;
|
||||||
|
--muted-foreground: #8c8c8c;
|
||||||
|
--accent: #232323;
|
||||||
|
--accent-foreground: #a4a4a4;
|
||||||
|
--primary: #c2bbb0;
|
||||||
|
--primary-foreground: #0a0a0a;
|
||||||
|
--secondary: #232323;
|
||||||
|
--secondary-foreground: #a4a4a4;
|
||||||
|
--cta: #c2bbb0;
|
||||||
|
--cta-foreground: #0a0a0a;
|
||||||
|
--ctahover: #c9c3b9;
|
||||||
|
--success: #a5a297;
|
||||||
|
--success-foreground: #0a0a0a;
|
||||||
|
--warning: #c6c4b0;
|
||||||
|
--warning-foreground: #0a0a0a;
|
||||||
|
--destructive: #e19e74;
|
||||||
|
--destructive-foreground: #0a0a0a;
|
||||||
|
--info: #605d5b;
|
||||||
|
--info-foreground: #dadada;
|
||||||
|
--busy: #d4b192;
|
||||||
|
--busy-foreground: #0a0a0a;
|
||||||
|
--busy-ink: #d4b192;
|
||||||
|
--border: #2f2f2f;
|
||||||
|
--input: #2f2f2f;
|
||||||
|
--ring: #c2bbb0;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #c2bbb0;
|
||||||
|
--chart-secondary: #8a8575;
|
||||||
|
--status-success: #a5a297;
|
||||||
|
--status-warning: #c6c4b0;
|
||||||
|
--status-in-progress: #605d5b;
|
||||||
|
--page-bg: #0a0a0a;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(18 18 18 / 0.72);
|
||||||
|
--panel-border: rgb(47 47 47 / 0.9);
|
||||||
|
--panel-border-bare: rgb(47 47 47 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(18 18 18 / 0.95);
|
||||||
|
--panel-raised-border: rgb(47 47 47 / 1);
|
||||||
|
--panel-accent: #c2bbb0;
|
||||||
|
--panel-header-bg: rgb(35 35 35 / 0.6);
|
||||||
|
--panel-header-fg: #a4a4a4;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #121212;
|
||||||
|
--glass-bg: rgb(18 18 18 / 0.55);
|
||||||
|
--glass-border: rgb(47 47 47 / 0.7);
|
||||||
|
--dock-bg: rgb(18 18 18 / 0.7);
|
||||||
|
--dock-border: rgb(47 47 47 / 0.8);
|
||||||
|
--dock-tooltip-bg: #232323;
|
||||||
|
--header-bg: rgb(18 18 18 / 0.8);
|
||||||
|
--header-border: rgb(47 47 47 / 0.9);
|
||||||
|
--header-fg: #a4a4a4;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(97 97 97 / 0.07);
|
||||||
|
--card-grid-color: rgb(97 97 97 / 0.05);
|
||||||
|
--panel-grid-color: rgb(97 97 97 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(56 55 53 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(56 55 53 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(56 55 53 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(56 55 53 / 0.7);
|
||||||
|
--brand: #c2bbb0;
|
||||||
|
--brand-foreground: #0a0a0a;
|
||||||
|
--brand-primary: #c2bbb0;
|
||||||
|
--brand-accent: #c2bbb0;
|
||||||
|
--brand-warm: #d4b192;
|
||||||
|
--brand-deep: #0a0a0a;
|
||||||
|
--brand-muted: #383735;
|
||||||
|
--ink: #a4a4a4;
|
||||||
|
--surface-warm: #121212;
|
||||||
|
--naturegreen: #a5a297;
|
||||||
|
--naturegreen-foreground: #0a0a0a;
|
||||||
|
--duck-dark: #a4a4a4;
|
||||||
|
--duck-teal: #c2bbb0;
|
||||||
|
--duck-yellow: #c6c4b0;
|
||||||
|
--duck-orange: #d4b192;
|
||||||
|
--duck-forest: #a5a297;
|
||||||
|
--duck-beige: #212121;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Bauhaus — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/somerocketeer/omarchy-bauhaus-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='bauhaus'] {
|
||||||
|
--background: #090a0d;
|
||||||
|
--foreground: #eaeff5;
|
||||||
|
--card: #101318;
|
||||||
|
--card-foreground: #eaeff5;
|
||||||
|
--popover: #101318;
|
||||||
|
--popover-foreground: #eaeff5;
|
||||||
|
--muted: #212428;
|
||||||
|
--muted-foreground: #a9adb3;
|
||||||
|
--accent: #212428;
|
||||||
|
--accent-foreground: #eaeff5;
|
||||||
|
--primary: #809d9e;
|
||||||
|
--primary-foreground: #090a0d;
|
||||||
|
--secondary: #212428;
|
||||||
|
--secondary-foreground: #eaeff5;
|
||||||
|
--cta: #809d9e;
|
||||||
|
--cta-foreground: #090a0d;
|
||||||
|
--ctahover: #8fa9aa;
|
||||||
|
--success: #789fa2;
|
||||||
|
--success-foreground: #090a0d;
|
||||||
|
--warning: #e7a46f;
|
||||||
|
--warning-foreground: #090a0d;
|
||||||
|
--destructive: #cb886d;
|
||||||
|
--destructive-foreground: #090a0d;
|
||||||
|
--info: #8999aa;
|
||||||
|
--info-foreground: #090a0d;
|
||||||
|
--busy: #d9966e;
|
||||||
|
--busy-foreground: #090a0d;
|
||||||
|
--busy-ink: #d9966e;
|
||||||
|
--border: #3b3e43;
|
||||||
|
--input: #3b3e43;
|
||||||
|
--ring: #809d9e;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #809d9e;
|
||||||
|
--chart-secondary: #8d758f;
|
||||||
|
--status-success: #789fa2;
|
||||||
|
--status-warning: #e7a46f;
|
||||||
|
--status-in-progress: #8999aa;
|
||||||
|
--page-bg: #090a0d;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(16 19 24 / 0.72);
|
||||||
|
--panel-border: rgb(59 62 67 / 0.9);
|
||||||
|
--panel-border-bare: rgb(59 62 67 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(16 19 24 / 0.95);
|
||||||
|
--panel-raised-border: rgb(59 62 67 / 1);
|
||||||
|
--panel-accent: #809d9e;
|
||||||
|
--panel-header-bg: rgb(33 36 40 / 0.6);
|
||||||
|
--panel-header-fg: #eaeff5;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #101318;
|
||||||
|
--glass-bg: rgb(16 19 24 / 0.55);
|
||||||
|
--glass-border: rgb(59 62 67 / 0.7);
|
||||||
|
--dock-bg: rgb(16 19 24 / 0.7);
|
||||||
|
--dock-border: rgb(59 62 67 / 0.8);
|
||||||
|
--dock-tooltip-bg: #212428;
|
||||||
|
--header-bg: rgb(16 19 24 / 0.8);
|
||||||
|
--header-border: rgb(59 62 67 / 0.9);
|
||||||
|
--header-fg: #eaeff5;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(114 118 123 / 0.07);
|
||||||
|
--card-grid-color: rgb(114 118 123 / 0.05);
|
||||||
|
--panel-grid-color: rgb(114 118 123 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(58 63 76 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(58 63 76 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(58 63 76 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(58 63 76 / 0.7);
|
||||||
|
--brand: #809d9e;
|
||||||
|
--brand-foreground: #090a0d;
|
||||||
|
--brand-primary: #809d9e;
|
||||||
|
--brand-accent: #809d9e;
|
||||||
|
--brand-warm: #d9966e;
|
||||||
|
--brand-deep: #090a0d;
|
||||||
|
--brand-muted: #3a3f4c;
|
||||||
|
--ink: #eaeff5;
|
||||||
|
--surface-warm: #101318;
|
||||||
|
--naturegreen: #789fa2;
|
||||||
|
--naturegreen-foreground: #090a0d;
|
||||||
|
--duck-dark: #eaeff5;
|
||||||
|
--duck-teal: #809d9e;
|
||||||
|
--duck-yellow: #e7a46f;
|
||||||
|
--duck-orange: #d9966e;
|
||||||
|
--duck-forest: #789fa2;
|
||||||
|
--duck-beige: #26292e;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Beachvan — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/haripako/omarchy-BeachVan-theme (alacritty.toml), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='beachvan'] {
|
||||||
|
--background: #0f181d;
|
||||||
|
--foreground: #dee3eb;
|
||||||
|
--card: #1b2b34;
|
||||||
|
--card-foreground: #dee3eb;
|
||||||
|
--popover: #1b2b34;
|
||||||
|
--popover-foreground: #dee3eb;
|
||||||
|
--muted: #2b3a42;
|
||||||
|
--muted-foreground: #a4acb4;
|
||||||
|
--accent: #2b3a42;
|
||||||
|
--accent-foreground: #dee3eb;
|
||||||
|
--primary: #66b0af;
|
||||||
|
--primary-foreground: #0f181d;
|
||||||
|
--secondary: #2b3a42;
|
||||||
|
--secondary-foreground: #dee3eb;
|
||||||
|
--cta: #66b0af;
|
||||||
|
--cta-foreground: #0f181d;
|
||||||
|
--ctahover: #78b9b9;
|
||||||
|
--success: #9fc69a;
|
||||||
|
--success-foreground: #0f181d;
|
||||||
|
--warning: #f8c76b;
|
||||||
|
--warning-foreground: #0f181d;
|
||||||
|
--destructive: #ea6770;
|
||||||
|
--destructive-foreground: #0f181d;
|
||||||
|
--info: #6d9dca;
|
||||||
|
--info-foreground: #0f181d;
|
||||||
|
--busy: #f1976e;
|
||||||
|
--busy-foreground: #0f181d;
|
||||||
|
--busy-ink: #f1976e;
|
||||||
|
--border: #414f58;
|
||||||
|
--input: #414f58;
|
||||||
|
--ring: #66b0af;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #66b0af;
|
||||||
|
--chart-secondary: #c49ac4;
|
||||||
|
--status-success: #9fc69a;
|
||||||
|
--status-warning: #f8c76b;
|
||||||
|
--status-in-progress: #6d9dca;
|
||||||
|
--page-bg: #0f181d;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(27 43 52 / 0.72);
|
||||||
|
--panel-border: rgb(65 79 88 / 0.9);
|
||||||
|
--panel-border-bare: rgb(65 79 88 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(27 43 52 / 0.95);
|
||||||
|
--panel-raised-border: rgb(65 79 88 / 1);
|
||||||
|
--panel-accent: #66b0af;
|
||||||
|
--panel-header-bg: rgb(43 58 66 / 0.6);
|
||||||
|
--panel-header-fg: #dee3eb;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1b2b34;
|
||||||
|
--glass-bg: rgb(27 43 52 / 0.55);
|
||||||
|
--glass-border: rgb(65 79 88 / 0.7);
|
||||||
|
--dock-bg: rgb(27 43 52 / 0.7);
|
||||||
|
--dock-border: rgb(65 79 88 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2b3a42;
|
||||||
|
--header-bg: rgb(27 43 52 / 0.8);
|
||||||
|
--header-border: rgb(65 79 88 / 0.9);
|
||||||
|
--header-fg: #dee3eb;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(115 126 134 / 0.07);
|
||||||
|
--card-grid-color: rgb(115 126 134 / 0.05);
|
||||||
|
--panel-grid-color: rgb(115 126 134 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(106 116 124 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(106 116 124 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(106 116 124 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(106 116 124 / 0.7);
|
||||||
|
--brand: #66b0af;
|
||||||
|
--brand-foreground: #0f181d;
|
||||||
|
--brand-primary: #66b0af;
|
||||||
|
--brand-accent: #66b0af;
|
||||||
|
--brand-warm: #f1976e;
|
||||||
|
--brand-deep: #0f181d;
|
||||||
|
--brand-muted: #6a747c;
|
||||||
|
--ink: #dee3eb;
|
||||||
|
--surface-warm: #1b2b34;
|
||||||
|
--naturegreen: #9fc69a;
|
||||||
|
--naturegreen-foreground: #0f181d;
|
||||||
|
--duck-dark: #dee3eb;
|
||||||
|
--duck-teal: #66b0af;
|
||||||
|
--duck-yellow: #f8c76b;
|
||||||
|
--duck-orange: #f1976e;
|
||||||
|
--duck-forest: #9fc69a;
|
||||||
|
--duck-beige: #2f3d46;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Beta — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/jjdizz1l/beta (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='beta'] {
|
||||||
|
--background: #120212;
|
||||||
|
--foreground: #ffeca0;
|
||||||
|
--card: #210321;
|
||||||
|
--card-foreground: #ffeca0;
|
||||||
|
--popover: #210321;
|
||||||
|
--popover-foreground: #ffeca0;
|
||||||
|
--muted: #311531;
|
||||||
|
--muted-foreground: #bca67a;
|
||||||
|
--accent: #311531;
|
||||||
|
--accent-foreground: #ffeca0;
|
||||||
|
--primary: #dce9fe;
|
||||||
|
--primary-foreground: #120212;
|
||||||
|
--secondary: #311531;
|
||||||
|
--secondary-foreground: #ffeca0;
|
||||||
|
--cta: #dce9fe;
|
||||||
|
--cta-foreground: #120212;
|
||||||
|
--ctahover: #e0ecfe;
|
||||||
|
--success: #7ad573;
|
||||||
|
--success-foreground: #120212;
|
||||||
|
--warning: #f2c256;
|
||||||
|
--warning-foreground: #120212;
|
||||||
|
--destructive: #e71852;
|
||||||
|
--destructive-foreground: #0d010d;
|
||||||
|
--info: #5090f8;
|
||||||
|
--info-foreground: #120212;
|
||||||
|
--busy: #ed6d54;
|
||||||
|
--busy-foreground: #120212;
|
||||||
|
--busy-ink: #ed6d54;
|
||||||
|
--border: #4c313a;
|
||||||
|
--input: #4c313a;
|
||||||
|
--ring: #dce9fe;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #dce9fe;
|
||||||
|
--chart-secondary: #9d58f0;
|
||||||
|
--status-success: #7ad573;
|
||||||
|
--status-warning: #f2c256;
|
||||||
|
--status-in-progress: #5090f8;
|
||||||
|
--page-bg: #120212;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(33 3 33 / 0.72);
|
||||||
|
--panel-border: rgb(76 49 58 / 0.9);
|
||||||
|
--panel-border-bare: rgb(76 49 58 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(33 3 33 / 0.95);
|
||||||
|
--panel-raised-border: rgb(76 49 58 / 1);
|
||||||
|
--panel-accent: #dce9fe;
|
||||||
|
--panel-header-bg: rgb(49 21 49 / 0.6);
|
||||||
|
--panel-header-fg: #ffeca0;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #210321;
|
||||||
|
--glass-bg: rgb(33 3 33 / 0.55);
|
||||||
|
--glass-border: rgb(76 49 58 / 0.7);
|
||||||
|
--dock-bg: rgb(33 3 33 / 0.7);
|
||||||
|
--dock-border: rgb(76 49 58 / 0.8);
|
||||||
|
--dock-tooltip-bg: #311531;
|
||||||
|
--header-bg: rgb(33 3 33 / 0.8);
|
||||||
|
--header-border: rgb(76 49 58 / 0.9);
|
||||||
|
--header-fg: #ffeca0;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(133 108 90 / 0.07);
|
||||||
|
--card-grid-color: rgb(133 108 90 / 0.05);
|
||||||
|
--panel-grid-color: rgb(133 108 90 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(171 84 136 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(171 84 136 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(171 84 136 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(171 84 136 / 0.7);
|
||||||
|
--brand: #dce9fe;
|
||||||
|
--brand-foreground: #120212;
|
||||||
|
--brand-primary: #dce9fe;
|
||||||
|
--brand-accent: #53d0bc;
|
||||||
|
--brand-warm: #ed6d54;
|
||||||
|
--brand-deep: #120212;
|
||||||
|
--brand-muted: #ab5488;
|
||||||
|
--ink: #ffeca0;
|
||||||
|
--surface-warm: #210321;
|
||||||
|
--naturegreen: #7ad573;
|
||||||
|
--naturegreen-foreground: #120212;
|
||||||
|
--duck-dark: #ffeca0;
|
||||||
|
--duck-teal: #53d0bc;
|
||||||
|
--duck-yellow: #f2c256;
|
||||||
|
--duck-orange: #ed6d54;
|
||||||
|
--duck-forest: #7ad573;
|
||||||
|
--duck-beige: #371a2e;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Biscuit DE Mar Dark — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark) — the source repo's own colors.toml says so directly.
|
||||||
|
|
||||||
|
Source: https://github.com/OldJobobo/omarchy-biscuit-de-mar-dark-theme (colors.toml (Omarchy schema)), read by scripts/port-community-themes.ts. Values
|
||||||
|
came straight from the repo's own colors.toml — the same schema and reader the official 22 themes
|
||||||
|
use, so nothing here is reconstructed. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='biscuit-de-mar-dark'] {
|
||||||
|
--background: #1a1515;
|
||||||
|
--foreground: #dcc9bc;
|
||||||
|
--card: #1a1515;
|
||||||
|
--card-foreground: #dcc9bc;
|
||||||
|
--popover: #1a1515;
|
||||||
|
--popover-foreground: #dcc9bc;
|
||||||
|
--muted: #2d2424;
|
||||||
|
--muted-foreground: #ffe9c7;
|
||||||
|
--accent: #2d2424;
|
||||||
|
--accent-foreground: #dcc9bc;
|
||||||
|
--primary: #ae3f82;
|
||||||
|
--primary-foreground: #ffe9c7;
|
||||||
|
--secondary: #2d2424;
|
||||||
|
--secondary-foreground: #dcc9bc;
|
||||||
|
--cta: #ae3f82;
|
||||||
|
--cta-foreground: #ffe9c7;
|
||||||
|
--ctahover: #b85691;
|
||||||
|
--success: #768f80;
|
||||||
|
--success-foreground: #1a1515;
|
||||||
|
--warning: #959a6b;
|
||||||
|
--warning-foreground: #1a1515;
|
||||||
|
--destructive: #f07342;
|
||||||
|
--destructive-foreground: #1a1515;
|
||||||
|
--info: #614f76;
|
||||||
|
--info-foreground: #ffe9c7;
|
||||||
|
--busy: #e39c45;
|
||||||
|
--busy-foreground: #1a1515;
|
||||||
|
--busy-ink: #e39c45;
|
||||||
|
--border: #5a5047;
|
||||||
|
--input: #5a5047;
|
||||||
|
--ring: #ae3f82;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #ae3f82;
|
||||||
|
--chart-secondary: #7b3d79;
|
||||||
|
--status-success: #768f80;
|
||||||
|
--status-warning: #959a6b;
|
||||||
|
--status-in-progress: #614f76;
|
||||||
|
--page-bg: #1a1515;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(26 21 21 / 0.72);
|
||||||
|
--panel-border: rgb(90 80 71 / 0.9);
|
||||||
|
--panel-border-bare: rgb(90 80 71 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(26 21 21 / 0.95);
|
||||||
|
--panel-raised-border: rgb(90 80 71 / 1);
|
||||||
|
--panel-accent: #ae3f82;
|
||||||
|
--panel-header-bg: rgb(45 36 36 / 0.6);
|
||||||
|
--panel-header-fg: #dcc9bc;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #1a1515;
|
||||||
|
--glass-bg: rgb(26 21 21 / 0.55);
|
||||||
|
--glass-border: rgb(90 80 71 / 0.7);
|
||||||
|
--dock-bg: rgb(26 21 21 / 0.7);
|
||||||
|
--dock-border: rgb(90 80 71 / 0.8);
|
||||||
|
--dock-tooltip-bg: #2d2424;
|
||||||
|
--header-bg: rgb(26 21 21 / 0.8);
|
||||||
|
--header-border: rgb(90 80 71 / 0.9);
|
||||||
|
--header-fg: #dcc9bc;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(156 129 129 / 0.07);
|
||||||
|
--card-grid-color: rgb(156 129 129 / 0.05);
|
||||||
|
--panel-grid-color: rgb(156 129 129 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(114 90 90 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(114 90 90 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(114 90 90 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(114 90 90 / 0.7);
|
||||||
|
--brand: #ae3f82;
|
||||||
|
--brand-foreground: #ffe9c7;
|
||||||
|
--brand-primary: #ae3f82;
|
||||||
|
--brand-accent: #756d94;
|
||||||
|
--brand-warm: #e39c45;
|
||||||
|
--brand-deep: #1a1515;
|
||||||
|
--brand-muted: #725a5a;
|
||||||
|
--ink: #dcc9bc;
|
||||||
|
--surface-warm: #1a1515;
|
||||||
|
--naturegreen: #768f80;
|
||||||
|
--naturegreen-foreground: #1a1515;
|
||||||
|
--duck-dark: #dcc9bc;
|
||||||
|
--duck-teal: #756d94;
|
||||||
|
--duck-yellow: #959a6b;
|
||||||
|
--duck-orange: #e39c45;
|
||||||
|
--duck-forest: #768f80;
|
||||||
|
--duck-beige: #2d2726;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
Black Arch — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
||||||
|
|
||||||
|
Dark theme (dark), by luminance of its own background — the source repo carries no mode flag of its own to read.
|
||||||
|
|
||||||
|
Source: https://github.com/ankur311sudo/black_arch (colors.toml (16-colour ANSI)), read by scripts/port-community-themes.ts. Values
|
||||||
|
went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4
|
||||||
|
colors.toml — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are.
|
||||||
|
|
||||||
|
DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo.
|
||||||
|
*/
|
||||||
|
[data-theme='black-arch'] {
|
||||||
|
--background: #000000;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--card: #000000;
|
||||||
|
--card-foreground: #ffffff;
|
||||||
|
--popover: #000000;
|
||||||
|
--popover-foreground: #ffffff;
|
||||||
|
--muted: #121212;
|
||||||
|
--muted-foreground: #b3b3b3;
|
||||||
|
--accent: #121212;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--primary: #989898;
|
||||||
|
--primary-foreground: #000000;
|
||||||
|
--secondary: #121212;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--cta: #989898;
|
||||||
|
--cta-foreground: #000000;
|
||||||
|
--ctahover: #a4a4a4;
|
||||||
|
--success: #8e8e8e;
|
||||||
|
--success-foreground: #000000;
|
||||||
|
--warning: #b9b9b9;
|
||||||
|
--warning-foreground: #000000;
|
||||||
|
--destructive: #646464;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--info: #989898;
|
||||||
|
--info-foreground: #000000;
|
||||||
|
--busy: #8f8f8f;
|
||||||
|
--busy-foreground: #000000;
|
||||||
|
--busy-ink: #8f8f8f;
|
||||||
|
--border: #323232;
|
||||||
|
--input: #323232;
|
||||||
|
--ring: #989898;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--chart-primary: #989898;
|
||||||
|
--chart-secondary: #747474;
|
||||||
|
--status-success: #8e8e8e;
|
||||||
|
--status-warning: #b9b9b9;
|
||||||
|
--status-in-progress: #989898;
|
||||||
|
--page-bg: #000000;
|
||||||
|
--page-bg-src: '';
|
||||||
|
--panel-bg: rgb(0 0 0 / 0.72);
|
||||||
|
--panel-border: rgb(50 50 50 / 0.9);
|
||||||
|
--panel-border-bare: rgb(50 50 50 / 0.55);
|
||||||
|
--panel-raised-bg: rgb(0 0 0 / 0.95);
|
||||||
|
--panel-raised-border: rgb(50 50 50 / 1);
|
||||||
|
--panel-accent: #989898;
|
||||||
|
--panel-header-bg: rgb(18 18 18 / 0.6);
|
||||||
|
--panel-header-fg: #ffffff;
|
||||||
|
--panel-radius: 0.75rem;
|
||||||
|
--panel-blur: 12px;
|
||||||
|
--card-bg: #000000;
|
||||||
|
--glass-bg: rgb(0 0 0 / 0.55);
|
||||||
|
--glass-border: rgb(50 50 50 / 0.7);
|
||||||
|
--dock-bg: rgb(0 0 0 / 0.7);
|
||||||
|
--dock-border: rgb(50 50 50 / 0.8);
|
||||||
|
--dock-tooltip-bg: #121212;
|
||||||
|
--header-bg: rgb(0 0 0 / 0.8);
|
||||||
|
--header-border: rgb(50 50 50 / 0.9);
|
||||||
|
--header-fg: #ffffff;
|
||||||
|
--header-blur: 12px;
|
||||||
|
--pixel-grid-color: rgb(115 115 115 / 0.07);
|
||||||
|
--card-grid-color: rgb(115 115 115 / 0.05);
|
||||||
|
--panel-grid-color: rgb(115 115 115 / 0.06);
|
||||||
|
--scrollbar-thumb: rgb(38 38 38 / 0.6);
|
||||||
|
--scrollbar-thumb-hover: rgb(38 38 38 / 0.85);
|
||||||
|
--scrollbar-thin-thumb: rgb(38 38 38 / 0.45);
|
||||||
|
--scrollbar-thin-thumb-hover: rgb(38 38 38 / 0.7);
|
||||||
|
--brand: #989898;
|
||||||
|
--brand-foreground: #000000;
|
||||||
|
--brand-primary: #989898;
|
||||||
|
--brand-accent: #585858;
|
||||||
|
--brand-warm: #8f8f8f;
|
||||||
|
--brand-deep: #000000;
|
||||||
|
--brand-muted: #262626;
|
||||||
|
--ink: #ffffff;
|
||||||
|
--surface-warm: #000000;
|
||||||
|
--naturegreen: #8e8e8e;
|
||||||
|
--naturegreen-foreground: #000000;
|
||||||
|
--duck-dark: #ffffff;
|
||||||
|
--duck-teal: #585858;
|
||||||
|
--duck-yellow: #b9b9b9;
|
||||||
|
--duck-orange: #8f8f8f;
|
||||||
|
--duck-forest: #8e8e8e;
|
||||||
|
--duck-beige: #1a1a1a;
|
||||||
|
--logo-src: '/officer-logo.svg';
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user