Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 9b0376ec39 todo: close the spawning endpoint
Both modes verified against the live server, not reasoned about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:45 +00:00
pastilhasandClaude Opus 5 66234cd3e0 an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.

So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.

The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.

`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.

Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.

Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:02:29 +00:00
pastilhas ad8ed88519 profile settings keyed (425 -> 352)
ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.

Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.

ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
2026-08-23 04:57:48 +00:00
pastilhasandClaude Opus 5 7ce5af88aa todo: close the invisible spawned agent
The endpoint, table and ingest all existed and nothing called them, so the table
read empty and the feature looked unbuilt. Ingest on read means an empty table was
itself the symptom: nobody had looked.

The proposed second fix — the loop echoing the transcript id — turned out
unnecessary. The ids are already in the loop's spawned record, which the endpoint
ingests, so they reach the UI without the loop narrating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:55:11 +00:00
pastilhasandClaude Opus 5 6bfc1d38f7 a spawned agent is a link, not a rumour
The endpoint, the table and the ingest all existed and nothing had ever called
them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even
though its transcript was readable the whole time. It writes an ordinary transcript
under the project directory for its cwd, so `/chat/<id>` has always rendered it
live. What was missing was anybody being told the id existed: the job panel showed
its output as a text stream in a small box with no link, so finding the session
meant knowing to look.

Now the commander's own pane lists them, each a link to its transcript, with a dot
on the ones still running. Ingest happens on read, so the list is right without a
timer that can stop working quietly.

Cost is stated as a floor when any child did not report one — `$1.20+` and a count
of unknowns, never a confident total. A commander's line reading $0.28 for a night
that spent several dollars is the specific understatement this exists to stop.

Renders nothing when there are none. A commander that has not fired yet is the
normal case and does not need a box explaining that.

Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:54:54 +00:00
pastilhas c10dae3e6e scanner: text inside code, kbd, pre and samp is code, not prose
ApiKeys.tsx documents the header to send as <code>Authorization: Bearer …</code>.
That is an identifier crossing a boundary on the doc's never-translate list, and
translating it would break the thing it documents. Those four elements exist to mark
content as code, so the scanner now honours them.

Verified the delta across both remaining areas is exactly that one string.
2026-08-23 04:47:05 +00:00
pastilhasandClaude Opus 5 41fffbebe9 todo: close delivering a turn to a named session — answered from both ends
SendMessage does agent-to-agent with notify_when_idle instead of a poll, proven
tonight when one session took over the localisation hand-off from the owner. The
nudger does backend-to-session on a schedule, which is how a commander now reports
into its own conversation rather than a job panel.

Kept open deliberately: spawning a NEW session from a non-chat caller. The harness
Agent tool does not close that — it is available to a chat agent and nothing else,
which is why a commander's loop still shells out to claude -p.

Also kept: 'a spawned agent is invisible in the interface'. That is a defect in
Officer's own UI with a one-echo fix, and deleting it because an affordance now
describes coordination is exactly what docs/affordances.md warns about — an
affordance describing a defect calcifies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:47:03 +00:00
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
SettingsSection now carries titleKey/descriptionKey instead of title/description, and
the sidebar and content pane resolve them. The section arrays are module-level consts
holding JSX content, so they cannot call a hook — the data holds keys and the renderers
translate. Sidebar search matches the RESOLVED words, so it works in the language on
screen.

Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for
route segments, and a lowercase title_key would have fed it twenty bogus paths.
Verified 0 bogus, 26 declared, 20 offered, 0 missing.

Section titles now share one key with the Spotlight rows that name the same section,
per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's:
'Change Password' now reads 'Change password'.

Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…',
which is what the skeleton catalog already said and what distinguishes it from the
global Spotlight.
2026-08-23 04:46:07 +00:00
pastilhas 926034750f keyed the dock, header and shell chrome (555 -> 487)
Layout reaches zero. The dock, header, user menu, theme switcher, spotlight,
shortcuts modal, screen-error fallback, rescan and bug report.

Three things worth knowing:

CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook
resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread
the same two sources before, so each would have had to resolve labels itself.
Plugin tiles keep the name from their own manifest — a plugin's strings are not ours.

Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader
can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which
is what docs:check parses — verified still 20 offered, 0 missing.

The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is
now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales.

ThemeSwitcher used t as its theme loop variable; renamed to entry.
2026-08-23 04:40:39 +00:00
pastilhasandClaude Opus 5 6d8709c05a plan: roles as data, teams as scopes, and the owner as an identity
Design for tomorrow, written down so the work starts from a spec. Phase 0 is
already built; everything after it is plan.

The load-bearing decision is the smallest one. Ownership is answered today by
users.role === 'Super Admin', which was fine while roles were a hardcoded union
and nothing could write a new one — and becomes a privilege-escalation path the
moment roles are editable data, because then adding a role and becoming the owner
are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every
existing call site keeps meaning owner, and nothing downstream starts until it
lands.

Teams union rather than restrict, which is the only coherent resolution and has a
consequence worth stating: a team can only ever grant. Deny rules are named under
what must not be built, along with a role that confers ownership and team-scoped
execution capabilities.

And the thing a reader would otherwise discover at the end of phase 4: there is
still no way to create a second account, so this is a permission model for
accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged
because it may be worth doing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:40:27 +00:00
pastilhas 2765e241d3 TranslationKeys stops at a plural entry instead of walking into it
A phase 0 defect that could only surface once a real catalog used a plural. The type
walked into { one, other } and contributed 'foo.one'/'foo.other' to the key union, so
t('foo', { count }) — the whole point of D7 — did not compile, while t('foo.one') did.

Found by the type checker on the first genuine plural in phase 2 (the rescan toast).
Mirrors isPluralForms at runtime: every key a CLDR category, every value a string.
Pinned with type-level @ts-expect-error assertions, since nothing else would notice.
2026-08-23 04:38:26 +00:00
pastilhas 36e18f935d scanner: do not count commented-out code
Header.tsx has a disabled dev-console button inside a JSX comment and
IntegrationsSettings has a commented-out section literal. Four hits in
officer-web, all of them code nobody ships — and an area cannot reach zero while
a comment counts against it.

Only two shapes stripped, both unambiguous: block comments, and lines whose first
non-space characters are //. Stripping every // would eat the tail of any string
holding a URL. Verified the delta is exactly those four.
2026-08-23 04:32:06 +00:00
pastilhas 0ddcdcbdbf memoize t in the typed wrappers, and give app code typed access to ui.*
Each wrapper built a fresh closure every render, so `t` in a dependency array
recomputed forever. That is the documented useClient()/Jellyfin trap in
platform/CLAUDE.md, and phase 2 needs t in useMemo deps immediately.

useTranslation in officer-web now also returns tUi, typed against the shared
components' catalog. 'Cancel' and 'Close' recur across the shell and already exist
in ui.* — the alternative was importing two hooks in a hundred files.
2026-08-23 04:30:07 +00:00
pastilhas 99a78f3c7f note the provider requirement shared components now carry, under open question 3
Found while converting them in phase 1: they resolve against whatever provider is
above them and render dot-paths with none. Safe today only because the second React
root imports nothing from components/. Written where the plugin-contract decision
will be made rather than solved here.
2026-08-23 04:27:04 +00:00
pastilhasandClaude Opus 5 845699e697 deploy: arming for the first time has nothing to rehearse against
A commander read the general doctrine — run the action once before arming — and
proposed replaying the last release against it. Fair reading of what was written,
and wrong here: every tag already has a success record, so the claim refuses, and
the only way to manufacture a trigger is to tag a release, which it must never do.

So the deploy procedure now says it outright: arm, and state that the action has
never executed under you. The next real release is the first run.

This belongs in deploy documentation rather than commander doctrine. The general
rule is sound; what is specific to this job is that its triggers are all spent, and
the agent should not have to be told that by the owner after the fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:23:43 +00:00
pastilhas fe000541b7 hold shared components at zero hardcoded strings
The area reached zero convertible strings, so it goes in ZEROED and the scanner's
--check now fails if a future feature reintroduces one. Verified both ways: exit 1
naming the string and file when one is added, exit 0 when it is gone.

Enabled per area rather than globally, as the doc says — officer-web and
officerdev are still at 559 and 578, and would be red for weeks.
2026-08-23 04:19:41 +00:00
pastilhasandClaude Opus 5 dfbf9be61c the two messages are an obligation in the brief, not advice in the skill
A commander read the skill section on telling the owner and did not mention it in
its read-back, so the owner would have had to remind it — which is the failure, not
the remedy. Doctrine in the skill is advice a commander may or may not weigh;
the brief is the prompt and always arrives. Same lesson as moving the output
constraints there earlier tonight.

So the brief now states it as owed: two messages per event, one when the thing
happens and one when the work is done, and never having to be asked. The skill
still carries the how.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:18:32 +00:00
pastilhas be29ae7aa7 delete two unreferenced components from another codebase
MarkdownEditor and ui/start-confetti-button had no importers anywhere in the repo
and both carried a different product's content: MarkdownEditor hardcoded
https://app.pertento.ai/logo.png with alt="Pertento", and start-confetti-button
spoke about 'the experiment' and 'Draft/Created' statuses.

Translating either would have put feature words into ui.*, which D6 forbids —
a shared namespace may not name a feature.

todo/inputs-outside-forms named MarkdownEditor as deliberately-unwrapped, so that
entry is updated rather than left pointing at a deleted file.
2026-08-23 04:18:08 +00:00
pastilhas e3e790fd2b command block and colour picker keyed
ColorPicker's one string was placeholder="Font Weight" on an <input type="color">,
where a placeholder is both wrong text and inert — browsers do not render one on a
colour input. Keyed it as ui.colorPicker.placeholder and moved it to the hex text
field beside it, which is a real text input and had none. Translating it in place
would have created a key guaranteed never to be seen.

Both files still have no importers; CommandBlock stays because commands the user
might run need a copy button by convention.
2026-08-23 04:16:54 +00:00
pastilhas 2b0b671895 scanner: separate never-translate hits from convertible ones, and stop counting tailwind as prose
The --check guard compared an area's whole hit count against zero, including
'thrown errors' — which the doc's never-translate list forbids converting. An
area holding one legitimate new Error() could therefore never be added to
ZEROED, so the debt guard could never be switched on for it at all. Categories
now carry neverTranslate, are still reported, and are excluded from --check.

Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object
label. The filter tests per token rather than per string: the first attempt
tested the whole string and ate a real label, 'Application type:'.

--check now prints the offending strings and files instead of only a count.
2026-08-23 04:13:21 +00:00
pastilhas 2ec3f11cdd shadcn primitives and logos keyed: dialog, sheet, pagination, carousel, breadcrumb, theme toggles
These are the strings a user meets everywhere, mostly sr-only: the dialog and
sheet close buttons, pagination labels, carousel arrows, the breadcrumb ellipsis
and both colour-mode toggles. nav aria-label='pagination' became a real key too —
a screen reader announces it, so it is user-facing.

Logo alt text keeps the product name and translates only around it
('Logótipo Officer'). Arrow-button insets became logical (-start-12/-end-12).

The three 'useX must be used within <Y>' Errors are deliberately untouched:
never-translate, per the doc's list.
2026-08-23 04:10:39 +00:00
pastilhas 5ce1a2928e inputs and data views keyed: search, select, tabs, state blocks, data table
The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.

DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.

Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
2026-08-23 04:10:33 +00:00
pastilhas 339d31f05c ui.* catalog and the dialogs: error dialogs, confirm and paper dialogs keyed
Establishes the ui.* namespace for phases 2 and 3. Both catalogs complete, so the
key-parity test holds. Two English copy defects fixed while keying, because the
Portuguese must not inherit them: 'Your are not authorized' -> 'You are', and
'Please Signin again' -> 'Please sign in again'.

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
pastilhasandClaude Opus 5 c0788d15c0 release 2026.08.23.03
Roles become a table and localization phase 0 lands. Both groundwork; nothing in
the UI changes and nothing reads the new table yet.

Schema change, additive only — the roles table is new and no constraint is added
to an existing column, so it cannot fail on existing data. postdb:push seeds it.
No dependency change; the two lockfile lines are the local i18n workspace being
declared as a dependency of officerdev.

The plan names the check that matters: four rows in roles, per host, by query —
db:push can fail and exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:56:07 +00:00
pastilhasandClaude Opus 5 7704867e05 roles are a table, seeded, with nothing depending on it yet
Phase 0 of moving roles out of a TypeScript union. The four the code names become
rows, marked isSystem because each is referenced by name somewhere:
rolePermitsDevTools tests for 'Developer', bootstrap writes 'Super Admin',
users.role defaults to 'Member', and 'Admin' is what rolePermitsDevTools
deliberately excludes. Renaming or deleting one of those is a code change, so the
flag is there for a future delete path to refuse on rather than for the failure to
surface later as an account that cannot log in.

DELIBERATELY NO FOREIGN KEY on users.role. That change cannot go through db:push:
push creates the table empty and adds the constraint in the same run, so every
existing account violates it. Verified against a scratch database — push reported
the violation, created the table, skipped the constraint, and exited 0. The FK is
a separate release, once every host holds these rows.

So this phase is additive and cannot fail, which is what makes it safe to hand to
an unattended rollout across five hosts. Nothing reads the table yet; USER_ROLES
is still the type and authorize.ts is untouched.

The seed runs from postdb:push, so a host gets the rows on its next schema push
with no extra step in a deploy plan. It never overwrites: a description edited on
one server must not be reverted by the next deploy.

Verified here the way the deploy doc now requires — by query, not by exit code.
Four rows present, seed idempotent on a second run, and a second push is a no-op,
which is the proof that the schema and the database agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:52:38 +00:00
pastilhasandClaude Opus 5 04a4ab33dd deploy: db:push can fail and exit 0, so check the database instead
Verified against a scratch database on 2026-08-23. A push whose foreign key
violated existing rows printed the Postgres error, created the new table, left the
constraint uncreated, and returned zero. `bun db:push` wraps it, so a plan step
that reads `bun db:push` can report success over a half-applied schema.

The rollout already survived this twice only because the agent decided on its own
to verify per host by query rather than trusting the exit code. That instinct is
now the instruction.

Also documents the shape that cannot go through push at all — a new table plus a
foreign key onto it from an existing column, where the seed has to happen between
the two halves — and what direct SQL is allowed to look like when a plan calls for
it: the Drizzle schema stays the source of truth, the SQL comes from the plan
rather than from the agent, it is idempotent, push is run afterwards and expected
to be a no-op as proof the two agree, and every host is verified by query.

And one refusal: never `--force`. Its job is answering the destructive prompts
unattended, which on five hosts holding real data is not a convenience.

This is deploy documentation, not commander doctrine — a developer running
something similar should be able to reach the same conclusions, and the commander
architecture does not need to carry it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:49:48 +00:00
pastilhas b640ec486d i18n: default-language picker offers only shipped locales; widen stored bare codes on read 2026-08-23 03:43:35 +00:00
pastilhas 0bd8c6c16a i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir 2026-08-23 03:43:01 +00:00
pastilhas 6ac9187c52 i18n: scaffold panel.* and ui.* locale catalogs, wire panelCatalogs into officerdev's barrel 2026-08-23 03:42:53 +00:00
pastilhas dcd11a3b17 i18n: plural selection via Intl.PluralRules, dev-mode miss warning, useFormat, key-parity helper 2026-08-23 03:42:46 +00:00
pastilhasandClaude Opus 5 3dd37d053b localization: every locale ships a complete catalog, no regional overlays
D2 had pt-BR inherit from pt so a base carried the shared bulk and a regional file
carried the differences. Conventional, and rejected by the owner for a product
reason that outranks the engineering one.

The failure mode of an overlay is a missing European Portuguese key falling
through to Brazilian — and the two differ most in exactly the technical vocabulary
this UI is full of. The platform sells into European Portuguese first, so that
failure is a reader getting a neighbouring variant's words on whichever strings
nobody remembered to override, silently.

So every locale is a whole file, translated, or it does not ship. The drift
objection is answered by promoting the key-parity test from advisory to
load-bearing: a locale missing a key `en` has fails the build, so an incomplete
catalog cannot ship at all. Completeness is enforced rather than papered over.

It is also less code — resolveKey looks in one catalog and then English, with no
chain to walk and no merge order — and it removes the question of which file a
given string lives in.

Consequence for the work: a new locale is a full machine-translated pass reviewed
on its own, not a diff against a base. pt-PT and pt-BR get one each, separately,
because the whole point is that neither borrows from the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 03:21:21 +00:00
pastilhas 64894ad96e deploy report 2026.08.23.02 2026-08-23 02:59:30 +00:00
108 changed files with 3251 additions and 1110 deletions
+4 -1
View File
@@ -207,7 +207,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). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days. never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 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`.
@@ -477,6 +477,9 @@ explaining why it was safe.
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level, owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the - `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
+2
View File
@@ -210,6 +210,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"hooks": "*", "hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
@@ -271,6 +272,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"hooks": "workspace:*", "hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*", "state": "workspace:*",
"widgets": "workspace:*", "widgets": "workspace:*",
}, },
+69
View File
@@ -0,0 +1,69 @@
# 2026.08.23.02 — deploy report
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
## Per host
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
| `officer-dev` | — | — | — | — | — | SKIPPED |
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
`sendoff_senders` are new tables, so the diff was additive.
## What restarted, beyond what the plan asked for
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
not a fault, and it is the larger part of what moved:
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
- `fofos` — one (`officer-gitea`).
- `production-pertento-bridge` — one (`officer-gitea`).
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
- `macbookpro` — none; no plugins are installed there.
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
on any host was `errored`.
## Hosts the plan flagged
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
steps, with `pm2` run separately through `$SHELL -ic`.
## Things worth knowing, none of them failures
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
it, and it was left alone.
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
## What was not done
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
+64
View File
@@ -0,0 +1,64 @@
# 2026.08.23.03
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
changes and nothing reads the new table yet.
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
existing data.
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
`bun install`.**
## What is in it
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
`db:push` and is a later release.
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
step here. It never overwrites an existing row.
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
(`en` → `en-GB`), with no database migration.
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
create.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
exists and holds four rows on that host. `db:push` can fail and still exit 0.
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.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## The check that matters this release
```sql
select count(*) from roles; -- expect 4
```
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
per host in the report.
No direct SQL is needed and none is authorised for this release.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
## `officer-dev` — skip it
The rollout runs from here. It already holds the release and the four rows, verified locally.
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.23.03 — deploy report
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
attempted, nothing spent on it).
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
authorised and none was needed.
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
## Per host
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
`online`** — not just `officer`, every entry.
## Notes, and what was not done
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
restarting `officer` on it is the owner's deliberate last step.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
never have collided with it, so it is not a reason to skip a host.
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
before `officer` itself was touched.
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
- **Nothing was retried and nothing was repaired.** No step failed on any host.
+70 -36
View File
@@ -28,13 +28,13 @@ the server does instead, which is the one decision that reaches outside this bou
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
also about **5% deployed**, and that gap is the whole job. also about **5% deployed**, and that gap is the whole job.
| | | | | |
| --- | --- | | ----------------------------------------- | -------------------------------------------------------------------------- |
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` | | `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` | | `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider | | `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** | | `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` | | components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation, What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
@@ -45,7 +45,7 @@ typecheck failure, which is worth more than it sounds when one agent is editing
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the 1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
runtime and unused. runtime and unused.
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use 2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates. the _browser's_ locale, not the user's chosen one, so a Portuguese UI shows American dates.
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit 3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
from `pt`. from `pt`.
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in 4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
@@ -59,12 +59,12 @@ typecheck failure, which is worth more than it sounds when one agent is editing
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`: Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences | | area | files | occurrences |
| --- | --- | --- | | --------------------- | ------- | ----------- |
| `officer-web` (shell) | 132 | 556 | | `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 | | `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 | | shared `components` | 91 | 47 |
| **total** | **436** | **1170** | | **total** | **436** | **1170** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today. **884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
@@ -101,29 +101,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
the prose was careful not to be. When in doubt, one code for one *response*, not one per branch. the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
### D2. Regional locales, BCP 47, with fallback chains ### D2. Regional locales, BCP 47, and every catalog is complete
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, `pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
and it is the owner's own language, so getting it wrong is felt immediately. it is the owner's own language, so getting it wrong is felt immediately.
Resolution walks a chain and stops at the first hit: **Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
every key the UI has, translated for that locale, or it does not ship.
``` This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
pt-BR → pt → en carried the shared bulk and a regional file carried only the differences. That is the conventional design
en-GB → en and it was rejected for a product reason that outranks the engineering one:
```
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.** **A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
differently. Two near-identical full catalogs would drift within a month. being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
variant's words.
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
by the test rather than by a fallback quietly papering over a gap.
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
base-versus-overlay merge order, and no question about which file a given string lives in.
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work. `Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to **Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
widening on read over a database migration — a stale bare code should resolve, not fail. over a database migration — a stale bare code should resolve, not fail.
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
the other.
### D3. RTL — prepare, do not ship ### D3. RTL — prepare, do not ship
@@ -148,7 +165,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150 `Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet". lines of glue, listed in "What it cannot do yet".
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms. Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition. ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text ### D5. Keys, not English source text
@@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
- `namespace` — `app` / `panel` / `ui`, per D6. - `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory. - `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased. - `component` — the file, lowercased.
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`. - `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable: Rules that keep 1200 keys navigable:
@@ -245,7 +262,7 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content. - **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies. - **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes* - **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process (the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values. names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is - **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
@@ -304,9 +321,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0.
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
meter: the count goes down as work lands. meter: the count goes down as work lands.
**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the **A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
one guard that stops a locale silently degrading into English. `bun docs:check` fails on a stale number.
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner **A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
@@ -339,7 +360,20 @@ else will ever notice.
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered. obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin 3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above. manifest. Their strings are not counted above.
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
**One constraint that contract has to answer, found while converting the shared components
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
provider-absent fallback inside the `components` package was considered and rejected: it would have
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them. the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared 5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose. between accounts needs a locale decision. D1 mostly removes this by removing server prose.
+210
View File
@@ -0,0 +1,210 @@
# Roles and teams
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
design, written down so the work starts from a spec rather than from a conversation.
## What this is
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
a homelab and it cannot express a company, where the same person is an administrator of one area and an
ordinary member of another.
Two things are wanted, and they are separable:
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
provisioning.
- **Teams**, so a user belongs to several and holds a different role in each.
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
uses both. Nothing below may make teams mandatory to reach a feature.
---
## What already landed — phase 0, 2026-08-23
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
Verified against a scratch database: push reported the violation, created the table, skipped the
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
---
## Decisions
### D1. The owner is an identity. It is never a role.
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
whatever UI manages roles.
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
else can wait behind it.
### D2. Several Super Admins are allowed, and the role provisions sudo
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
a company has an owner and several administrators, and a homelab owner may add one or never do.
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
they left behind.
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
the UI to say what it is rather than presenting it as one more row in a dropdown.
### D3. `Admin` is app-wide authority with no operating-system privilege
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
"administering the platform is not developing on it". This decision only writes that down.
### D4. A role may be deleted, but not while anyone holds it
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
nobody asked for, and "absence denies" is the posture everywhere else in this system.
`isSystem` roles refuse both delete and rename, because the code names them by name:
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
`Member`. Renaming one is a code change that has not happened yet.
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
rename cascades to every user and every grant, atomically, with no code change.
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
### D6. A team is a scope. Permissions UNION across a user's teams.
Admin in marketing and member in developers means marketing's admin level does not reach developers'
files — but a user's effective permission for a given capability is the **highest level any of their teams
grants**, plus their global role.
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
should be refused rather than half-built.
### D7. The global role remains, and answers a different question
Two questions, two mechanisms:
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
- **"What may this account do in this area?"** — team membership with a role in that team.
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
has no natural team to ask it about. A homelab uses only the first and never makes a team.
### D8. Per-role provisioning is a script the role carries
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
in code that should become a row.
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
rootless specifically. `usermod -aG docker` is root by a longer route —
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
---
## Phases
### Phase 0 — roles are a table ✅ DONE 2026-08-23
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
The load-bearing phase, and small.
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
"owner or Super Admin" is a separate, reviewable change per route.
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
Reviewable on its own, and nothing downstream can start until ownership is an identity.
### Phase 2 — the foreign key, and role CRUD
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
replace what the compiler was doing.
### Phase 3 — per-role provisioning
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
D8's warning in front of whoever writes the first script.
### Phase 4 — teams
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
one table with a nullable `team_id`.
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
4. Teams UI, and the account screen showing which teams a user is in and as what.
### Phase 5 — the thing that actually unblocks all of it
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
for accounts that cannot yet exist.
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
find nothing to test it with. **It may be worth doing first.**
---
## What must not be built
- **Deny rules.** See D6. Teams grant; they never restrict.
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
the answer is a `confined` grant with a Linux user, which is per-account by nature.
## Verification
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
binary directly.
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
## Open questions
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
or a self-service one.
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
holds globally; the same question exists per team and is not answered here.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "officer", "name": "officer",
"version": "2026.08.23.02", "version": "2026.08.23.03",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
@@ -30,7 +30,7 @@
"build:dashboard": "bun run ./scripts/build/dashboard.ts", "build:dashboard": "bun run ./scripts/build/dashboard.ts",
"db:gen": "cd src/databases/officer_db && bun run generate", "db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push", "db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts", "postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
"db:migrate": "cd src/databases/officer_db && bun run migrate", "db:migrate": "cd src/databases/officer_db && bun run migrate",
"dev:emailer": "cd src/workspaces/emailer && bun run dev", "dev:emailer": "cd src/workspaces/emailer && bun run dev",
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write", "format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
+66
View File
@@ -9,6 +9,27 @@ one.
--- ---
## Arming for the first time — there is nothing to rehearse against
General commander doctrine says to run the action once before arming, so it is known to work unattended.
**That does not apply here, and reading it as "replay the last release" is wrong.**
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
release, which you must never do.
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
fights the claim or invents an occasion.
Two things follow from it, and they are the reason this is fine rather than a gap:
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
What is unproven is this loop, not the rollout.
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
## What wakes you ## What wakes you
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
@@ -192,6 +213,51 @@ assemble at the end from memory is a report that will be wrong about the host th
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
one that stopped in the middle and said nothing about the four hosts it never reached. one that stopped in the middle and said nothing about the four hosts it never reached.
### Schema changes, and why `db:push` cannot be trusted
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
success while the schema is half-applied.
So on any release whose plan says the schema changed:
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
a guess.
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
table empty and adds the constraint in the same run, so every existing row violates it. There is no
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
This is allowed and it is not a licence to improvise. The rules, in order:
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
that is a code change and it is not yours to make on five production hosts at 3am.
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
the fleet has diverged in a way nothing will detect.
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
forensic exercise.
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
finding for the report rather than something to push past.
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
the specific check, and its answer, for each.
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
repair you invented.
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
### Skip the host you are running on ### Skip the host you are running on
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**. Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
+80 -10
View File
@@ -26,29 +26,48 @@ const AREAS = [
/** /**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches * Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored. * zero — enabling every area on day one would just be red for weeks and get ignored.
*
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
* that must NEVER be translated; they are reported but excluded from the check, which is why the
* neverTranslate split below has to exist for this entry to be possible at all.
*/ */
const ZEROED: string[] = []; const ZEROED: string[] = ['shared components'];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/; const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset. // A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`; const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
const PATTERNS: { kind: string; rx: RegExp }[] = [ /**
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
*
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
*/
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words< // >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g }, { kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends // placeholder="..." and friends
{ {
kind: 'attributes', kind: 'attributes',
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'), rx: new RegExp(
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
'g',
),
}, },
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') }, { kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category. // label: 'Status' — nav items, menus, table columns. The largest hidden category.
{ {
kind: 'object labels', kind: 'object labels',
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'), rx: new RegExp(
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
'g',
),
}, },
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') }, // Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert. // `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g }, { kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
]; ];
@@ -62,9 +81,42 @@ function isHuman(value: string | undefined): value is string {
if (/^[a-z0-9\-_./:]+$/.test(s)) return false; if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false; if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false; if (/^[A-Z0-9_]+$/.test(s)) return false;
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads.
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
const tokens = s.split(/\s+/);
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
return true; return true;
} }
/**
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
*
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
*/
const stripComments = (source: string): string =>
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
/**
* `<code>`, `<kbd>`, `<pre>` and `<samp>` mark content as code rather than prose — that is what the
* elements are for. `<code>Authorization: Bearer …</code>` is an HTTP header, which the doc's
* never-translate list covers as an identifier crossing a boundary, and translating it would break the
* thing it documents.
*/
const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
function* walk(dir: string): Generator<string> { function* walk(dir: string): Generator<string> {
let entries: string[]; let entries: string[];
try { try {
@@ -96,7 +148,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++; files++;
let source: string; let source: string;
try { try {
source = readFileSync(file, 'utf8'); source = stripCodeElements(stripComments(readFileSync(file, 'utf8')));
} catch { } catch {
continue; continue;
} }
@@ -118,7 +170,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind); const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n); const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`); console.log(
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
);
for (const r of results) { for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join(''); const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`); console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
@@ -128,9 +182,19 @@ const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value)); const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value)); const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`); const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
console.log(
`${'TOTAL'.padEnd(22)} ${pad(
results.reduce((n, r) => n + r.files, 0),
5,
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
);
console.log(); console.log();
console.log(`occurrences ${all.length}`); console.log(`occurrences ${all.length}`);
console.log(` convertible ${convertible(all).length}`);
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
console.log(`distinct strings ${distinct.size}`); console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`); console.log(` needing params ${interpolated.size}`);
@@ -149,10 +213,16 @@ if (listPrefix) {
} }
if (args.has('--check')) { if (args.has('--check')) {
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0); const regressed = results
.filter((r) => ZEROED.includes(r.label))
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
.filter((r) => r.hits.length > 0);
if (regressed.length > 0) { if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:'); console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`); for (const r of regressed) {
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
}
console.error('\nUse a catalog key. See docs/localization.md.'); console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1); process.exit(1);
} }
+53
View File
@@ -0,0 +1,53 @@
/**
* Seed the `roles` table with the roles the platform's own code names.
*
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
* mechanism `backfill-session-models.ts` relies on.
*
* ── Why these four are `isSystem` ──
*
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
*
* ── What this deliberately does NOT do ──
*
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
* and adds the constraint in the same run, so every existing account violates it — verified against a
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
*
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
* unattended.
*
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
*/
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
const SEED: { name: string; description: string }[] = [
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
];
async function main(): Promise<void> {
await waitForDatabase();
let created = 0;
for (const role of SEED) {
if (await seedRole({ ...role, isSystem: true })) created += 1;
}
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
}
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
// a successful push look like a failed one — the rows are additive and the next push tries again.
main()
.catch((err: unknown) => {
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
})
.finally(() => process.exit(0));
@@ -1,16 +1,18 @@
import { Bug } from 'lucide-react'; import { Bug } from 'lucide-react';
import { useBugReport } from './use-bug-report'; import { useBugReport } from './use-bug-report';
import { useTranslation } from '@/lib/i18n';
import { BugReportDialog } from './BugReportDialog'; import { BugReportDialog } from './BugReportDialog';
export const BugReportButton = () => { export const BugReportButton = () => {
const bugReport = useBugReport(); const bugReport = useBugReport();
const { t } = useTranslation();
return ( return (
<> <>
<button <button
onClick={bugReport.handleOpen} onClick={bugReport.handleOpen}
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110" className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
aria-label="Report a bug" aria-label={t('bugReport.label')}
> >
<Bug size={16} /> <Bug size={16} />
</button> </button>
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslation } from '@/lib/i18n';
type BugReportDialogProps = { type BugReportDialogProps = {
open: boolean; open: boolean;
@@ -21,6 +22,7 @@ export const BugReportDialog = ({
onClose, onClose,
onSubmit, onSubmit,
}: BugReportDialogProps) => { }: BugReportDialogProps) => {
const { t, tUi } = useTranslation();
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]); const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
@@ -36,7 +38,7 @@ export const BugReportDialog = ({
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Report a Bug</DialogTitle> <DialogTitle>{t('bugReport.title')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
@@ -46,34 +48,32 @@ export const BugReportDialog = ({
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : previewUrl ? ( ) : previewUrl ? (
<img src={previewUrl} alt="Screenshot" className="max-h-48 w-full object-contain" /> <img src={previewUrl} alt={t('bugReport.screenshot')} className="max-h-48 w-full object-contain" />
) : ( ) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground"> <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
No screenshot captured {t('bugReport.noScreenshot')}
</div> </div>
)} )}
</div> </div>
<Textarea <Textarea
placeholder="Describe what went wrong..." placeholder={t('bugReport.describePlaceholder')}
value={description} value={description}
onChange={(ev) => setDescription(ev.target.value)} onChange={(ev) => setDescription(ev.target.value)}
rows={4} rows={4}
autoFocus autoFocus
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('bugReport.collectedNote')}</p>
Console logs, browser info, and recent API errors are collected automatically.
</p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}> <Button variant="outline" onClick={onClose} disabled={submitting}>
Cancel {tUi('button.cancel')}
</Button> </Button>
<Button onClick={handleSubmit} disabled={!canSubmit}> <Button onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} {submitting ? <Loader2 className="me-2 h-4 w-4 animate-spin" /> : null}
Submit {t('bugReport.submit')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -6,6 +6,7 @@ import { useGlobal } from 'hooks/useGlobal';
import type { ErrorDetails } from 'hooks/useClient'; import type { ErrorDetails } from 'hooks/useClient';
import { getConsoleBuffer } from '@/lib/console-buffer'; import { getConsoleBuffer } from '@/lib/console-buffer';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from '@/lib/i18n';
export const useBugReport = () => { export const useBugReport = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -14,6 +15,7 @@ export const useBugReport = () => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const client = useClient(); const client = useClient();
const { user } = useAuth(); const { user } = useAuth();
const { t } = useTranslation();
const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null); const [apiError] = useGlobal<ErrorDetails | null>('API_ERROR', null);
const handleOpen = useCallback(async () => { const handleOpen = useCallback(async () => {
@@ -57,15 +59,15 @@ export const useBugReport = () => {
} }
await client.post('/bug-report', formData); await client.post('/bug-report', formData);
toast.success('Bug report submitted'); toast.success(t('bugReport.submitted'));
handleClose(); handleClose();
} catch { } catch {
toast.error('Failed to submit bug report'); toast.error(t('bugReport.failed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, },
[screenshot, client, user, apiError, handleClose], [screenshot, client, user, apiError, handleClose, t],
); );
return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit }; return { open, capturing, submitting, screenshot, handleOpen, handleClose, handleSubmit };
@@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback'; import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background'; import { Background } from './Background';
import { Header } from './Header'; import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock'; import { Dock, useDockItems, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight'; import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal'; import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch'; import { useIsTouch } from './useIsTouch';
@@ -24,10 +24,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
// will then slam is worse than one that never showed it. // will then slam is worse than one that never showed it.
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes // The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one. // anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo( const allDockItems = useDockItems(plugins);
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)), const permitted = useMemo(() => allDockItems.filter((item) => canVisit(item.to)), [canVisit, allDockItems]);
[canVisit, plugins],
);
const { items: visibleItems } = useDock( const { items: visibleItems } = useDock(
permitted, permitted,
useMemo(() => defaultDockPaths(plugins), [plugins]), useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev'; import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/usePermissions'; import type { PluginManifest } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
export type DockItem = { export type DockItem = {
/** Already resolved for the active locale — see `useDockItems`. */
label: string; label: string;
to: string; to: string;
color: string; color: string;
@@ -169,22 +171,22 @@ import {
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in * These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
* the light profile because it fronts a remote instance and installs nothing locally. * the light profile because it fronts a remote instance and installs nothing locally.
*/ */
export const CORE_DOCK_ITEMS: DockItem[] = [ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, { labelKey: 'nav.home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' }, { labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, { labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, { labelKey: 'nav.browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { labelKey: 'nav.monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { labelKey: 'nav.activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, { labelKey: 'nav.dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is // Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment // `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets. // every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' }, { labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and // Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working // contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure // while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -194,16 +196,16 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork // the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color` // does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
// still earns its place: the active glow, the indicator dot and the settings tint all use it. // still earns its place: the active glow, the indicator dot and the settings tint all use it.
{ label: 'Calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' }, { labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ label: 'Contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' }, { labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the // Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`, // tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one. // and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
// Core by necessity: the store is how every other feature arrives, so it can never be one of the // Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled. // things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' }, { labelKey: 'nav.appStore', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one. // Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' }, { labelKey: 'nav.marketplace', to: '/marketplace', icon: Puzzle, color: '#94a3b8' },
]; ];
/** /**
@@ -213,6 +215,30 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than * have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock. * throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
*/ */
/**
* A shell tile before its label is resolved. `labelKey` rather than `label` because these are the only
* dock entries the platform owns the words for — a plugin's tile carries the name from its own manifest,
* which is the plugin's string and not ours to translate (docs/localization.md open question 3).
*/
type CoreDockItem = Omit<DockItem, 'label'> & { labelKey: AppTranslationKey };
/**
* The composed dock, with the shell's own labels in the active locale.
*
* One place rather than three: DashboardLayout, Spotlight and DockSettings each built this list by
* spreading the same two sources, and each would otherwise have to resolve labels itself.
*/
export function useDockItems(plugins: PluginManifest[]): DockItem[] {
const { t } = useTranslation();
return useMemo(
() => [
...CORE_DOCK_ITEMS.map(({ labelKey, ...item }) => ({ ...item, label: t(labelKey) })),
...dockItemsFromPlugins(plugins),
],
[t, plugins],
);
}
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] { export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
return plugins.flatMap((plugin) => { return plugins.flatMap((plugin) => {
const tile = (t: { const tile = (t: {
@@ -13,6 +13,7 @@ import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle'; import { usePageTitle } from '@/state/usePageTitle';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useThemeAsset } from '@/components/ui/ThemeProvider'; import { useThemeAsset } from '@/components/ui/ThemeProvider';
import { useTranslation } from '@/lib/i18n';
// import { BugReportButton } from '../BugReport/BugReportButton'; // import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a // Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
@@ -20,14 +21,15 @@ import { useThemeAsset } from '@/components/ui/ThemeProvider';
// the field hand the tab back to the route's own name. // the field hand the tab back to the route's own name.
function EditablePageTitle() { function EditablePageTitle() {
const [title, rename] = usePageTitle(); const [title, rename] = usePageTitle();
const { t } = useTranslation();
return ( return (
<EditableTitle <EditableTitle
value={title} value={title}
onCommit={rename} onCommit={rename}
allowEmpty allowEmpty
ariaLabel="Tab name" ariaLabel={t('header.tabName')}
hint="Click to rename this tab — clear it to go back to the page name" hint={t('header.tabNameHint')}
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl" className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl" inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
/> />
@@ -48,6 +50,7 @@ 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(); const { user } = useAuth();
const { t } = useTranslation();
// 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');
@@ -81,7 +84,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
*/} */}
<div <div
role="img" role="img"
aria-label="Officer" aria-label={t('header.logoAlt')}
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat" className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark — /* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
@@ -119,7 +122,7 @@ export function Header({ dockItems, hidden }: HeaderProps) {
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left"> <SheetContent side="left">
<SheetHeader> <SheetHeader>
<SheetTitle>Navigation</SheetTitle> <SheetTitle>{t('header.navigation')}</SheetTitle>
</SheetHeader> </SheetHeader>
<nav className="flex flex-col gap-1 mt-4 px-2"> <nav className="flex flex-col gap-1 mt-4 px-2">
{dockItems.map((item) => ( {dockItems.map((item) => (
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react'; import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { useTranslation } from '@/lib/i18n';
type Counts = { running: number; runningJobId: string | null; queued: number }; type Counts = { running: number; runningJobId: string | null; queued: number };
@@ -15,6 +16,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
export const JobsIndicator = () => { export const JobsIndicator = () => {
const client = useClient(); const client = useClient();
const { can } = usePermissions(); const { can } = usePermissions();
const { t } = useTranslation();
const allowed = can('tasks'); const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 }); const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
@@ -44,7 +46,7 @@ export const JobsIndicator = () => {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Link <Link
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'} to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
title={counts.running ? 'Running job' : 'Nothing running'} title={counts.running ? t('header.jobsRunning') : t('header.jobsNothingRunning')}
className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`} className={`${pill} text-white ${counts.running ? 'bg-blue-500/60 hover:bg-blue-500/75' : 'bg-black/25 hover:bg-black/35'}`}
> >
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} /> <Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
@@ -52,7 +54,7 @@ export const JobsIndicator = () => {
</Link> </Link>
<Link <Link
to="/jobs" to="/jobs"
title="Queued jobs" title={t('header.jobsQueued')}
className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`} className={`${pill} text-white ${counts.queued ? 'bg-amber-500/70 hover:bg-amber-500/85' : 'bg-black/25 hover:bg-black/35'}`}
> >
<ListOrdered className="h-3.5 w-3.5" /> <ListOrdered className="h-3.5 w-3.5" />
@@ -5,6 +5,7 @@ import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
import * as Dropdown from '@/components/ui/dropdown-menu'; import * as Dropdown from '@/components/ui/dropdown-menu';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider'; import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites'; import { useThemeFavorites } from '@/state/useThemeFavorites';
import { useTranslation } from '@/lib/i18n';
const { const {
DropdownMenu, DropdownMenu,
@@ -32,6 +33,7 @@ const {
*/ */
export const ThemeSwitcher = () => { export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme(); const { theme, themeId, setTheme, themes } = useTheme();
const { t } = useTranslation();
const { favorites, isFavorite, toggle } = useThemeFavorites(); const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere. // 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 { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
@@ -50,7 +52,7 @@ export const ThemeSwitcher = () => {
*/ */
const ordered = useMemo(() => { const ordered = useMemo(() => {
const rotated = rotateToSelected(themes, themeId); const rotated = rotateToSelected(themes, themeId);
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated; const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
return favouritesFirst(listed, favorites); return favouritesFirst(listed, favorites);
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those. // `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]); }, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
@@ -62,10 +64,11 @@ export const ThemeSwitcher = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${ title={t('theme.pillTitle', {
isFavorite(themeId) ? 'unstar' : 'star' name: theme.name,
}`} starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
aria-label={`Theme: ${theme.name}`} })}
aria-label={t('theme.pillLabel', { name: theme.name })}
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth // 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 // reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
// the cluster stays one shape. // the cluster stays one shape.
@@ -89,8 +92,8 @@ export const ThemeSwitcher = () => {
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0"> <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"> <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"> <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">{t('theme.cycleHint')}</span>
<span className="block">ctrl+? for every shortcut</span> <span className="block">{t('theme.everyShortcutHint')}</span>
</DropdownMenuLabel> </DropdownMenuLabel>
<button <button
type="button" type="button"
@@ -105,35 +108,41 @@ export const ThemeSwitcher = () => {
}`} }`}
> >
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} /> <Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`} {onlyFavorites
? t('theme.favouritesOnly', { count: favorites.size })
: t('theme.favourites', { count: favorites.size })}
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-1"> <div className="min-h-0 flex-1 overflow-y-auto p-1">
{ordered.length === 0 ? ( {ordered.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground"> <p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
No favourites yet — star one with the button on its row.
</p>
) : ( ) : (
families.map(([family, entries]) => ( families.map(([family, entries]) => (
<div key={family}> <div key={family}>
{entries.map((t) => ( {entries.map((entry) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1"> <DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} /> <Check
<span className="min-w-0 flex-1 truncate">{t.name}</span> className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */} {/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
<button <button
type="button" type="button"
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`} aria-label={
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'} isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
onClick={(ev) => { onClick={(ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
toggle(t.id); toggle(entry.id);
}} }}
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary" 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' : ''}`} /> <Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -165,22 +174,24 @@ export const ThemeSwitcher = () => {
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the 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. */} 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"> <ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((t) => { {ordered.map((entry) => {
const isFocused = t.id === focused; const isFocused = entry.id === focused;
return ( return (
<li key={t.id}> <li key={entry.id}>
<button <button
onMouseEnter={() => setFocused(t.id)} onMouseEnter={() => setFocused(entry.id)}
onClick={() => { onClick={() => {
setTheme(t.id); setTheme(entry.id);
setOpen(false); setOpen(false);
}} }}
className={`flex w-full items-center gap-2 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'
}`} }`}
> >
<span className="min-w-0 flex-1 truncate">{t.name}</span> <span className="min-w-0 flex-1 truncate">{entry.name}</span>
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>} {entry.id === themeId && (
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
)}
{/* {/*
A span, not a button: this sits inside the row's own <button>, and a button inside 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 a button is invalid HTML that browsers resolve by dropping one of them. The click
@@ -190,18 +201,24 @@ export const ThemeSwitcher = () => {
<span <span
role="button" role="button"
tabIndex={-1} tabIndex={-1}
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`} aria-label={
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'} isFavorite(entry.id)
? t('theme.unstarNamed', { name: entry.name })
: t('theme.starNamed', { name: entry.name })
}
title={
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
}
onClick={(ev) => { onClick={(ev) => {
// Or the row's own onClick applies the theme and closes the overlay, which is // Or the row's own onClick applies the theme and closes the overlay, which is
// the opposite of what starring is for. // the opposite of what starring is for.
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
toggle(t.id); toggle(entry.id);
}} }}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100" className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
> >
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} /> <Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
</span> </span>
</button> </button>
</li> </li>
@@ -210,9 +227,7 @@ export const ThemeSwitcher = () => {
</ul> </ul>
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's {/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
header does. */} header does. */}
<p className="mt-6 text-center text-xs opacity-60"> <p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
</p>
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -27,13 +27,13 @@ export function UserMenu() {
<DropdownMenuContent align="end" className="z-10 w-48"> <DropdownMenuContent align="end" className="z-10 w-48">
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile"> <Link to="/settings/profile">
<User className="mr-2 h-4 w-4" /> <User className="me-2 h-4 w-4" />
{t('header.userMenu.profile')} {t('header.userMenu.profile')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/system"> <Link to="/settings/system">
<Settings className="mr-2 h-4 w-4" /> <Settings className="me-2 h-4 w-4" />
{t('header.userMenu.systemSettings')} {t('header.userMenu.systemSettings')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -42,14 +42,14 @@ export function UserMenu() {
(navigation-audit M8.) */} (navigation-audit M8.) */}
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai"> <Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" /> <Bot className="me-2 h-4 w-4" />
AI {t('header.userMenu.ai')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/integrations"> <Link to="/settings/integrations">
<Puzzle className="mr-2 h-4 w-4" /> <Puzzle className="me-2 h-4 w-4" />
Integrations {t('header.userMenu.integrations')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{/* Owner only. This is tidiness, not access control — the route stays reachable and its {/* Owner only. This is tidiness, not access control — the route stays reachable and its
@@ -58,15 +58,15 @@ export function UserMenu() {
{user?.role === 'Super Admin' && ( {user?.role === 'Super Admin' && (
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/user-management"> <Link to="/settings/user-management">
<UserCog className="mr-2 h-4 w-4" /> <UserCog className="me-2 h-4 w-4" />
User Management {t('header.userMenu.userManagement')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout"> <Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="me-2 h-4 w-4" />
{t('header.userMenu.signOut')} {t('header.userMenu.signOut')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -4,9 +4,29 @@ import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
type RescanResponse = { ok: boolean; counts: Record<string, number> }; type RescanResponse = { ok: boolean; counts: Record<string, number> };
/**
* One key per counted noun rather than one sentence with six numbers in it.
*
* Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with
* more than two categories needs it for every value. Assembling the sentence in code and translating only
* the frame would have hardcoded English plural rules into the frame.
*
* Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats
* the type checker and cannot be grepped (see the key naming convention in docs/localization.md).
*/
const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [
['tasks', 'rescan.counts.tasks'],
['agents', 'rescan.counts.agents'],
['tools', 'rescan.counts.tools'],
['skills', 'rescan.counts.skills'],
['processes', 'rescan.counts.processes'],
['affordances', 'rescan.counts.affordances'],
];
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the // Item query caches the header refreshes after a rescan (matches the queryKeys used by the
// Automation and Capability pages). // Automation and Capability pages).
const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories']; const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories'];
@@ -15,6 +35,7 @@ export function RescanButton() {
const client = useClient(); const client = useClient();
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = usePermissions(); const { can } = usePermissions();
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the // `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
@@ -32,32 +53,28 @@ export function RescanButton() {
// you next open that page. This refreshes every item cache immediately, wherever you press it. // you next open that page. This refreshes every item cache immediately, wherever you press it.
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' }))); await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {}; const c = res.counts ?? {};
toast.success( const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', ');
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`, toast.success(t('rescan.done', { summary }), {
{ // Two things said here rather than left to be discovered.
// Two things said here rather than left to be discovered. //
// // A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it // started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an // agent, because the loader skips it: a line that does not say when to reach for something
// agent, because the loader skips it: a line that does not say when to reach for something // costs context in every conversation and earns nothing. So a skill can be present, listed and
// costs context in every conversation and earns nothing. So a skill can be present, listed and // editable while being invisible to every agent, and the only symptom is two numbers
// editable while being invisible to every agent, and the only symptom is two numbers // disagreeing where nobody is comparing them.
// disagreeing where nobody is comparing them. description:
description: [
[ c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null,
c.affordances || c.skillsOffered typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? 'Applies to new chats — an open one keeps what it started with.' ? t('rescan.noDescription', { count: c.skills - c.skillsOffered })
: null, : null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills ]
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.` .filter(Boolean)
: null, .join(' ') || undefined,
] });
.filter(Boolean)
.join(' ') || undefined,
},
);
} catch { } catch {
toast.error('Failed to rescan items'); toast.error(t('rescan.failed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -68,8 +85,8 @@ export function RescanButton() {
onClick={rescan} onClick={rescan}
disabled={loading} disabled={loading}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50" className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
aria-label="Rescan items" aria-label={t('rescan.label')}
title="Rescan officer-items (apply changes without restarting)" title={t('rescan.title')}
> >
<RotateCw size={16} className={loading ? 'animate-spin' : ''} /> <RotateCw size={16} className={loading ? 'animate-spin' : ''} />
</button> </button>
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/lib/i18n';
type ScreenErrorFallbackProps = { error: Error; reset: () => void }; type ScreenErrorFallbackProps = { error: Error; reset: () => void };
@@ -18,6 +19,7 @@ type ScreenErrorFallbackProps = { error: Error; reset: () => void };
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => { export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
@@ -46,36 +48,34 @@ export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps)
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" /> <AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-semibold">This screen failed to render</h1> <h1 className="text-xl font-semibold">{t('screenError.title')}</h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">{t('screenError.description')}</p>
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
untouched.
</p>
</div> </div>
</div> </div>
<details className="bg-muted/40 rounded-md border p-3 text-sm"> <details className="bg-muted/40 rounded-md border p-3 text-sm">
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary> <summary className="cursor-pointer select-none font-medium">
{error.message || t('screenError.unknownError')}
</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre> <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
</details> </details>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={reset}>Try again</Button> <Button onClick={reset}>{t('screenError.tryAgain')}</Button>
<Button variant="secondary" onClick={() => window.location.reload()}> <Button variant="secondary" onClick={() => window.location.reload()}>
Reload {t('screenError.reload')}
</Button> </Button>
<Button variant="secondary" asChild> <Button variant="secondary" asChild>
<Link to="/">Go home</Link> <Link to="/">{t('screenError.goHome')}</Link>
</Button> </Button>
</div> </div>
<div className="space-y-2 border-t pt-4"> <div className="space-y-2 border-t pt-4">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's {t('screenError.resetHint')} <strong>{t('screenError.dashboardsSafe')}</strong>
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
</p> </p>
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}> <Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'} {resetting ? t('screenError.resetting') : confirming ? t('screenError.resetConfirm') : t('screenError.reset')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Keyboard } from 'lucide-react'; import { Keyboard } from 'lucide-react';
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts'; import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
import { useTranslation } from '@/lib/i18n';
/* /*
`Ctrl+?` — every shortcut the shell owns, on one screen. `Ctrl+?` — every shortcut the shell owns, on one screen.
@@ -27,6 +28,7 @@ const Row = ({ keys, description }: { keys: string; description: string }) => (
export const ShortcutsModal = () => { export const ShortcutsModal = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const onKey = (ev: KeyboardEvent) => { const onKey = (ev: KeyboardEvent) => {
@@ -57,8 +59,8 @@ export const ShortcutsModal = () => {
> >
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3"> <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" /> <Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2> <h2 className="text-sm font-semibold">{t('shortcutsModal.title')}</h2>
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span> <span className="ms-auto text-xs text-muted-foreground">{t('shortcutsModal.hint')}</span>
</div> </div>
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */} {/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { rank } from './match'; import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems'; import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
import { useTranslation } from '@/lib/i18n';
// Spotlight: type a few letters, go somewhere. // Spotlight: type a few letters, go somewhere.
// //
@@ -181,6 +182,7 @@ export const Spotlight = () => {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const items = useSpotlightItems(); const items = useSpotlightItems();
const { t } = useTranslation();
const results = useMemo(() => rank(items, query), [items, query]); const results = useMemo(() => rank(items, query), [items, query]);
@@ -287,14 +289,14 @@ export const Spotlight = () => {
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(ev) => setQuery(ev.target.value)} onChange={(ev) => setQuery(ev.target.value)}
placeholder="Go to…" placeholder={t('spotlight.placeholder')}
className="w-full bg-transparent py-4 text-base 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-[60vh] 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">{t('spotlight.noResults', { query })}</p>
) : ( ) : (
results.map((result, i) => ( results.map((result, i) => (
<Row <Row
@@ -19,7 +19,8 @@ import {
Wrench, Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock'; import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
import { useDockItems } from '../Dock';
// What Spotlight can take you to. // What Spotlight can take you to.
// //
@@ -40,6 +41,7 @@ import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
export type SpotlightItem = { export type SpotlightItem = {
/** Stable across renders — used as the React key and for the recency store. */ /** Stable across renders — used as the React key and for the recency store. */
id: string; id: string;
/** Resolved for the active locale, because `rank()` matches what the reader can actually see. */
label: string; label: string;
/** Where it goes. Matched against `canVisit` for anything not already filtered. */ /** Where it goes. Matched against `canVisit` for anything not already filtered. */
to: string; to: string;
@@ -78,64 +80,137 @@ export type SpotlightItem = {
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone * `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
* does not say where it lives. * does not say where it lives.
*/ */
const SETTINGS_ITEMS: SpotlightItem[] = [ type SettingsItem = Omit<SpotlightItem, 'label' | 'hint'> & {
labelKey: AppTranslationKey;
hintKey?: AppTranslationKey;
};
const SETTINGS_ITEMS: SettingsItem[] = [
// Profile — ProfileSettings/index.tsx // Profile — ProfileSettings/index.tsx
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User }, {
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock }, id: 'set:profile',
labelKey: 'settings.sections.profile.label',
to: '/settings/profile',
hintKey: 'settings.sections.profile.hint',
icon: User,
},
{
id: 'set:password',
labelKey: 'settings.sections.changePassword.label',
to: '/settings/profile/change-password',
icon: Lock,
},
{ {
id: 'set:api-keys', id: 'set:api-keys',
label: 'API keys', labelKey: 'settings.sections.apiKeys.label',
to: '/settings/profile/api-keys', to: '/settings/profile/api-keys',
hint: 'keys that sign in as you', hintKey: 'settings.sections.apiKeys.hint',
icon: KeyRound, icon: KeyRound,
}, },
{ {
id: 'set:languages', id: 'set:languages',
label: 'Languages', labelKey: 'settings.sections.languages.label',
to: '/settings/profile/languages', to: '/settings/profile/languages',
hint: 'spoken and translation', hintKey: 'settings.sections.languages.hint',
icon: Globe, icon: Globe,
}, },
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 }, {
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid }, id: 'set:voice',
labelKey: 'settings.sections.voice.label',
to: '/settings/profile/voice',
hintKey: 'settings.sections.voice.hint',
icon: Volume2,
},
{
id: 'set:dock',
labelKey: 'settings.sections.dock.label',
to: '/settings/profile/dock',
hintKey: 'settings.sections.dock.hint',
icon: LayoutGrid,
},
// AI — AISettings.tsx // AI — AISettings.tsx
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles }, {
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles }, id: 'set:ai-my-models',
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles }, labelKey: 'settings.sections.myModels.label',
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot }, to: '/settings/ai/my-models',
icon: Sparkles,
},
{
id: 'set:ai-channel-models',
labelKey: 'settings.sections.channelModels.label',
to: '/settings/ai/member-models',
icon: Sparkles,
},
{
id: 'set:ai-default-models',
labelKey: 'settings.sections.defaultModels.label',
to: '/settings/ai/default-models',
icon: Sparkles,
},
{
id: 'set:ai-harnesses',
labelKey: 'settings.sections.providers.label',
to: '/settings/ai/ai-harnesses',
hintKey: 'settings.sections.providers.hint',
icon: Bot,
},
// System — SystemSettings.tsx // System — SystemSettings.tsx
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 }, { id: 'set:tts', labelKey: 'settings.sections.tts.label', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic }, { id: 'set:stt', labelKey: 'settings.sections.stt.label', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText }, { id: 'set:ocr', labelKey: 'settings.sections.ocr.label', to: '/settings/system/ocr', icon: ScanText },
{ {
id: 'set:maintenance', id: 'set:maintenance',
label: 'Rebuild & restart', labelKey: 'settings.sections.maintenance.label',
to: '/settings/system/maintenance', to: '/settings/system/maintenance',
hint: 'maintenance', hintKey: 'settings.sections.maintenance.hint',
icon: Cpu, icon: Cpu,
}, },
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail }, {
id: 'set:smtp',
labelKey: 'settings.sections.smtp.label',
to: '/settings/system/smtp',
hintKey: 'settings.sections.smtp.hint',
icon: Mail,
},
// Integrations — IntegrationsSettings // Integrations — IntegrationsSettings
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound }, {
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench }, id: 'set:google-oauth',
labelKey: 'settings.sections.googleOauth.label',
to: '/settings/integrations/google-oauth',
icon: KeyRound,
},
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
{ {
id: 'set:dav', id: 'set:dav',
label: 'Calendar & Contacts sync', labelKey: 'settings.sections.davSync.label',
to: '/settings/integrations/dav-app-passwords', to: '/settings/integrations/dav-app-passwords',
hint: 'app passwords', hintKey: 'settings.sections.davSync.hint',
icon: CalendarDays, icon: CalendarDays,
}, },
// Users — UserManagement // Users — UserManagement
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users }, {
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck }, id: 'set:users',
labelKey: 'settings.sections.accounts.label',
to: '/settings/user-management/users',
hintKey: 'settings.sections.accounts.hint',
icon: Users,
},
{
id: 'set:permissions',
labelKey: 'settings.sections.permissions.label',
to: '/settings/user-management/permissions',
icon: ShieldCheck,
},
]; ];
/** Everything Spotlight can offer this account, in a stable order. */ /** Everything Spotlight can offer this account, in a stable order. */
export function useSpotlightItems(): SpotlightItem[] { export function useSpotlightItems(): SpotlightItem[] {
const { canVisit, plugins } = usePermissions(); const { canVisit, plugins } = usePermissions();
const { t } = useTranslation();
const dockItems = useDockItems(plugins);
return useMemo(() => { return useMemo(() => {
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)] const fromDock: SpotlightItem[] = dockItems
.filter((item) => canVisit(item.to)) .filter((item) => canVisit(item.to))
.map((item) => ({ .map((item) => ({
id: `nav:${item.to}`, id: `nav:${item.to}`,
@@ -149,8 +224,14 @@ export function useSpotlightItems(): SpotlightItem[] {
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a // Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings. // courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to)); const settings: SpotlightItem[] = SETTINGS_ITEMS.filter((item) => canVisit(item.to)).map(
({ labelKey, hintKey, ...item }) => ({
...item,
label: t(labelKey),
...(hintKey ? { hint: t(hintKey) } : {}),
}),
);
return [...fromDock, ...settings]; return [...fromDock, ...settings];
}, [canVisit, plugins]); }, [canVisit, dockItems, t]);
} }
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings'; import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels'; import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
import { useAccessPolicy } from 'state/useAccessPolicy'; import { useAccessPolicy } from 'state/useAccessPolicy';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
// --- My Models Section (per-user hidden models) --- // --- My Models Section (per-user hidden models) ---
function MyModelsSection() { function MyModelsSection() {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const visibleModels = useModels(); const visibleModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', ''); const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
@@ -174,11 +176,7 @@ function MyModelsSection() {
}, []); }, []);
if (providerGroups.length === 0) { if (providerGroups.length === 0) {
return ( return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
} }
return ( return (
@@ -199,9 +197,9 @@ function MyModelsSection() {
))} ))}
</div> </div>
<DropZone label="Visible" onDrop={showModel}> <DropZone label={t('settings.ai.visible')} onDrop={showModel}>
{shown.length === 0 && ( {shown.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
)} )}
{shown.map((m) => ( {shown.map((m) => (
<ModelPill <ModelPill
@@ -215,9 +213,9 @@ function MyModelsSection() {
))} ))}
</DropZone> </DropZone>
<DropZone label="Hidden" onDrop={hideModel}> <DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
{hidden.length === 0 && ( {hidden.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
)} )}
{hidden.map((m) => ( {hidden.map((m) => (
<ModelPill <ModelPill
@@ -237,6 +235,7 @@ function MyModelsSection() {
// --- Member Models Section (system-wide policy, admin only) --- // --- Member Models Section (system-wide policy, admin only) ---
function MemberModelsSection() { function MemberModelsSection() {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy(); const { policy, savePolicy } = useAccessPolicy();
@@ -249,9 +248,9 @@ function MemberModelsSection() {
try { try {
await client.post('/server-settings/chat-providers/local-providers/refresh'); await client.post('/server-settings/chat-providers/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] }); queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success('Models refreshed'); toast.success(t('settings.ai.refreshed'));
} catch { } catch {
toast.error('Failed to refresh models'); toast.error(t('settings.ai.refreshFailed'));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -325,11 +324,7 @@ function MemberModelsSection() {
}, []); }, []);
if (providerGroups.length === 0) { if (providerGroups.length === 0) {
return ( return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
} }
return ( return (
@@ -353,15 +348,17 @@ function MemberModelsSection() {
onClick={refreshModels} onClick={refreshModels}
disabled={refreshing} disabled={refreshing}
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30" className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Refresh models from providers" title={t('settings.ai.refresh')}
> >
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
</button> </button>
</div> </div>
<DropZone label="Enabled" onDrop={enableModel}> <DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
{enabled.length === 0 && ( {enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.ai.dragToEnable')}
</span>
)} )}
{enabled.map((m) => ( {enabled.map((m) => (
<ModelPill <ModelPill
@@ -375,9 +372,9 @@ function MemberModelsSection() {
))} ))}
</DropZone> </DropZone>
<DropZone label="Disabled" onDrop={disableModel}> <DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
{disabled.length === 0 && ( {disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
)} )}
{disabled.map((m) => ( {disabled.map((m) => (
<ModelPill <ModelPill
@@ -410,49 +407,49 @@ function MemberModelsSection() {
function useAISettingsGroups(): SettingsSectionGroup[] { function useAISettingsGroups(): SettingsSectionGroup[] {
return useMemo(() => { return useMemo(() => {
const modelsGroup: SettingsSectionGroup = { const modelsGroup: SettingsSectionGroup = {
label: 'Models', labelKey: 'settings.groups.models',
icon: Eye, icon: Eye,
sections: [ sections: [
{ {
key: 'my-models', key: 'my-models',
icon: Eye, icon: Eye,
title: 'My Models', titleKey: 'settings.sections.myModels.label',
description: 'Show or hide models for yourself', descriptionKey: 'settings.sections.myModels.description',
content: <MyModelsSection />, content: <MyModelsSection />,
}, },
{ {
key: 'member-models', key: 'member-models',
icon: Eye, icon: Eye,
title: 'Channel Models', titleKey: 'settings.sections.channelModels.label',
description: 'Models reachable by non-owner accounts', descriptionKey: 'settings.sections.channelModels.description',
content: <MemberModelsSection />, content: <MemberModelsSection />,
}, },
], ],
}; };
const defaultsGroup: SettingsSectionGroup = { const defaultsGroup: SettingsSectionGroup = {
label: 'Defaults', labelKey: 'settings.groups.defaults',
icon: Sparkles, icon: Sparkles,
sections: [ sections: [
{ {
key: 'default-models', key: 'default-models',
icon: Sparkles, icon: Sparkles,
title: 'Default Models', titleKey: 'settings.sections.defaultModels.label',
description: 'Default models for chat, projects, and tasks', descriptionKey: 'settings.sections.defaultModels.description',
content: <AIModels />, content: <AIModels />,
}, },
], ],
}; };
const providersGroup: SettingsSectionGroup = { const providersGroup: SettingsSectionGroup = {
label: 'Providers', labelKey: 'settings.groups.providers',
icon: Terminal, icon: Terminal,
sections: [ sections: [
{ {
key: 'ai-harnesses', key: 'ai-harnesses',
icon: Terminal, icon: Terminal,
title: 'Providers', titleKey: 'settings.sections.providers.label',
description: 'Remote and local AI providers', descriptionKey: 'settings.sections.providers.description',
content: <AIHarnessesSection />, content: <AIHarnessesSection />,
}, },
], ],
@@ -482,7 +479,7 @@ export const AISettings = () => {
createSettingsPanelComponents({ createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: Bot, sidebarIcon: Bot,
sidebarLabel: 'AI', sidebarLabelKey: 'settings.nav.ai',
groups, groups,
}), }),
[groups], [groups],
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now // import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig'; import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords'; import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations'; const BASE_PATH = '/settings/integrations';
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
{ {
key: 'google-oauth', key: 'google-oauth',
icon: KeyRound, icon: KeyRound,
title: 'Google OAuth', titleKey: 'settings.sections.googleOauth.label',
description: 'Client ID and secret for Google APIs', descriptionKey: 'settings.sections.googleOauth.description',
content: <GoogleOAuthConfig />, content: <GoogleOAuthConfig />,
}, },
{ {
key: 'apify', key: 'apify',
icon: Wrench, icon: Wrench,
title: 'Apify', titleKey: 'settings.sections.apify.label',
description: 'API token for web scraping actors', descriptionKey: 'settings.sections.apify.description',
content: <ApifyConfig />, content: <ApifyConfig />,
}, },
]; ];
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
// key: 'google-account', // key: 'google-account',
// icon: UserCircle, // icon: UserCircle,
// title: 'Google Account', // title: 'Google Account',
// description: 'Connect your Google account', // descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />, // content: <GoogleAccount />,
// }, // },
{ {
key: 'dav-app-passwords', key: 'dav-app-passwords',
icon: CalendarDays, icon: CalendarDays,
title: 'Calendar & Contacts sync', titleKey: 'settings.sections.davSync.label',
description: 'Per-device passwords for CalDAV/CardDAV clients', descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />, content: <DavAppPasswords />,
}, },
]; ];
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise'; personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => { const IntegrationsSidebar = () => {
const { t } = useTranslation();
const { section } = useParams<{ section?: string }>(); const { section } = useParams<{ section?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const tab = tabForSection(section); const tab = tabForSection(section);
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
<div className="p-3 pb-2"> <div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"> <div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Puzzle className="h-4 w-4" /> <Puzzle className="h-4 w-4" />
Integrations {t('settings.nav.integrations')}
</div> </div>
</div> </div>
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Tabs value={tab} onValueChange={setTab}> <Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer"> <TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
Enterprise {t('settings.integrations.enterprise')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="personal" className="flex-1 cursor-pointer"> <TabsTrigger value="personal" className="flex-1 cursor-pointer">
Personal {t('settings.integrations.personal')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader /> <SettingsSidebar
basePath={BASE_PATH}
icon={Puzzle}
labelKey="settings.nav.integrations"
sections={sections}
hideHeader
/>
</div> </div>
); );
}; };
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels'; import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
function buildGroups(models: ModelOption[]) { function buildGroups(models: ModelOption[]) {
const groups: Record<string, { id: string; name: string }[]> = {}; const groups: Record<string, { id: string; name: string }[]> = {};
@@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) {
const NONE = '__none__'; const NONE = '__none__';
export const AIModels = () => { export const AIModels = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const piModels = useUserVisibleModels(); const piModels = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -54,9 +56,9 @@ export const AIModels = () => {
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel }, chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
tasks: { ...settings.tasks, defaultModel: taskModel }, tasks: { ...settings.tasks, defaultModel: taskModel },
}); });
toast.success('AI model defaults saved'); toast.success(t('settings.profile.models.aiDefaultsSaved'));
} catch { } catch {
toast.error('Failed to save settings'); toast.error(t('settings.profile.models.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -86,25 +88,25 @@ export const AIModels = () => {
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen {t('settings.profile.models.defaultChatHint')}
</p> </p>
{renderModelSelect(chatModel, setChatModel, 'System default')} {renderModelSelect(chatModel, setChatModel, 'System default')}
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard {t('settings.profile.models.defaultProjectHint')}
</p> </p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')} {renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser {t('settings.profile.models.defaultTaskHint')}
</p> </p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')} {renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label> </Label>
@@ -2,6 +2,8 @@ import type { FormEvent } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react'; import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -36,19 +38,22 @@ type Minted = { key: string; name: string };
const API_KEYS_QUERY_KEY = ['API_KEYS']; const API_KEYS_QUERY_KEY = ['API_KEYS'];
const formatDate = (value: string | null) => // `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null; // browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7).
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success('Key copied');
} catch {
toast.error('Could not copy — select and copy manually');
}
};
export const ApiKeys = () => { export const ApiKeys = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.profile.apiKeys.copied'));
} catch {
toast.error(t('settings.profile.apiKeys.copyFailed'));
}
};
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -74,7 +79,7 @@ export const ApiKeys = () => {
setName(''); setName('');
await refresh(); await refresh();
} catch (ex) { } catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not create the key'); toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed'));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -85,17 +90,17 @@ export const ApiKeys = () => {
await client.delete(`/api-keys/${id}/record`); await client.delete(`/api-keys/${id}/record`);
await refresh(); await refresh();
} catch { } catch {
toast.error('Could not remove it'); toast.error(t('settings.profile.apiKeys.forgetFailed'));
} }
}; };
const revoke = async (id: number) => { const revoke = async (id: number) => {
try { try {
await client.delete(`/api-keys/${id}`); await client.delete(`/api-keys/${id}`);
toast.success('Revoked — anything using that key is signed out'); toast.success(t('settings.profile.apiKeys.revokeDone'));
await refresh(); await refresh();
} catch { } catch {
toast.error('Could not revoke'); toast.error(t('settings.profile.apiKeys.revokeFailed'));
} }
}; };
@@ -116,7 +121,7 @@ export const ApiKeys = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys… <Loader2 className="h-4 w-4 animate-spin" /> {t('settings.profile.apiKeys.loading')}
</div> </div>
); );
} }
@@ -124,15 +129,13 @@ export const ApiKeys = () => {
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70"> <p className="text-sm text-duck-dark/70 dark:text-foreground/70">
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not {t('settings.profile.apiKeys.intro')} <code className="font-mono text-xs">Authorization: Bearer …</code>.
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
</p> </p>
{minted && ( {minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4"> <div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground"> <div className="text-sm font-medium text-duck-dark dark:text-foreground">
Key for “{minted.name}” — shown once {t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}
</div> </div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60"> <p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it, This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
@@ -143,30 +146,35 @@ export const ApiKeys = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs"> <code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{minted.key} {minted.key}
</code> </code>
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key"> <Button
variant="ghost"
size="sm"
onClick={() => copy(minted.key)}
title={t('settings.profile.apiKeys.copyKey')}
>
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}> <Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it {t('settings.profile.apiKeys.savedIt')}
</Button> </Button>
</div> </div>
)} )}
<form onSubmit={create} className="flex items-end gap-2"> <form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2"> <Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.apiKeys.newKey')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20" className="h-11 bg-background/60 border-duck-dark/20"
value={name} value={name}
onChange={(ev) => setName(ev.target.value)} onChange={(ev) => setName(ev.target.value)}
placeholder="iPhone, Music app, laptop CLI…" placeholder={t('settings.profile.apiKeys.namePlaceholder')}
/> />
</Label> </Label>
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11"> <Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" /> <Plus className="me-1.5 h-4 w-4" />
Create {t('settings.profile.apiKeys.create')}
</Button> </Button>
</form> </form>
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40"> <div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence {/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
that would send somebody looking for a bug in the list. */} that would send somebody looking for a bug in the list. */}
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'} {allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
</div> </div>
) : ( ) : (
<div className="grid gap-2"> <div className="grid gap-2">
{keys.map((entry) => { {keys.map((entry) => {
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now(); const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
const dead = !!entry.revokedAt || expired; const dead = !!entry.revokedAt || expired;
const lastUsed = formatDate(entry.lastUsedAt); const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return ( return (
<div <div
key={entry.id} key={entry.id}
@@ -195,14 +205,20 @@ export const ApiKeys = () => {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground"> <div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.name} {entry.name}
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>} {entry.revokedAt && (
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>} <span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.revoked')}</span>
)}
{!entry.revokedAt && expired && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.expired')}</span>
)}
</div> </div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40"> <div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.prefix}…</code> <code className="font-mono">{entry.prefix}…</code>
{' · '} {' · '}
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */} {/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'} {lastUsed
? t('settings.profile.apiKeys.lastUsed', { date: lastUsed })
: t('settings.profile.apiKeys.neverUsed')}
</div> </div>
</div> </div>
{dead ? ( {dead ? (
@@ -212,7 +228,7 @@ export const ApiKeys = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void forget(entry.id)} onClick={() => void forget(entry.id)}
title="Remove this record" title={t('settings.profile.apiKeys.forget')}
className="hover:text-red-500" className="hover:text-red-500"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -222,7 +238,7 @@ export const ApiKeys = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void revoke(entry.id)} onClick={() => void revoke(entry.id)}
title="Revoke" title={t('settings.profile.apiKeys.revoke')}
className="hover:text-red-500" className="hover:text-red-500"
> >
<Ban className="h-3.5 w-3.5" /> <Ban className="h-3.5 w-3.5" />
@@ -241,8 +257,8 @@ export const ApiKeys = () => {
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70" className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
> >
{showRevoked {showRevoked
? 'Hide revoked and expired' ? t('settings.profile.apiKeys.hideDead')
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`} : t('settings.profile.apiKeys.showDead', { count: deadKeys.length })}
</button> </button>
)} )}
</div> </div>
@@ -3,7 +3,8 @@ import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev'; import { useDock } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions'; import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock'; import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useTranslation } from '@/lib/i18n';
type DockPillProps = { type DockPillProps = {
label: string; label: string;
@@ -104,10 +105,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
}; };
export const DockSettings = () => { export const DockSettings = () => {
const { t } = useTranslation();
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone // Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken. // pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = usePermissions(); const { plugins } = usePermissions();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]); const allDockItems = useDockItems(plugins);
const { items, allItems, setItems, reset } = useDock( const { items, allItems, setItems, reset } = useDock(
allDockItems, allDockItems,
useMemo(() => defaultDockPaths(plugins), [plugins]), useMemo(() => defaultDockPaths(plugins), [plugins]),
@@ -193,10 +195,10 @@ export const DockSettings = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}> <DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
{items.length === 0 && ( {items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1"> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock {t('settings.profile.dock.dragToShow')}
</span> </span>
)} )}
{items.map((item) => ( {items.map((item) => (
@@ -216,9 +218,11 @@ export const DockSettings = () => {
))} ))}
</DropZone> </DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}> <DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
{hiddenItems.length === 0 && ( {hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.profile.dock.allVisible')}
</span>
)} )}
{hiddenItems.map((item) => ( {hiddenItems.map((item) => (
<DockPill <DockPill
@@ -235,7 +239,7 @@ export const DockSettings = () => {
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer"> <Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" /> <RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults {t('settings.profile.dock.reset')}
</Button> </Button>
</div> </div>
); );
@@ -1,45 +1,67 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales';
const LANGUAGES = [ /**
{ code: 'en', label: 'English' }, * Language names come from `Intl.DisplayNames`, not from the catalog.
{ code: 'pt', label: 'Portuguese' }, *
{ code: 'es', label: 'Spanish' }, * The runtime already knows every language's name in every locale — "German" in English, "alemão" in
{ code: 'fr', label: 'French' }, * Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for
{ code: 'de', label: 'German' }, * free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers.
{ code: 'it', label: 'Italian' }, *
{ code: 'nl', label: 'Dutch' }, * The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product
{ code: 'ru', label: 'Russian' }, * choice, not something to enumerate from the runtime.
{ code: 'zh', label: 'Chinese' }, */
{ code: 'ja', label: 'Japanese' }, const SPOKEN_LANGUAGE_TAGS = [
{ code: 'ko', label: 'Korean' }, 'en',
{ code: 'ar', label: 'Arabic' }, 'pt',
{ code: 'hi', label: 'Hindi' }, 'es',
{ code: 'tr', label: 'Turkish' }, 'fr',
{ code: 'pl', label: 'Polish' }, 'de',
{ code: 'sv', label: 'Swedish' }, 'it',
{ code: 'da', label: 'Danish' }, 'nl',
{ code: 'no', label: 'Norwegian' }, 'ru',
{ code: 'fi', label: 'Finnish' }, 'zh',
{ code: 'uk', label: 'Ukrainian' }, 'ja',
{ code: 'cs', label: 'Czech' }, 'ko',
{ code: 'ro', label: 'Romanian' }, 'ar',
{ code: 'el', label: 'Greek' }, 'hi',
{ code: 'he', label: 'Hebrew' }, 'tr',
{ code: 'th', label: 'Thai' }, 'pl',
{ code: 'vi', label: 'Vietnamese' }, 'sv',
{ code: 'id', label: 'Indonesian' }, 'da',
{ code: 'ms', label: 'Malay' }, 'no',
]; 'fi',
'uk',
'cs',
'ro',
'el',
'he',
'th',
'vi',
'id',
'ms',
] as const;
const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code; /** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */
const useLanguageName = () => {
const { locale } = useTranslation();
return useMemo(() => {
const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' });
return (code: string) => {
const name = names.of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
}, [locale]);
};
export const Languages = () => { export const Languages = () => {
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const languageName = useLanguageName();
const { spoken, default: defaultLang, translateTo } = settings.languages; const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState(''); const [addingLang, setAddingLang] = useState('');
@@ -56,12 +78,13 @@ export const Languages = () => {
const removeSpoken = (code: string) => { const removeSpoken = (code: string) => {
const next = spoken.filter((s) => s !== code); const next = spoken.filter((s) => s !== code);
const updates = { ...settings.languages, spoken: next }; const updates = { ...settings.languages, spoken: next };
if (defaultLang === code) updates.default = next[0] ?? 'en'; // `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
// language never leaves it without a valid value.
if (translateTo === code) updates.translateTo = next[0] ?? 'en'; if (translateTo === code) updates.translateTo = next[0] ?? 'en';
save(updates); save(updates);
}; };
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code)); const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
return ( return (
<div className="grid gap-5"> <div className="grid gap-5">
@@ -74,7 +97,7 @@ export const Languages = () => {
key={code} key={code}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
> >
{getLabel(code)} {languageName(code)}
{spoken.length > 1 && ( {spoken.length > 1 && (
<button <button
onClick={() => removeSpoken(code)} onClick={() => removeSpoken(code)}
@@ -94,9 +117,9 @@ export const Languages = () => {
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer" className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
> >
<option value="">{t('settings.profile.languages.addLanguage')}</option> <option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => ( {availableToAdd.map((code) => (
<option key={l.code} value={l.code}> <option key={code} value={code}>
{l.label} {languageName(code)}
</option> </option>
))} ))}
</select> </select>
@@ -104,7 +127,7 @@ export const Languages = () => {
)} )}
</div> </div>
{/* Default language */} {/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span> <span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select <select
@@ -112,9 +135,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })} onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer" className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
> >
{spoken.map((code) => ( {SHIPPED_LOCALES.map(({ code, label }) => (
<option key={code} value={code}> <option key={code} value={code}>
{getLabel(code)} {label}
</option> </option>
))} ))}
</select> </select>
@@ -129,9 +152,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })} onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer" className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
> >
{LANGUAGES.map((l) => ( {SPOKEN_LANGUAGE_TAGS.map((code) => (
<option key={l.code} value={l.code}> <option key={code} value={code}>
{l.label} {languageName(code)}
</option> </option>
))} ))}
</select> </select>
@@ -13,8 +13,10 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, type ModelOption } from 'state/useModels'; import { useUserVisibleModels, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
export const TaskDefaults = () => { export const TaskDefaults = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const models = useUserVisibleModels(); const models = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -44,9 +46,9 @@ export const TaskDefaults = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } }); await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } });
toast.success('Task defaults saved'); toast.success(t('settings.profile.models.taskDefaultsSaved'));
} catch { } catch {
toast.error('Failed to save settings'); toast.error(t('settings.profile.models.saveFailed'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -55,10 +57,10 @@ export const TaskDefaults = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Default Model</span> <span className="text-duck-dark/70">{t('settings.profile.models.defaultModel')}</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}> <Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Same as chat default" /> <SelectValue placeholder={t('settings.profile.models.sameAsChat')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <SelectContent className="z-[600] max-h-[300px]">
{modelGroups.map(({ provider, models }) => ( {modelGroups.map(({ provider, models }) => (
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm'; import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type ProfileFormState = { type ProfileFormState = {
name?: string; name?: string;
@@ -24,6 +25,7 @@ const readFileAsBase64 = (file: File): Promise<string> => {
}; };
export const UserData = () => { export const UserData = () => {
const { t } = useTranslation();
const { user, updateUser } = useAuth(); const { user, updateUser } = useAuth();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null); const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
@@ -35,12 +37,12 @@ export const UserData = () => {
if (!file) return; if (!file) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Please select an image file'); toast.error(t('settings.profile.userData.selectImage'));
return; return;
} }
if (file.size > MAX_AVATAR_SIZE) { if (file.size > MAX_AVATAR_SIZE) {
toast.error('Image must be smaller than 384KB'); toast.error(t('settings.profile.userData.imageTooLarge'));
return; return;
} }
@@ -58,11 +60,11 @@ export const UserData = () => {
name: profileForm.state.name ?? '', name: profileForm.state.name ?? '',
avatar: avatarPreview ?? user?.avatar ?? '', avatar: avatarPreview ?? user?.avatar ?? '',
}); });
toast.success('Profile updated'); toast.success(t('settings.profile.userData.updated'));
setAvatarPreview(null); setAvatarPreview(null);
} catch (ex) { } catch (ex) {
const error = ex as { message?: string }; const error = ex as { message?: string };
toast.error(error.message || 'Failed to update profile'); toast.error(error.message || t('settings.profile.userData.updateFailed'));
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
@@ -93,7 +95,7 @@ export const UserData = () => {
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4"> <form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span> <span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email" type="email"
@@ -103,7 +105,7 @@ export const UserData = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span> <span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
@@ -113,12 +115,12 @@ export const UserData = () => {
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span> <span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
<Input <Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text" type="text"
name="name" name="name"
placeholder="Your name" placeholder={t('settings.profile.userData.namePlaceholder')}
autoComplete="name" autoComplete="name"
/> />
</Label> </Label>
@@ -128,7 +130,7 @@ export const UserData = () => {
disabled={isUpdating} disabled={isUpdating}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100" className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
> >
{isUpdating ? 'Saving...' : 'Save'} {isUpdating ? t('common.saving') : t('common.save')}
</Button> </Button>
</form> </form>
</div> </div>
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
type VoiceGroup = { label: string; voices: string[] }; type VoiceGroup = { label: string; voices: string[] };
@@ -26,6 +27,7 @@ type TtsConfig = {
const SERVER_DEFAULT = '__server_default__'; const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => { export const VoicePreference = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]); const [voices, setVoices] = useState<string[]>([]);
@@ -128,13 +130,13 @@ export const VoicePreference = () => {
<div className="grid gap-4"> <div className="grid gap-4">
<Label className="grid gap-2"> <Label className="grid gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span> <span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
<button <button
type="button" type="button"
onClick={handleRefresh} onClick={handleRefresh}
disabled={loading} disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40" className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices" title={t('settings.profile.voice.refresh')}
> >
<RefreshCw <RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
@@ -144,7 +146,7 @@ export const VoicePreference = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}> <Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground"> <SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" /> <SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]"> <SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}> <SelectItem value={SERVER_DEFAULT}>
@@ -175,7 +177,7 @@ export const VoicePreference = () => {
onClick={handleListen} onClick={handleListen}
disabled={listening === 'loading' || !serverConfig} disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default" className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'} title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
> >
{listening === 'loading' ? ( {listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" /> <Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
@@ -186,9 +188,7 @@ export const VoicePreference = () => {
)} )}
</button> </button>
</div> </div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40"> <span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
</Label> </Label>
</div> </div>
); );
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
const BASE_PATH = '/settings/profile'; const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [ const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> }, {
key: 'profile',
icon: User,
titleKey: 'settings.sections.profile.label',
descriptionKey: 'settings.sections.profile.description',
content: <UserData />,
},
{ {
key: 'change-password', key: 'change-password',
icon: Lock, icon: Lock,
title: 'Change Password', titleKey: 'settings.sections.changePassword.label',
description: 'Update your password', descriptionKey: 'settings.sections.changePassword.description',
content: <ChangePassword />, content: <ChangePassword />,
}, },
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an // Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
@@ -29,29 +35,29 @@ const sections: SettingsSection[] = [
{ {
key: 'api-keys', key: 'api-keys',
icon: KeyRound, icon: KeyRound,
title: 'API keys', titleKey: 'settings.sections.apiKeys.label',
description: 'Keys that sign in as you, revocable one at a time', descriptionKey: 'settings.sections.apiKeys.description',
content: <ApiKeys />, content: <ApiKeys />,
}, },
{ {
key: 'languages', key: 'languages',
icon: Globe, icon: Globe,
title: 'Languages', titleKey: 'settings.sections.languages.label',
description: 'Spoken, default, and translation', descriptionKey: 'settings.sections.languages.description',
content: <Languages />, content: <Languages />,
}, },
{ {
key: 'voice', key: 'voice',
icon: Volume2, icon: Volume2,
title: 'Voice', titleKey: 'settings.sections.voice.label',
description: 'Text-to-speech voice preference', descriptionKey: 'settings.sections.voice.description',
content: <VoicePreference />, content: <VoicePreference />,
}, },
{ {
key: 'dock', key: 'dock',
icon: LayoutGrid, icon: LayoutGrid,
title: 'Dock', titleKey: 'settings.sections.dock.label',
description: 'Choose and reorder dock items', descriptionKey: 'settings.sections.dock.description',
content: <DockSettings />, content: <DockSettings />,
}, },
]; ];
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
const { Sidebar, Content } = createSettingsPanelComponents({ const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: User, sidebarIcon: User,
sidebarLabel: 'Profile', sidebarLabelKey: 'settings.nav.profile',
sections, sections,
}); });
@@ -3,17 +3,24 @@ import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router'; import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
/**
* Sections are declared as module-level consts holding JSX `content`, so they cannot call a hook —
* they carry catalog KEYS and the renderers below resolve them. `titleKey` rather than `titleKeyName`
* and deliberately not `title_key`: docs-check-settings.ts greps `key:\s*'…'` for route segments, and
* a capital K keeps `titleKey` out of that match.
*/
export type SettingsSection = { export type SettingsSection = {
key: string; key: string;
icon: LucideIcon; icon: LucideIcon;
title: string; titleKey: AppTranslationKey;
description: string; descriptionKey: AppTranslationKey;
content: ReactNode; content: ReactNode;
}; };
export type SettingsSectionGroup = { export type SettingsSectionGroup = {
label: string; labelKey: AppTranslationKey;
icon: LucideIcon; icon: LucideIcon;
sections: SettingsSection[]; sections: SettingsSection[];
}; };
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
type SettingsSidebarProps = { type SettingsSidebarProps = {
basePath: string; basePath: string;
icon: LucideIcon; icon: LucideIcon;
label: string; labelKey: AppTranslationKey;
sections: SettingsSection[]; sections: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
hideHeader?: boolean; hideHeader?: boolean;
@@ -32,44 +39,51 @@ type SettingsSidebarProps = {
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global * the browser knows it has been visited, and `isActive` comes from the router instead of from a global
* that the content pane had to be told about separately. * that the content pane had to be told about separately.
*/ */
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => ( const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => {
<NavLink const { t } = useTranslation();
to={to} return (
className={({ isActive }) => <NavLink
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${ to={to}
isActive className={({ isActive }) =>
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' `flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground' isActive
}` ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
} : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
> }`
{({ isActive }) => ( }
<> >
<section.icon {({ isActive }) => (
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} <>
/> <section.icon
<div className="min-w-0 flex-1"> className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
<div className="text-sm font-medium truncate">{section.title}</div> />
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div> <div className="min-w-0 flex-1">
</div> <div className="text-sm font-medium truncate">{t(section.titleKey)}</div>
</> <div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
)} {t(section.descriptionKey)}
</NavLink> </div>
); </div>
</>
)}
</NavLink>
);
};
export const SettingsSidebar = ({ export const SettingsSidebar = ({
basePath, basePath,
icon: Icon, icon: Icon,
label, labelKey,
sections, sections,
groups, groups,
hideHeader, hideHeader,
}: SettingsSidebarProps) => { }: SettingsSidebarProps) => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { t } = useTranslation();
const query = search.toLowerCase(); const query = search.toLowerCase();
// Matched against the RESOLVED words, so searching works in the language on screen.
const matchesSearch = (s: SettingsSection) => const matchesSearch = (s: SettingsSection) =>
s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query); t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
return ( return (
<div className="flex flex-col h-full overflow-y-auto"> <div className="flex flex-col h-full overflow-y-auto">
@@ -77,13 +91,13 @@ export const SettingsSidebar = ({
<div className="p-3 pb-2"> <div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"> <div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{label} {t(labelKey)}
</div> </div>
</div> </div>
)} )}
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Input <Input
placeholder="Search..." placeholder={t('settings.searchPlaceholder')}
value={search} value={search}
onChange={(ev) => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
const filtered = group.sections.filter(matchesSearch); const filtered = group.sections.filter(matchesSearch);
if (filtered.length === 0) return null; if (filtered.length === 0) return null;
return ( return (
<div key={group.label}> <div key={group.labelKey}>
<div className="flex items-center gap-2 px-3 pt-3 pb-1"> <div className="flex items-center gap-2 px-3 pt-3 pb-1">
<group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" /> <group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30"> <span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
{group.label} {t(group.labelKey)}
</span> </span>
</div> </div>
{filtered.map((s) => ( {filtered.map((s) => (
@@ -121,6 +135,7 @@ type SettingsContentProps = {
}; };
export const SettingsContent = ({ sections }: SettingsContentProps) => { export const SettingsContent = ({ sections }: SettingsContentProps) => {
const { t } = useTranslation();
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key. // The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
// They agree via the URL now, so neither has to be told about the other — and neither can be the one // They agree via the URL now, so neither has to be told about the other — and neither can be the one
// that seeds the slot, which is what made the old defaulting order matter (see §5.6). // that seeds the slot, which is what made the old defaulting order matter (see §5.6).
@@ -130,7 +145,7 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
if (!section) { if (!section) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p> <p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
</div> </div>
); );
} }
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
<div className="h-full overflow-y-auto p-6"> <div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" /> <section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2> <h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{t(section.titleKey)}</h2>
</div> </div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p> <p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
<div>{section.content}</div> <div>{section.content}</div>
</div> </div>
); );
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
type CreateSettingsPanelParams = { type CreateSettingsPanelParams = {
basePath: string; basePath: string;
sidebarIcon: LucideIcon; sidebarIcon: LucideIcon;
sidebarLabel: string; sidebarLabelKey: AppTranslationKey;
sections?: SettingsSection[]; sections?: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
}; };
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
export const createSettingsPanelComponents = ({ export const createSettingsPanelComponents = ({
basePath, basePath,
sidebarIcon, sidebarIcon,
sidebarLabel, sidebarLabelKey,
sections = [], sections = [],
groups, groups,
}: CreateSettingsPanelParams) => { }: CreateSettingsPanelParams) => {
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
<SettingsSidebar <SettingsSidebar
basePath={basePath} basePath={basePath}
icon={sidebarIcon} icon={sidebarIcon}
label={sidebarLabel} labelKey={sidebarLabelKey}
sections={allSections} sections={allSections}
groups={groups} groups={groups}
/> />
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
const groups: SettingsSectionGroup[] = [ const groups: SettingsSectionGroup[] = [
{ {
label: 'AI', labelKey: 'settings.groups.ai',
icon: Bot, icon: Bot,
sections: [ sections: [
{ {
key: 'tts', key: 'tts',
icon: Volume2, icon: Volume2,
title: 'Text to Speech', titleKey: 'settings.sections.tts.label',
description: 'TTS provider and voice', descriptionKey: 'settings.sections.tts.description',
content: <TTSSection />, content: <TTSSection />,
}, },
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> }, {
key: 'stt',
icon: Mic,
titleKey: 'settings.sections.stt.label',
descriptionKey: 'settings.sections.stt.description',
content: <STTSection />,
},
{ {
key: 'ocr', key: 'ocr',
icon: ScanText, icon: ScanText,
title: 'OCR', titleKey: 'settings.sections.ocr.label',
description: 'Vision model for text extraction', descriptionKey: 'settings.sections.ocr.description',
content: <OCRSection />, content: <OCRSection />,
}, },
], ],
}, },
{ {
label: 'Maintenance', labelKey: 'settings.groups.maintenance',
icon: Hammer, icon: Hammer,
sections: [ sections: [
{ {
key: 'maintenance', key: 'maintenance',
icon: Hammer, icon: Hammer,
title: 'Rebuild & restart', titleKey: 'settings.sections.maintenance.label',
description: 'Load changes without a terminal', descriptionKey: 'settings.sections.maintenance.description',
content: <MaintenanceSection />, content: <MaintenanceSection />,
}, },
], ],
}, },
{ {
label: 'Email', labelKey: 'settings.groups.email',
icon: Mail, icon: Mail,
sections: [ sections: [
{ key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: <SMTPSection /> }, {
key: 'smtp',
icon: Mail,
titleKey: 'settings.sections.smtp.label',
descriptionKey: 'settings.sections.smtp.description',
content: <SMTPSection />,
},
], ],
}, },
]; ];
@@ -59,7 +71,7 @@ const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({ const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: Settings, sidebarIcon: Settings,
sidebarLabel: 'System', sidebarLabelKey: 'settings.nav.system',
groups, groups,
}); });
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
{ {
key: 'users', key: 'users',
icon: Users, icon: Users,
title: 'Accounts', titleKey: 'settings.sections.accounts.label',
description: 'Who has access, and as what', descriptionKey: 'settings.sections.accounts.description',
content: <UsersSection />, content: <UsersSection />,
}, },
{ {
key: 'permissions', key: 'permissions',
icon: ShieldCheck, icon: ShieldCheck,
title: 'Permissions', titleKey: 'settings.sections.permissions.label',
description: 'What each role can reach', descriptionKey: 'settings.sections.permissions.description',
content: <PermissionsSection />, content: <PermissionsSection />,
}, },
]; ];
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({ const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: UserCog, sidebarIcon: UserCog,
sidebarLabel: 'Users', sidebarLabelKey: 'settings.nav.users',
sections, sections,
}); });
+30 -5
View File
@@ -1,11 +1,30 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { TranslationMap } from 'i18n'; import type { TranslationMap } from 'i18n';
import { I18nProvider } from 'i18n'; import { I18nProvider, localeDirection } from 'i18n';
import { panelCatalogs } from 'officerdev';
import { uiCatalogs } from '@/components/locales';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import en from '../locales/en.json'; import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
import pt from '../locales/pt.json'; import appEnGB from '../locales/en-GB.json';
import appPtPT from '../locales/pt-PT.json';
const translations: TranslationMap = { en, pt }; const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
/**
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
*/
const translations: TranslationMap = Object.fromEntries(
SHIPPED_LOCALES.map(({ code }) => [
code,
{
app: appCatalogs[code] ?? {},
panel: panelCatalogs[code] ?? {},
ui: uiCatalogs[code] ?? {},
},
]),
);
type I18nBridgeProps = { type I18nBridgeProps = {
children: ReactNode; children: ReactNode;
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
const { settings } = useSettings(); const { settings } = useSettings();
const locale = settings.languages.default; const locale = settings.languages.default;
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
// RTL catalog needs no second pass over the tree.
useEffect(() => {
document.documentElement.dir = localeDirection(locale);
}, [locale]);
return ( return (
<I18nProvider translations={translations} locale={locale}> <I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
{children} {children}
</I18nProvider> </I18nProvider>
); );
+23 -3
View File
@@ -1,12 +1,32 @@
import type { TranslationKeys } from 'i18n'; import type { TranslationKeys } from 'i18n';
import type { InterpolationParams } from 'i18n'; import type { InterpolationParams } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n'; import { useTranslation as useTranslationBase } from 'i18n';
import type en from '../locales/en.json'; import type en from '../locales/en-GB.json';
import type uiEn from '@/components/locales/en-GB.json';
type AppTranslationKey = TranslationKeys<typeof en>; type AppTranslationKey = TranslationKeys<typeof en>;
type UiTranslationKey = TranslationKeys<typeof uiEn>;
/**
* `t` for this workspace's own catalog, `tUi` for the shared-component one.
*
* Both are here rather than in two imports because "Cancel", "Close" and "Search" recur across the
* shell and genuinely belong to `ui.*` — phase 1 put them there so phases 2 and 3 could reach for them
* instead of inventing a second key for the same word. Anything feature-specific stays in `app.*`:
* per D6 a `ui.*` key may not name a feature.
*
* Both are memoized. An unstable `t` is unusable in a dependency array, and this repo has already lost
* days to exactly that shape — see platform/CLAUDE.md on `useClient()` and the Jellyfin player.
*/
export const useTranslation = () => { export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase(); const { t: baseT, locale } = useTranslationBase();
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
return { t, locale }; const t = useCallback((key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params), [baseT]);
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
return { t, tUi, locale };
}; };
export type { AppTranslationKey };
+14
View File
@@ -0,0 +1,14 @@
/**
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
*/
export type ShippedLocale = { code: string; label: string };
export const SHIPPED_LOCALES: ShippedLocale[] = [
{ code: 'en-GB', label: 'English (UK)' },
{ code: 'pt-PT', label: 'Português (Portugal)' },
];
export const FALLBACK_LOCALE = 'en-GB';
+348
View File
@@ -0,0 +1,348 @@
{
"common": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel"
},
"settings": {
"profile": {
"userData": {
"email": "Email",
"username": "Username",
"name": "Name",
"namePlaceholder": "Your name",
"updated": "Profile updated",
"updateFailed": "Failed to update profile",
"selectImage": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB"
},
"apiKeys": {
"intro": "An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as",
"loading": "Loading keys…",
"mintedTitle": "Key for “{{name}}” — shown once",
"savedIt": "I’ve saved it",
"copyKey": "Copy key",
"copied": "Key copied",
"copyFailed": "Could not copy — select and copy manually",
"newKey": "New key",
"namePlaceholder": "iPhone, Music app, laptop CLI…",
"create": "Create",
"createFailed": "Could not create the key",
"noneYet": "No keys yet.",
"noneInUse": "No keys in use.",
"revoked": "revoked",
"expired": "expired",
"lastUsed": "last used {{date}}",
"neverUsed": "never used",
"forget": "Remove this record",
"forgetFailed": "Could not remove it",
"revoke": "Revoke",
"revokeDone": "Revoked — anything using that key is signed out",
"revokeFailed": "Could not revoke",
"hideDead": "Hide revoked and expired",
"showDead": {
"one": "Show {{count}} revoked or expired key",
"other": "Show {{count}} revoked or expired keys"
}
},
"voice": {
"label": "Voice",
"refresh": "Refresh voices",
"serverDefault": "Server default",
"hint": "Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.",
"stop": "Stop",
"listen": "Listen"
},
"dock": {
"visible": "Visible",
"hidden": "Hidden",
"dragToShow": "Drag items here to show in dock",
"allVisible": "All items visible",
"reset": "Reset to defaults"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
},
"changePassword": {
"currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password",
"newPasswordLabel": "New Password",
"newPasswordPlaceholder": "New password",
"confirmPasswordLabel": "Confirm New Password",
"confirmPasswordPlaceholder": "Confirm new password",
"changeButton": "Change Password",
"changingButton": "Changing...",
"passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password"
},
"models": {
"defaultModel": "Default Model",
"sameAsChat": "Same as chat default",
"taskDefaultsSaved": "Task defaults saved",
"saveFailed": "Failed to save settings",
"defaultChat": "Default Chat Model",
"defaultChatHint": "Used when starting a new chat from the home screen",
"defaultProject": "Default Project Model",
"defaultProjectHint": "Used when starting a new chat inside a project dashboard",
"defaultTask": "Default Task Model",
"defaultTaskHint": "Used when running tasks from the file browser",
"aiDefaultsSaved": "AI model defaults saved"
}
},
"sections": {
"profile": {
"label": "Profile",
"hint": "avatar, name, email",
"description": "Avatar, name, and email"
},
"changePassword": {
"label": "Change password",
"description": "Update your password"
},
"apiKeys": {
"label": "API keys",
"hint": "keys that sign in as you",
"description": "Keys that sign in as you, revocable one at a time"
},
"languages": {
"label": "Languages",
"hint": "spoken and translation",
"description": "Spoken, default, and translation"
},
"voice": {
"label": "Voice",
"hint": "text-to-speech",
"description": "Text-to-speech voice preference"
},
"dock": {
"label": "Dock",
"hint": "choose and reorder",
"description": "Choose and reorder dock items"
},
"myModels": {
"label": "My Models",
"description": "Show or hide models for yourself"
},
"channelModels": {
"label": "Channel Models",
"description": "Models reachable by non-owner accounts"
},
"defaultModels": {
"label": "Default Models",
"description": "Default models for chat, projects, and tasks"
},
"providers": {
"label": "Providers",
"hint": "harnesses",
"description": "Remote and local AI providers"
},
"tts": {
"label": "Text to Speech",
"description": "TTS provider and voice"
},
"stt": {
"label": "Speech to Text",
"description": "Whisper server URL"
},
"ocr": {
"label": "OCR",
"description": "Vision model for text extraction"
},
"maintenance": {
"label": "Rebuild & restart",
"hint": "maintenance",
"description": "Load changes without a terminal"
},
"smtp": {
"label": "SMTP",
"hint": "outgoing mail",
"description": "Email delivery provider"
},
"googleOauth": {
"label": "Google OAuth",
"description": "Client ID and secret for Google APIs"
},
"apify": {
"label": "Apify",
"description": "API token for web scraping actors"
},
"davSync": {
"label": "Calendar & Contacts sync",
"hint": "app passwords",
"description": "Per-device passwords for CalDAV/CardDAV clients"
},
"accounts": {
"label": "Accounts",
"hint": "users",
"description": "Who has access, and as what"
},
"permissions": {
"label": "Permissions",
"description": "What each role can reach"
}
},
"searchPlaceholder": "Search settings…",
"selectSection": "Select a section",
"groups": {
"models": "Models",
"defaults": "Defaults",
"providers": "Providers",
"ai": "AI",
"maintenance": "Maintenance",
"email": "Email"
},
"nav": {
"profile": "Profile",
"ai": "AI",
"system": "System",
"integrations": "Integrations",
"users": "Users"
},
"ai": {
"noModels": "No models available. Configure API keys in Providers.",
"dragToShow": "Drag models here to show",
"allVisible": "All models visible",
"dragToEnable": "Drag models here to enable",
"allEnabled": "All models enabled",
"visible": "Visible",
"hidden": "Hidden",
"enabled": "Enabled",
"disabled": "Disabled",
"refresh": "Refresh models from providers",
"refreshed": "Models refreshed",
"refreshFailed": "Failed to refresh models"
},
"integrations": {
"enterprise": "Enterprise",
"personal": "Personal"
}
},
"header": {
"userMenu": {
"profile": "Profile",
"systemSettings": "System Settings",
"signOut": "Sign Out",
"ai": "AI",
"integrations": "Integrations",
"userManagement": "User Management"
},
"tabName": "Tab name",
"tabNameHint": "Click to rename this tab — clear it to go back to the page name",
"logoAlt": "Officer",
"navigation": "Navigation",
"jobsRunning": "Running job",
"jobsNothingRunning": "Nothing running",
"jobsQueued": "Queued jobs"
},
"nav": {
"home": "Home",
"files": "Files",
"chat": "Chat",
"commandHq": "Command HQ",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
"browser": "Browser",
"monitor": "Monitor",
"activity": "Activity",
"dashboards": "Dashboards",
"sendOff": "SendOff",
"calendar": "Calendar",
"contacts": "Contacts",
"appStore": "App store",
"marketplace": "Marketplace"
},
"theme": {
"pillTitle": "Theme: {{name}} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f {{starAction}}",
"pillLabel": "Theme: {{name}}",
"star": "star",
"unstar": "unstar",
"cycleHint": "ctrl+shift+←/→ cycle · ctrl+shift+space browse",
"everyShortcutHint": "ctrl+? for every shortcut",
"favouritesOnly": "Favourites only ({{count}})",
"favourites": "Favourites ({{count}})",
"noFavourites": "No favourites yet — star one with the button on its row.",
"starNamed": "Star {{name}}",
"unstarNamed": "Unstar {{name}}",
"addToFavourites": "Add to favourites",
"removeFromFavourites": "Remove from favourites",
"addToFavouritesKey": "Add to favourites (f)",
"removeFromFavouritesKey": "Remove from favourites (f)",
"active": "active",
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel"
},
"spotlight": {
"placeholder": "Go to…",
"noResults": "Nothing matches “{{query}}”."
},
"shortcutsModal": {
"title": "Keyboard shortcuts",
"hint": "Ctrl+? · Esc to close"
},
"screenError": {
"title": "This screen failed to render",
"description": "Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are untouched.",
"unknownError": "Unknown error",
"tryAgain": "Try again",
"reload": "Reload",
"goHome": "Go home",
"resetHint": "If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen’s panes back to their defaults.",
"dashboardsSafe": "Your dashboards are not touched.",
"resetting": "Resetting…",
"resetConfirm": "Yes — reset every screen layout",
"reset": "Reset screen layouts"
},
"rescan": {
"label": "Rescan items",
"title": "Rescan officer-items (apply changes without restarting)",
"failed": "Failed to rescan items",
"done": "Rescanned items — {{summary}}",
"counts": {
"tasks": {
"one": "{{count}} task",
"other": "{{count}} tasks"
},
"agents": {
"one": "{{count}} agent",
"other": "{{count}} agents"
},
"tools": {
"one": "{{count}} tool",
"other": "{{count}} tools"
},
"skills": {
"one": "{{count}} skill",
"other": "{{count}} skills"
},
"processes": {
"one": "{{count}} process",
"other": "{{count}} processes"
},
"affordances": {
"one": "{{count}} affordance",
"other": "{{count}} affordances"
}
},
"newChatsOnly": "Applies to new chats — an open one keeps what it started with.",
"noDescription": {
"one": "{{count}} skill has no description, so no agent is offered it.",
"other": "{{count}} skills have no description, so no agent is offered them."
}
},
"bugReport": {
"label": "Report a bug",
"title": "Report a Bug",
"describePlaceholder": "Describe what went wrong...",
"screenshot": "Screenshot",
"noScreenshot": "No screenshot captured",
"collectedNote": "Console logs, browser info, and recent API errors are collected automatically.",
"submitted": "Bug report submitted",
"failed": "Failed to submit bug report",
"submit": "Submit"
}
}
-68
View File
@@ -1,68 +0,0 @@
{
"common": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel"
},
"settings": {
"profile": {
"searchPlaceholder": "Search settings...",
"sections": {
"profileTitle": "Profile",
"profileDescription": "Update your name and avatar.",
"changePasswordTitle": "Change Password",
"changePasswordDescription": "Update your account password.",
"tasksTitle": "Tasks",
"tasksDescription": "Default model for file browser tasks.",
"languagesTitle": "Languages",
"languagesDescription": "Set your spoken languages and translation preferences."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Name",
"namePlaceholder": "Your name",
"profileUpdated": "Profile updated",
"profileUpdateFailed": "Failed to update profile",
"selectImageFile": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB"
},
"changePassword": {
"currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password",
"newPasswordLabel": "New Password",
"newPasswordPlaceholder": "New password",
"confirmPasswordLabel": "Confirm New Password",
"confirmPasswordPlaceholder": "Confirm new password",
"changeButton": "Change Password",
"changingButton": "Changing...",
"passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
}
}
},
"header": {
"userMenu": {
"profile": "Profile",
"systemSettings": "System Settings",
"signOut": "Sign Out"
}
},
"dock": {
"chat": "Chat",
"files": "Files",
"terminal": "Terminal",
"plans": "Plans",
"skills": "Skills",
"tasks": "Tasks",
"processes": "Processes",
"logs": "Logs"
}
}
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findMissingKeys } from 'i18n';
import en from './en-GB.json';
import ptPT from './pt-PT.json';
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
// not a warning — there is no regional fallback to paper over the gap.
describe('officer-web (app.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
+348
View File
@@ -0,0 +1,348 @@
{
"common": {
"save": "Guardar",
"saving": "A guardar...",
"cancel": "Cancelar"
},
"settings": {
"profile": {
"userData": {
"email": "Email",
"username": "Nome de utilizador",
"name": "Nome",
"namePlaceholder": "O seu nome",
"updated": "Perfil atualizado",
"updateFailed": "Falha ao atualizar o perfil",
"selectImage": "Selecione um ficheiro de imagem",
"imageTooLarge": "A imagem tem de ser menor do que 384KB"
},
"apiKeys": {
"intro": "Uma chave de API inicia sessão como você, sem palavra-passe e sem expirar. Uma chave funciona em todo o lado — uma chave não está associada a uma aplicação. Dar a cada dispositivo a sua própria chave serve apenas para poder cortar uma sem mexer nas restantes. Envie-a como",
"loading": "A carregar chaves…",
"mintedTitle": "Chave para “{{name}}” — mostrada uma única vez",
"savedIt": "Já a guardei",
"copyKey": "Copiar chave",
"copied": "Chave copiada",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"newKey": "Nova chave",
"namePlaceholder": "iPhone, aplicação Música, CLI do portátil…",
"create": "Criar",
"createFailed": "Não foi possível criar a chave",
"noneYet": "Ainda não há chaves.",
"noneInUse": "Nenhuma chave em uso.",
"revoked": "revogada",
"expired": "expirada",
"lastUsed": "usada pela última vez em {{date}}",
"neverUsed": "nunca usada",
"forget": "Remover este registo",
"forgetFailed": "Não foi possível remover",
"revoke": "Revogar",
"revokeDone": "Revogada — tudo o que usava essa chave foi desligado",
"revokeFailed": "Não foi possível revogar",
"hideDead": "Ocultar revogadas e expiradas",
"showDead": {
"one": "Mostrar {{count}} chave revogada ou expirada",
"other": "Mostrar {{count}} chaves revogadas ou expiradas"
}
},
"voice": {
"label": "Voz",
"refresh": "Atualizar vozes",
"serverDefault": "Predefinição do servidor",
"hint": "Escolha uma voz para a conversão de texto em fala. Deixe na predefinição do servidor para usar a voz configurada pelo administrador.",
"stop": "Parar",
"listen": "Ouvir"
},
"dock": {
"visible": "Visíveis",
"hidden": "Ocultos",
"dragToShow": "Arraste itens para aqui para os mostrar no dock",
"allVisible": "Todos os itens visíveis",
"reset": "Restaurar predefinições"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
},
"changePassword": {
"currentPasswordLabel": "Palavra-passe Atual",
"currentPasswordPlaceholder": "Palavra-passe atual",
"newPasswordLabel": "Nova Palavra-passe",
"newPasswordPlaceholder": "Nova palavra-passe",
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
"changeButton": "Alterar Palavra-passe",
"changingButton": "A alterar...",
"passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe"
},
"models": {
"defaultModel": "Modelo predefinido",
"sameAsChat": "Igual à predefinição do chat",
"taskDefaultsSaved": "Predefinições de tarefas guardadas",
"saveFailed": "Falha ao guardar as definições",
"defaultChat": "Modelo predefinido do chat",
"defaultChatHint": "Usado ao iniciar um novo chat a partir do ecrã inicial",
"defaultProject": "Modelo predefinido do projeto",
"defaultProjectHint": "Usado ao iniciar um novo chat dentro de um painel de projeto",
"defaultTask": "Modelo predefinido das tarefas",
"defaultTaskHint": "Usado ao executar tarefas a partir do explorador de ficheiros",
"aiDefaultsSaved": "Predefinições de modelos de IA guardadas"
}
},
"sections": {
"profile": {
"label": "Perfil",
"hint": "avatar, nome, email",
"description": "Avatar, nome e email"
},
"changePassword": {
"label": "Alterar palavra-passe",
"description": "Atualize a sua palavra-passe"
},
"apiKeys": {
"label": "Chaves de API",
"hint": "chaves que iniciam sessão como você",
"description": "Chaves que iniciam sessão como você, revogáveis uma a uma"
},
"languages": {
"label": "Idiomas",
"hint": "falados e tradução",
"description": "Falados, predefinido e tradução"
},
"voice": {
"label": "Voz",
"hint": "conversão de texto em fala",
"description": "Preferência de voz para conversão de texto em fala"
},
"dock": {
"label": "Dock",
"hint": "escolher e reordenar",
"description": "Escolher e reordenar os itens do dock"
},
"myModels": {
"label": "Os meus modelos",
"description": "Mostrar ou ocultar modelos para si"
},
"channelModels": {
"label": "Modelos do canal",
"description": "Modelos acessíveis a contas que não são do proprietário"
},
"defaultModels": {
"label": "Modelos predefinidos",
"description": "Modelos predefinidos para chat, projetos e tarefas"
},
"providers": {
"label": "Fornecedores",
"hint": "harnesses",
"description": "Fornecedores de IA remotos e locais"
},
"tts": {
"label": "Texto para fala",
"description": "Fornecedor de TTS e voz"
},
"stt": {
"label": "Fala para texto",
"description": "URL do servidor Whisper"
},
"ocr": {
"label": "OCR",
"description": "Modelo de visão para extração de texto"
},
"maintenance": {
"label": "Reconstruir e reiniciar",
"hint": "manutenção",
"description": "Carregar alterações sem um terminal"
},
"smtp": {
"label": "SMTP",
"hint": "correio a enviar",
"description": "Fornecedor de entrega de email"
},
"googleOauth": {
"label": "Google OAuth",
"description": "Client ID e secret para as APIs Google"
},
"apify": {
"label": "Apify",
"description": "Token de API para atores de web scraping"
},
"davSync": {
"label": "Sincronização de Calendário e Contactos",
"hint": "palavras-passe de aplicação",
"description": "Palavras-passe por dispositivo para clientes CalDAV/CardDAV"
},
"accounts": {
"label": "Contas",
"hint": "utilizadores",
"description": "Quem tem acesso, e como"
},
"permissions": {
"label": "Permissões",
"description": "O que cada função pode alcançar"
}
},
"searchPlaceholder": "Pesquisar definições…",
"selectSection": "Selecione uma secção",
"groups": {
"models": "Modelos",
"defaults": "Predefinições",
"providers": "Fornecedores",
"ai": "IA",
"maintenance": "Manutenção",
"email": "Email"
},
"nav": {
"profile": "Perfil",
"ai": "IA",
"system": "Sistema",
"integrations": "Integrações",
"users": "Utilizadores"
},
"ai": {
"noModels": "Nenhum modelo disponível. Configure as chaves de API em Fornecedores.",
"dragToShow": "Arraste modelos para aqui para os mostrar",
"allVisible": "Todos os modelos visíveis",
"dragToEnable": "Arraste modelos para aqui para os ativar",
"allEnabled": "Todos os modelos ativados",
"visible": "Visíveis",
"hidden": "Ocultos",
"enabled": "Ativados",
"disabled": "Desativados",
"refresh": "Atualizar modelos a partir dos fornecedores",
"refreshed": "Modelos atualizados",
"refreshFailed": "Falha ao atualizar modelos"
},
"integrations": {
"enterprise": "Empresa",
"personal": "Pessoal"
}
},
"header": {
"userMenu": {
"profile": "Perfil",
"systemSettings": "Definições do Sistema",
"signOut": "Terminar Sessão",
"ai": "IA",
"integrations": "Integrações",
"userManagement": "Gestão de Utilizadores"
},
"tabName": "Nome do separador",
"tabNameHint": "Clique para renomear este separador — limpe-o para voltar ao nome da página",
"logoAlt": "Officer",
"navigation": "Navegação",
"jobsRunning": "Trabalho em execução",
"jobsNothingRunning": "Nada em execução",
"jobsQueued": "Trabalhos em fila"
},
"nav": {
"home": "Início",
"files": "Ficheiros",
"chat": "Chat",
"commandHq": "Command HQ",
"editor": "Editor",
"jobs": "Trabalhos",
"terminal": "Terminal",
"browser": "Navegador",
"monitor": "Monitor",
"activity": "Atividade",
"dashboards": "Painéis",
"sendOff": "SendOff",
"calendar": "Calendário",
"contacts": "Contactos",
"appStore": "Loja de aplicações",
"marketplace": "Marketplace"
},
"theme": {
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ percorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
"pillLabel": "Tema: {{name}}",
"star": "marcar",
"unstar": "desmarcar",
"cycleHint": "ctrl+shift+←/→ percorrer · ctrl+shift+space explorar",
"everyShortcutHint": "ctrl+? para todos os atalhos",
"favouritesOnly": "Apenas favoritos ({{count}})",
"favourites": "Favoritos ({{count}})",
"noFavourites": "Ainda não há favoritos — marque um com o botão na sua linha.",
"starNamed": "Marcar {{name}}",
"unstarNamed": "Desmarcar {{name}}",
"addToFavourites": "Adicionar aos favoritos",
"removeFromFavourites": "Remover dos favoritos",
"addToFavouritesKey": "Adicionar aos favoritos (f)",
"removeFromFavouritesKey": "Remover dos favoritos (f)",
"active": "ativo",
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar"
},
"spotlight": {
"placeholder": "Ir para…",
"noResults": "Nada corresponde a “{{query}}”."
},
"shortcutsModal": {
"title": "Atalhos de teclado",
"hint": "Ctrl+? · Esc para fechar"
},
"screenError": {
"title": "Este ecrã não conseguiu ser apresentado",
"description": "Nada foi perdido — esta é uma falha de apresentação, não de dados. As transcrições do agente, os trabalhos e os ficheiros estão intactos.",
"unknownError": "Erro desconhecido",
"tryAgain": "Tentar novamente",
"reload": "Recarregar",
"goHome": "Ir para o início",
"resetHint": "Se falhar novamente no mesmo local, a causa habitual é um layout de painéis guardado. Reiniciar devolve os painéis de todos os ecrãs às predefinições.",
"dashboardsSafe": "Os seus painéis não são afetados.",
"resetting": "A reiniciar…",
"resetConfirm": "Sim — reiniciar o layout de todos os ecrãs",
"reset": "Reiniciar layouts dos ecrãs"
},
"rescan": {
"label": "Reanalisar itens",
"title": "Reanalisar officer-items (aplicar alterações sem reiniciar)",
"failed": "Falha ao reanalisar itens",
"done": "Itens reanalisados — {{summary}}",
"counts": {
"tasks": {
"one": "{{count}} tarefa",
"other": "{{count}} tarefas"
},
"agents": {
"one": "{{count}} agente",
"other": "{{count}} agentes"
},
"tools": {
"one": "{{count}} ferramenta",
"other": "{{count}} ferramentas"
},
"skills": {
"one": "{{count}} skill",
"other": "{{count}} skills"
},
"processes": {
"one": "{{count}} processo",
"other": "{{count}} processos"
},
"affordances": {
"one": "{{count}} affordance",
"other": "{{count}} affordances"
}
},
"newChatsOnly": "Aplica-se a novos chats — um chat aberto mantém o que tinha ao iniciar.",
"noDescription": {
"one": "{{count}} skill não tem descrição, pelo que não é oferecida a nenhum agente.",
"other": "{{count}} skills não têm descrição, pelo que não são oferecidas a nenhum agente."
}
},
"bugReport": {
"label": "Comunicar um erro",
"title": "Comunicar um erro",
"describePlaceholder": "Descreva o que aconteceu...",
"screenshot": "Captura de ecrã",
"noScreenshot": "Nenhuma captura de ecrã obtida",
"collectedNote": "Os registos da consola, a informação do navegador e os erros recentes da API são recolhidos automaticamente.",
"submitted": "Comunicação de erro enviada",
"failed": "Falha ao enviar a comunicação de erro",
"submit": "Enviar"
}
}
-68
View File
@@ -1,68 +0,0 @@
{
"common": {
"save": "Guardar",
"saving": "A guardar...",
"cancel": "Cancelar"
},
"settings": {
"profile": {
"searchPlaceholder": "Pesquisar definições...",
"sections": {
"profileTitle": "Perfil",
"profileDescription": "Atualiza o teu nome e avatar.",
"changePasswordTitle": "Alterar Palavra-passe",
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
"tasksTitle": "Tarefas",
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
"languagesTitle": "Idiomas",
"languagesDescription": "Define os teus idiomas e preferências de tradução."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Nome",
"namePlaceholder": "O teu nome",
"profileUpdated": "Perfil atualizado",
"profileUpdateFailed": "Falha ao atualizar perfil",
"selectImageFile": "Por favor seleciona um ficheiro de imagem",
"imageTooLarge": "A imagem deve ter menos de 384KB"
},
"changePassword": {
"currentPasswordLabel": "Palavra-passe Atual",
"currentPasswordPlaceholder": "Palavra-passe atual",
"newPasswordLabel": "Nova Palavra-passe",
"newPasswordPlaceholder": "Nova palavra-passe",
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
"changeButton": "Alterar Palavra-passe",
"changingButton": "A alterar...",
"passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
}
}
},
"header": {
"userMenu": {
"profile": "Perfil",
"systemSettings": "Definições do Sistema",
"signOut": "Terminar Sessão"
}
},
"dock": {
"chat": "Chat",
"files": "Ficheiros",
"terminal": "Terminal",
"plans": "Planos",
"skills": "Skills",
"tasks": "Tarefas",
"processes": "Processos",
"logs": "Registos"
}
}
+1
View File
@@ -41,6 +41,7 @@ export * from './anthropic-accounts';
export * from './theme-favorites'; export * from './theme-favorites';
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup'; export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
export * from './sendoff'; export * from './sendoff';
export * from './roles';
export * from './command-hq'; export * from './command-hq';
export * from './dashboards'; export * from './dashboards';
export * from './integrations'; export * from './integrations';
@@ -0,0 +1,2 @@
export { roles } from './schema';
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
@@ -0,0 +1,61 @@
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../auth/schema';
import { roles } from './schema';
export type RoleRow = {
name: string;
description: string | null;
isSystem: boolean;
provisioningScript: string | null;
createdAt: Date;
};
/**
* Every role, with how many accounts hold each.
*
* The count is here rather than in a second query because every caller needs it: a picker wants to show
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
* between them.
*/
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
const rows = await db
.select({
name: roles.name,
description: roles.description,
isSystem: roles.isSystem,
provisioningScript: roles.provisioningScript,
createdAt: roles.createdAt,
userCount: sql<number>`count(${users.id})::int`,
})
.from(roles)
.leftJoin(users, eq(users.role, roles.name))
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
.orderBy(asc(roles.name));
return rows;
}
export async function getRole(name: string): Promise<RoleRow | undefined> {
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
return row;
}
/**
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
*
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
* deploy — so the seed establishes a row and never touches it again.
*/
export async function seedRole(row: {
name: string;
description?: string | null;
isSystem?: boolean;
}): Promise<boolean> {
const inserted = await db
.insert(roles)
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
.onConflictDoNothing({ target: roles.name })
.returning({ name: roles.name });
return inserted.length > 0;
}
@@ -0,0 +1,52 @@
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
/**
* The roles this server has, as data rather than a TypeScript union.
*
* ── Why the NAME is the primary key ──
*
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
* file standing between a Member and a shell — to buy nothing the name does not already give, because
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
* changes.
*
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
*
* ── What is NOT here ──
*
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
*
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
* its provisioning, not its grants.
*/
export const roles = pgTable('roles', {
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
name: text('name').primaryKey(),
/** One line, for a picker. Never load-bearing — nothing branches on it. */
description: text('description'),
/**
* A role the platform's own code names, so it cannot be deleted or renamed.
*
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
* discovering it as a runtime failure somewhere else.
*/
isSystem: boolean('is_system').notNull().default(false),
/**
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
* becomes a row. Null today, and nothing reads it.
*
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
* column that arrives with the feature that uses it is a second migration for no gain.
*/
provisioningScript: text('provisioning_script'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
+1
View File
@@ -38,6 +38,7 @@ export * from './anthropic-accounts/schema'; // anthropic_accounts — switchabl
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account 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 './sendoff/schema'; // sendoff_devices, sendoff_senders export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
export * from './roles/schema'; // roles
export * from './agent-panels/schema'; // agent_panels export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering // Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
+133
View File
@@ -0,0 +1,133 @@
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { chatIdentity } from '../chat/chat';
import { checkBudget } from './budget';
/*
POST /sub-agents/spawn — an agent asks Officer to run another agent.
── Why this exists rather than leaning on the harness ──
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
it started.
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
caller gets the same one, and none of it changes when a CLI does.
── The condition that comes before the feature ──
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
floor: never spend the last 15%.
── What it is NOT ──
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
*/
export const subAgentsRouter = createRouter();
const MAX_PROMPT = 100_000;
subAgentsRouter.post('/spawn', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
/*
Refused BEFORE anything is spawned, and the reason travels with the refusal.
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
not a "not you".
*/
const budget = await checkBudget();
if (!budget.ok) {
return ctx.json(
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
429,
);
}
/*
The identity is resolved from the TOKEN, never from the body.
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
run in.
*/
const who = await chatIdentity(user);
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
// reports one, which is after this returns — see the note in the response below.
const sessionKey = crypto.randomUUID();
const spawn = {
userId: user.id,
email: who.email,
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
prompt,
sessionKey,
...(body?.cwd ? { cwd: body.cwd } : {}),
...(body?.model ? { model: body.model } : {}),
/*
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
there is no Linux user to run as.
*/
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
};
try {
/*
Two modes, and the default is the one a loop wants.
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
work.
*/
if (body?.wait === true) {
const result = await sidecar.spawnClaude(spawn);
return ctx.json({ sessionKey, result });
}
await sidecar.spawnClaudeStreaming(spawn);
/*
`sessionKey` and not a transcript id, because the transcript does not exist yet.
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
saying which id this is.
*/
return ctx.json({ sessionKey, running: true });
} catch (err) {
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
// later is the correct response, which is a distinction a caller can act on.
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
// response — a distinction the caller can act on.
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
}
});
/**
* GET /sub-agents/budget — is there room to start one, without starting one.
*
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
* calls somebody else may have spent the window.
*/
subAgentsRouter.get('/budget', async (ctx) => {
const budget = await checkBudget();
return ctx.json(budget);
});
+2
View File
@@ -28,6 +28,7 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server'; import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router'; import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router'; import { sendoffRouter } from './api/sendoff/router';
import { subAgentsRouter } from './api/sub-agents/router';
import { caldavRouter } from './api/dav/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router'; // import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router'; import { appStoreRouter } from './api/app-store/router';
@@ -143,6 +144,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/terminal', terminalRouter], ['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter], ['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter], ['/sendoff', sendoffRouter],
['/sub-agents', subAgentsRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13 // ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter], ['/app-store', appStoreRouter],
['/plugins', pluginsRouter], ['/plugins', pluginsRouter],
+12 -1
View File
@@ -241,8 +241,19 @@ describe('kinds', () => {
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed // both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential, // on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry. // transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
//
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
// chooses a directory, so there is nothing for a caller to point somewhere else.
test('confined is a short, deliberate list', () => { test('confined is a short, deliberate list', () => {
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']); expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
'terminal',
'chat',
'files',
'sub-agents',
]);
}); });
test('admin permissions are never grantable', () => { test('admin permissions are never grantable', () => {
+18
View File
@@ -360,6 +360,24 @@ const CORE_REGISTRY: Permission[] = [
person who should decide whether somebody may put a file in your home is you, not whoever administers person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all. roles. This grant only says the feature is reachable at all.
*/ */
/*
`confined`, and for the same reason chat is.
Spawning an agent runs a process in a home directory — the caller's own, resolved by `chatIdentity`
from their token. So it needs exactly what chat needs: a Linux account for a member, and nothing for
the owner, with the kernel doing the containing. `authorize.ts` drops a confined grant for an account
whose `osUser` is null, which is what stops "granted but unconfined" resolving to the owner's home.
Not `execution`: that kind exists for surfaces that can ONLY ever run as the owner. This one runs as
whoever called it, which is the whole distinction.
*/
{
key: 'sub-agents',
label: 'Sub-agents',
description: 'Start another agent from a prompt, subject to the account budget',
kind: 'confined',
api: ['/sub-agents'],
},
{ {
key: 'sendoff', key: 'sendoff',
label: 'SendOff', label: 'SendOff',
+7 -3
View File
@@ -1,11 +1,15 @@
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation } from '@/components/locales';
type ColorPickerProps = { value?: string; name: string; label?: string }; type ColorPickerProps = { value?: string; name: string; label?: string };
export const ColorPicker = (props: ColorPickerProps) => { export const ColorPicker = (props: ColorPickerProps) => {
const { value = '#000000', name, label } = props; const { value = '#000000', name, label } = props;
const { t } = useTranslation();
return ( return (
<div style={{ borderWidth: '1px' }} className="relative w-full rounded"> <div style={{ borderWidth: '1px' }} className="relative w-full rounded">
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>} {label && (
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="h-12 overflow-hidden rounded-full p-0"> <div className="h-12 overflow-hidden rounded-full p-0">
<Input <Input
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
className="absolute h-12 w-24 rounded-full border-0 text-lg" className="absolute h-12 w-24 rounded-full border-0 text-lg"
type="color" type="color"
name={name} name={name}
placeholder="Font Weight"
/> />
</div> </div>
<div> <div>
<Input <Input
name={name} name={name}
className="border-0 text-right text-base outline-none focus:outline-none" className="border-0 text-end text-base outline-none focus:outline-none"
defaultValue={value} defaultValue={value}
placeholder={t('colorPicker.placeholder')}
/> />
</div> </div>
</div> </div>
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react'; import { Copy, Check, Play } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard'; import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/components/locales';
type CommandBlockProps = { type CommandBlockProps = {
label?: string; label?: string;
@@ -9,6 +10,7 @@ type CommandBlockProps = {
}; };
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => { export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copy = () => { const copy = () => {
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
return ( return (
<div className="inline-flex flex-col gap-1"> <div className="inline-flex flex-col gap-1">
{label && <span className="text-xs text-duck-dark/50">{label}</span>} {label && <span className="text-xs text-duck-dark/50">{label}</span>}
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5"> <div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code> <code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
{onRun && ( {onRun && (
<button <button
type="button" type="button"
onClick={() => onRun(command)} onClick={() => onRun(command)}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors" className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Run in terminal" title={t('commandBlock.run')}
> >
<Play className="h-3.5 w-3.5 text-duck-teal" /> <Play className="h-3.5 w-3.5 text-duck-teal" />
</button> </button>
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
type="button" type="button"
onClick={copy} onClick={copy}
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors" className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
title="Copy command" title={t('commandBlock.copy')}
> >
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />} {copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
</button> </button>
+10 -6
View File
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react'; import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
// Loading, failed, empty. See docs/design-language-interface.md § 6. // Loading, failed, empty. See docs/design-language-interface.md § 6.
// //
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
</div> </div>
); );
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => ( export const LoadingBlock = ({ label }: { label?: string }) => {
<Shell> const { t } = useTranslation();
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> return (
<p className="text-sm text-muted-foreground">{label}</p> <Shell>
</Shell> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
); <p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
</Shell>
);
};
/** /**
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person * The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
@@ -1,7 +1,21 @@
import type { Column } from './DataTable'; import type { Column } from './DataTable';
import type { ReactNode } from 'react';
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { render, screen, fireEvent, within } from '@testing-library/react'; import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nProvider } from 'i18n';
import { DataTable, useDataControl } from './DataTable'; import { DataTable, useDataControl } from './DataTable';
import enGB from '../locales/en-GB.json';
/**
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
*/
const withI18n = (ui: ReactNode) => (
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
{ui}
</I18nProvider>
);
type TestItem = { type TestItem = {
id: number; id: number;
@@ -55,7 +69,7 @@ const TestWrapper = ({
describe('DataTable', () => { describe('DataTable', () => {
describe('rendering', () => { describe('rendering', () => {
test('renders table with data', () => { test('renders table with data', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
expect(screen.getByRole('table')).toBeDefined(); expect(screen.getByRole('table')).toBeDefined();
expect(screen.getByText('Alpha')).toBeDefined(); expect(screen.getByText('Alpha')).toBeDefined();
@@ -63,7 +77,7 @@ describe('DataTable', () => {
}); });
test('renders column headers', () => { test('renders column headers', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
// Component renders both desktop and mobile versions (hidden via CSS) // Component renders both desktop and mobile versions (hidden via CSS)
// so we use getAllByText and verify at least one exists // so we use getAllByText and verify at least one exists
@@ -74,7 +88,7 @@ describe('DataTable', () => {
}); });
test('renders correct number of rows based on page size', () => { test('renders correct number of rows based on page size', () => {
render(<TestWrapper pageSize={3} />); render(withI18n(<TestWrapper pageSize={3} />));
const rows = screen.getAllByRole('row'); const rows = screen.getAllByRole('row');
// 1 header row + 3 data rows // 1 header row + 3 data rows
@@ -82,20 +96,20 @@ describe('DataTable', () => {
}); });
test('shows no data message when data is empty', () => { test('shows no data message when data is empty', () => {
render(<TestWrapper data={[]} noDataMessage="No items found" />); render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
expect(screen.getByText('No items found')).toBeDefined(); expect(screen.getByText('No items found')).toBeDefined();
expect(screen.queryByRole('table')).toBeNull(); expect(screen.queryByRole('table')).toBeNull();
}); });
test('shows default no data message', () => { test('shows default no data message', () => {
render(<TestWrapper data={[]} />); render(withI18n(<TestWrapper data={[]} />));
expect(screen.getByText('No Data Found.')).toBeDefined(); expect(screen.getByText('No Data Found.')).toBeDefined();
}); });
test('renders noDataCTA when provided', () => { test('renders noDataCTA when provided', () => {
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />); render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined(); expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
}); });
@@ -106,7 +120,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} columns={columns} />; return <DataTable dataController={controller} columns={columns} />;
}; };
const { container } = render(<NullWrapper />); const { container } = render(withI18n(<NullWrapper />));
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
}); });
@@ -118,7 +132,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} />; return <DataTable dataController={controller} />;
}; };
render(<AutoColumnsWrapper />); render(withI18n(<AutoColumnsWrapper />));
// Should generate columns from object keys (rendered for both mobile/desktop) // Should generate columns from object keys (rendered for both mobile/desktop)
expect(screen.getAllByText('Id').length).toBeGreaterThan(0); expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
@@ -130,7 +144,7 @@ describe('DataTable', () => {
describe('sorting', () => { describe('sorting', () => {
test('clicking sortable column header triggers sort', () => { test('clicking sortable column header triggers sort', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
// Get the first "Name" element (desktop version) // Get the first "Name" element (desktop version)
const nameHeaders = screen.getAllByText('Name'); const nameHeaders = screen.getAllByText('Name');
@@ -143,7 +157,7 @@ describe('DataTable', () => {
}); });
test('clicking same column twice reverses sort order', () => { test('clicking same column twice reverses sort order', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
const nameHeaders = screen.getAllByText('Name'); const nameHeaders = screen.getAllByText('Name');
fireEvent.click(nameHeaders[0]!); // asc fireEvent.click(nameHeaders[0]!); // asc
@@ -156,7 +170,7 @@ describe('DataTable', () => {
}); });
test('sort icon is displayed for sortable columns', () => { test('sort icon is displayed for sortable columns', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
// Each sortable column should have the ArrowUpDown icon // Each sortable column should have the ArrowUpDown icon
const headers = screen.getAllByRole('columnheader'); const headers = screen.getAllByRole('columnheader');
@@ -169,19 +183,19 @@ describe('DataTable', () => {
describe('pagination', () => { describe('pagination', () => {
test('shows pagination bar when data exceeds page size', () => { test('shows pagination bar when data exceeds page size', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined(); expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
}); });
test('does not show pagination when all data fits on one page', () => { test('does not show pagination when all data fits on one page', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
expect(screen.queryByText(/Showing page/)).toBeNull(); expect(screen.queryByText(/Showing page/)).toBeNull();
}); });
test('next button navigates to next page', () => { test('next button navigates to next page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -192,7 +206,7 @@ describe('DataTable', () => {
}); });
test('previous button navigates to previous page', () => { test('previous button navigates to previous page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -205,7 +219,7 @@ describe('DataTable', () => {
}); });
test('first button goes to first page', () => { test('first button goes to first page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -217,7 +231,7 @@ describe('DataTable', () => {
}); });
test('last button goes to last page', () => { test('last button goes to last page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const lastButton = screen.getByRole('button', { name: 'Last' }); const lastButton = screen.getByRole('button', { name: 'Last' });
fireEvent.click(lastButton); fireEvent.click(lastButton);
@@ -226,7 +240,7 @@ describe('DataTable', () => {
}); });
test('navigation buttons are disabled at boundaries', () => { test('navigation buttons are disabled at boundaries', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const firstBtn = screen.getByRole('button', { name: 'First' }); const firstBtn = screen.getByRole('button', { name: 'First' });
const prevBtn = screen.getByRole('button', { name: 'Prev' }); const prevBtn = screen.getByRole('button', { name: 'Prev' });
@@ -258,7 +272,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: false }, { field: 'status', label: 'Status', condition: false },
]; ];
render(<TestWrapper cols={conditionalColumns} />); render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('ID').length).toBeGreaterThan(0); expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
expect(screen.getAllByText('Name').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
@@ -271,7 +285,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: true }, { field: 'status', label: 'Status', condition: true },
]; ];
render(<TestWrapper cols={conditionalColumns} />); render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
}); });
@@ -283,11 +297,11 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: () => showStatus }, { field: 'status', label: 'Status', condition: () => showStatus },
]; ];
const { rerender } = render(<TestWrapper cols={conditionalColumns} />); const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.queryByText('Status')).toBeNull(); expect(screen.queryByText('Status')).toBeNull();
showStatus = true; showStatus = true;
rerender(<TestWrapper cols={conditionalColumns} />); rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
}); });
}); });
@@ -303,7 +317,7 @@ describe('DataTable', () => {
}, },
]; ];
render(<TestWrapper cols={formattedColumns} />); render(withI18n(<TestWrapper cols={formattedColumns} />));
const formattedNames = screen.getAllByTestId('formatted-name'); const formattedNames = screen.getAllByTestId('formatted-name');
expect(formattedNames.length).toBeGreaterThan(0); expect(formattedNames.length).toBeGreaterThan(0);
@@ -319,7 +333,7 @@ describe('DataTable', () => {
}, },
]; ];
render(<TestWrapper cols={formattedColumns} />); render(withI18n(<TestWrapper cols={formattedColumns} />));
// Each row should have a unique test ID based on item.id // Each row should have a unique test ID based on item.id
expect(screen.getByTestId('item-1')).toBeDefined(); expect(screen.getByTestId('item-1')).toBeDefined();
@@ -337,7 +351,7 @@ describe('DataTable', () => {
}, },
]; ];
render(<TestWrapper cols={actionColumns} />); render(withI18n(<TestWrapper cols={actionColumns} />));
expect(screen.getByTestId('action-1')).toBeDefined(); expect(screen.getByTestId('action-1')).toBeDefined();
expect(screen.getByText('Edit Alpha')).toBeDefined(); expect(screen.getByText('Edit Alpha')).toBeDefined();
@@ -351,7 +365,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Name' }, { field: 'name', label: 'Name' },
]; ];
render(<TestWrapper cols={tooltipColumns} />); render(withI18n(<TestWrapper cols={tooltipColumns} />));
// The tooltip indicator (ⓘ) should be present in the ID column headers // The tooltip indicator (ⓘ) should be present in the ID column headers
const idHeaders = screen.getAllByText(/ID/); const idHeaders = screen.getAllByText(/ID/);
@@ -367,7 +381,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Full Name', labelMobile: 'Name' }, { field: 'name', label: 'Full Name', labelMobile: 'Name' },
]; ];
render(<TestWrapper cols={mobileColumns} />); render(withI18n(<TestWrapper cols={mobileColumns} />));
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS) // Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0); expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
@@ -396,7 +410,7 @@ describe('DataTable', () => {
); );
}; };
render(<SearchableTable />); render(withI18n(<SearchableTable />));
const searchInput = screen.getByTestId('search-input'); const searchInput = screen.getByTestId('search-input');
fireEvent.change(searchInput, { target: { value: 'Alpha' } }); fireEvent.change(searchInput, { target: { value: 'Alpha' } });
@@ -420,7 +434,7 @@ describe('DataTable', () => {
); );
}; };
render(<ExternalSortTable />); render(withI18n(<ExternalSortTable />));
fireEvent.click(screen.getByTestId('sort-btn')); fireEvent.click(screen.getByTestId('sort-btn'));
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl'; import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
import { PaginationBar } from './PaginationBar'; import { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const DataTable = <T,>(props: DataTableProps<T>) => { export const DataTable = <T,>(props: DataTableProps<T>) => {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column<T>[]>([]); const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props; const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id'; const theUniqueKey = uniqueKey || 'id';
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
if (rawData.length === 0) { if (rawData.length === 0) {
return ( return (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div> <div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
{noDataCTA && <>{noDataCTA}</>} {noDataCTA && <>{noDataCTA}</>}
</div> </div>
); );
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
<span className="min-[500px]:hidden"> <span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''} {String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span> </span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />} {sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
{tooltip && ( {tooltip && (
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/components/locales';
type PaginationBarProps<T> = { type PaginationBarProps<T> = {
dataManager: UseDataControlReturn<T>; dataManager: UseDataControlReturn<T>;
}; };
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => { export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
const { t } = useTranslation();
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager; const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []); const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
{/* LEFT */} {/* LEFT */}
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap"> <div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
<span className="hidden min-[500px]:inline"> <span className="hidden min-[500px]:inline">
Showing page {currentPage} of {pageCount} {t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
</span> </span>
<span className="min-[500px]:hidden"> <span className="min-[500px]:hidden">
p {currentPage}/{pageCount} {t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
</span> </span>
<span className="bg-border h-5 w-px" aria-hidden="true" /> <span className="bg-border h-5 w-px" aria-hidden="true" />
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
hover:bg-background hover:bg-background
data-[state=open]:bg-muted data-[state=open]:bg-muted
" "
aria-label="Rows per page" aria-label={t('dataTable.pagination.rowsPerPage')}
> >
<SelectValue className="text-muted-foreground"> <SelectValue className="text-muted-foreground">
<span className="hidden min-[500px]:inline">{pageSize} / page</span> <span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
<span className="min-[500px]:hidden">{pageSize}/p</span> <span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
data-[state=checked]:text-muted-foreground data-[state=checked]:text-muted-foreground
" "
> >
<span className="hidden min-[500px]:inline">{n} / page</span> <span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
<span className="min-[500px]:hidden">{n}/p</span> <span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(1)} onClick={() => changePage(1)}
disabled={currentPage === 1} disabled={currentPage === 1}
aria-label="First" aria-label={t('dataTable.pagination.first')}
> >
« «
</Button> </Button>
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(currentPage - 1)} onClick={() => changePage(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
aria-label="Prev" aria-label={t('dataTable.pagination.prev')}
> >
‹ ‹
</Button> </Button>
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(currentPage + 1)} onClick={() => changePage(currentPage + 1)}
disabled={currentPage === pageCount} disabled={currentPage === pageCount}
aria-label="Next" aria-label={t('dataTable.pagination.next')}
> >
› ›
</Button> </Button>
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(pageCount)} onClick={() => changePage(pageCount)}
disabled={currentPage === pageCount} disabled={currentPage === pageCount}
aria-label="Last" aria-label={t('dataTable.pagination.last')}
> >
» »
</Button> </Button>
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type ConfirmDialogProps = { type ConfirmDialogProps = {
open: boolean; open: boolean;
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
export const ConfirmDialog = (props: ConfirmDialogProps) => { export const ConfirmDialog = (props: ConfirmDialogProps) => {
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const { t } = useTranslation();
const { open, disabled = false, title, description, children } = props; const { open, disabled = false, title, description, children } = props;
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props; const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
const handleConfirm = async () => { const handleConfirm = async () => {
setConfirming(true); setConfirming(true);
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
Cancel {t('button.cancel')}
</Button> </Button>
<Button <Button
type="button" type="button"
variant={confirmVariant} variant={confirmVariant}
onClick={handleConfirm} onClick={handleConfirm}
disabled={disabled || confirming} disabled={disabled || confirming}
className="sm:ml-2" className="sm:ms-2"
> >
{confirmLabel} {confirmLabel ?? t('button.confirm')}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card'; import { cardStyle } from '@/components/Card';
import { useTranslation } from '@/components/locales';
type PaperDialogProps = { type PaperDialogProps = {
open: boolean; open: boolean;
@@ -11,6 +12,8 @@ type PaperDialogProps = {
}; };
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => { export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
const { t } = useTranslation();
const handleOpenAutoFocus = (ev: Event) => { const handleOpenAutoFocus = (ev: Event) => {
ev.preventDefault(); ev.preventDefault();
onOpenAutoFocus?.(ev); onOpenAutoFocus?.(ev);
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg" className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
style={cardStyle()} style={cardStyle()}
> >
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="h-4 w-4 text-duck-dark">✕</span> <span className="h-4 w-4 text-duck-dark">✕</span>
<span className="sr-only">Close</span> <span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
{children} {children}
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => { export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Server Error {t('error.server.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p> <p>{apiError?.message}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => { export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Unauthorized Access {t('error.forbidden.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5"> <DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
Your are not authorized to perform this action.
</DialogDescription>
</DialogHeader> </DialogHeader>
<p>If you think this is an error, please contact your manager.</p> <p>{t('error.forbidden.hint')}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => { export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Server Error {t('error.server.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p> <p>{apiError?.message}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => { export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
const { signout } = useAuth(); const { signout } = useAuth();
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Invalid Credentials {t('error.unauthorized.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">Your access token has expired</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">
{t('error.unauthorized.description')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<p>Please Signin again</p> <p>{t('error.unauthorized.hint')}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,17 +1,14 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = { type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}; };
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) { export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig[size]; const config = sizeConfig[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Icon"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
const sizeConfig = { const sizeConfig = {
@@ -1,3 +1,5 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = { type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
square?: boolean; square?: boolean;
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
} }
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) { export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.regular[size]; const config = sizeConfig.regular[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) { export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.square[size]; const config = sizeConfig.square[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
const sizeConfig = { const sizeConfig = {
@@ -1,115 +0,0 @@
import { useState } from 'react';
import md from 'markdown-it';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Button } from '@/components/ui/button';
import { Code, Eye } from 'lucide-react';
type MarkdownEditorProps = {
content: string;
onChange: (content: string) => void;
placeholder?: string;
greeting?: string;
firstName?: string;
lastName?: string;
};
export function MarkdownEditor({
content,
onChange,
placeholder = 'Enter markdown...',
greeting,
firstName,
lastName,
}: MarkdownEditorProps) {
const [showPreview, setShowPreview] = useState(false);
const markdown = md({
html: true,
breaks: true,
linkify: true,
});
const processGreeting = (text: string) => {
return text
.replace(/\{\{firstName\}\}/g, firstName || '')
.replace(/\{\{fullName\}\}/g, `${firstName || ''} ${lastName || ''}`.trim());
};
const processedGreeting = greeting ? processGreeting(greeting) : '';
const greetingHtml = processedGreeting ? `<p>${processedGreeting}</p>` : '';
const preview = markdown.render(content);
return (
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 border-b border-border bg-muted px-4 py-2">
<Button size="sm" variant={!showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(false)}>
<Code className="h-4 w-4 mr-2" />
Markdown
</Button>
<Button size="sm" variant={showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(true)}>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-hidden">
{!showPreview ? (
<textarea
rows={15}
name="content"
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-full resize-none p-4 font-mono text-sm focus:outline-none bg-background text-foreground placeholder:text-muted-foreground overflow-auto"
/>
) : (
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
{/* Email wrapper simulation */}
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
{/* Logo */}
<div className="mb-8 text-center">
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
</div>
{/* Content */}
<div className="space-y-4">
{processedGreeting && <p className="text-base leading-relaxed">{processedGreeting}</p>}
<style>{`
.markdown-preview h1 { font-size: 2rem; font-weight: bold; margin: 1.5rem 0 1rem 0; }
.markdown-preview h2 { font-size: 1.5rem; font-weight: bold; margin: 1.25rem 0 0.75rem 0; }
.markdown-preview h3 { font-size: 1.25rem; font-weight: bold; margin: 1rem 0 0.5rem 0; }
.markdown-preview p { margin: 0.75rem 0; line-height: 1.6; }
/* list-style is restored explicitly — tailwind preflight strips it, which leaves an
ordered list indented but unnumbered. */
.markdown-preview ul, .markdown-preview ol { margin: 1rem 0; padding-left: 2rem; list-style-position: outside; }
.markdown-preview ul { list-style-type: disc; }
.markdown-preview ol { list-style-type: decimal; }
.markdown-preview ul ul { list-style-type: circle; }
.markdown-preview ol ol { list-style-type: lower-alpha; }
.markdown-preview li { margin: 0.5rem 0; line-height: 1.6; }
.markdown-preview code { background-color: rgb(229, 229, 229); color: rgb(31, 41, 55); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.875rem; }
.markdown-preview pre { background-color: rgb(17, 24, 39); color: rgb(243, 244, 246); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.markdown-preview pre code { background-color: transparent; color: inherit; padding: 0; }
.markdown-preview a { color: rgb(37, 99, 235); text-decoration: underline; }
.markdown-preview blockquote { border-left: 4px solid rgb(209, 213, 219); padding-left: 1rem; font-style: italic; margin: 1rem 0; }
.dark .markdown-preview code { background-color: rgb(55, 65, 81); color: rgb(243, 244, 246); }
.dark .markdown-preview a { color: rgb(96, 165, 250); }
.dark .markdown-preview blockquote { border-left-color: rgb(75, 85, 99); }
`}</style>
<div className="markdown-preview">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+10 -8
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Search as SearchIcon, X as ClearIcon } from 'lucide-react'; import { Search as SearchIcon, X as ClearIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
type SearchInputProps = { type SearchInputProps = {
className?: string; className?: string;
@@ -12,7 +13,8 @@ type SearchInputProps = {
}; };
export function SearchInput(props: SearchInputProps) { export function SearchInput(props: SearchInputProps) {
const { className, handleSearch, value = '', placeholder = 'Search' } = props; const { t } = useTranslation();
const { className, handleSearch, value = '', placeholder = t('search.placeholder') } = props;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'} aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
onClick={() => { onClick={() => {
setIsMobileSearchOpen((v) => !v); setIsMobileSearchOpen((v) => !v);
if (!isMobileSearchOpen) { if (!isMobileSearchOpen) {
setTimeout(() => inputRef.current?.focus(), 160); setTimeout(() => inputRef.current?.focus(), 160);
} }
}} }}
className="ml-auto shrink-0 md:hidden" className="ms-auto shrink-0 md:hidden"
> >
{isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />} {isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />}
</Button> </Button>
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
className={cn( className={cn(
'relative w-fit max-w-lg', 'relative w-fit max-w-lg',
isMobileSearchOpen isMobileSearchOpen
? 'absolute left-0 mt-4 block w-full md:relative md:mt-0 md:w-fit' ? 'absolute start-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
: 'hidden w-auto md:block', : 'hidden w-auto md:block',
className, className,
)} )}
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
{!!value ? ( {!!value ? (
<button <button
type="button" type="button"
aria-label="Clear search" aria-label={t('search.clear')}
onClick={() => handleSearch('')} onClick={() => handleSearch('')}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted" className="absolute start-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
> >
<ClearIcon className="h-4 w-4 text-muted-foreground" /> <ClearIcon className="h-4 w-4 text-muted-foreground" />
</button> </button>
) : ( ) : (
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2"> <span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
<SearchIcon className="text-muted-foreground h-4 w-4" /> <SearchIcon className="text-muted-foreground h-4 w-4" />
</span> </span>
)} )}
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(ev) => handleSearch(ev.target.value)} onChange={(ev) => handleSearch(ev.target.value)}
className="pl-10 pr-4 text-sm" className="ps-10 pe-4 text-sm"
/> />
</div> </div>
</div> </div>
+3 -1
View File
@@ -1,10 +1,12 @@
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select'; import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const Select = (props: SelectProps) => { export const Select = (props: SelectProps) => {
const { t } = useTranslation();
const { options, value, onChange, onValueChange, label } = props; const { options, value, onChange, onValueChange, label } = props;
const { name = '', className, placeholder = 'Select...', update } = props; const { name = '', className, placeholder = t('select.placeholder'), update } = props;
if (!options) return null; if (!options) return null;
+3 -1
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from '@/components/locales';
type TabsProps = { type TabsProps = {
tabList: { value: string; label: string }[]; tabList: { value: string; label: string }[];
@@ -8,6 +9,7 @@ type TabsProps = {
setActiveTab: (tab: string) => void; setActiveTab: (tab: string) => void;
}; };
const TabsComponent = (props: TabsProps) => { const TabsComponent = (props: TabsProps) => {
const { t } = useTranslation();
const { tabList, activeTab, setActiveTab } = props; const { tabList, activeTab, setActiveTab } = props;
const tabsListRef = useRef<HTMLDivElement>(null); const tabsListRef = useRef<HTMLDivElement>(null);
return ( return (
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
<div className="block lg:hidden"> <div className="block lg:hidden">
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}> <Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Filter" /> <SelectValue placeholder={t('tabs.filterPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tabList.map((f) => ( {tabList.map((f) => (
@@ -0,0 +1,81 @@
{
"button": {
"cancel": "Cancel",
"confirm": "Confirm",
"ok": "Ok",
"close": "Close"
},
"search": {
"placeholder": "Search",
"open": "Open search",
"close": "Close search",
"clear": "Clear search"
},
"select": {
"placeholder": "Select..."
},
"tabs": {
"filterPlaceholder": "Filter"
},
"commandBlock": {
"run": "Run in terminal",
"copy": "Copy command"
},
"colorPicker": {
"placeholder": "Hex colour"
},
"state": {
"loading": "Loading…"
},
"dataTable": {
"empty": "No Data Found.",
"pagination": {
"showingPage": "Showing page {{current}} of {{total}}",
"showingPageShort": "p {{current}}/{{total}}",
"rowsPerPage": "Rows per page",
"perPage": "{{size}} / page",
"perPageShort": "{{size}}/p",
"first": "First",
"prev": "Prev",
"next": "Next",
"last": "Last"
}
},
"pagination": {
"label": "Pagination",
"previous": "Previous",
"next": "Next",
"morePages": "More pages",
"goToPrevious": "Go to previous page",
"goToNext": "Go to next page"
},
"carousel": {
"previous": "Previous slide",
"next": "Next slide"
},
"breadcrumb": {
"more": "More"
},
"theme": {
"toggle": "Toggle color mode"
},
"error": {
"forbidden": {
"title": "Unauthorized Access",
"description": "You are not authorized to perform this action.",
"hint": "If you think this is an error, please contact your manager."
},
"unauthorized": {
"title": "Invalid Credentials",
"description": "Your access token has expired",
"hint": "Please sign in again"
},
"server": {
"title": "Server Error"
}
},
"logo": {
"icon": "Officer Icon",
"logo": "Officer Logo"
}
}
@@ -0,0 +1,8 @@
import type { TranslationMap } from 'i18n';
import enGB from './en-GB.json';
import ptPT from './pt-PT.json';
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
export { useTranslation } from './useTranslation';
@@ -0,0 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { findMissingKeys } from 'i18n';
import en from './en-GB.json';
import ptPT from './pt-PT.json';
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
describe('shared components (ui.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
@@ -0,0 +1,81 @@
{
"button": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"ok": "OK",
"close": "Fechar"
},
"search": {
"placeholder": "Pesquisar",
"open": "Abrir pesquisa",
"close": "Fechar pesquisa",
"clear": "Limpar pesquisa"
},
"select": {
"placeholder": "Selecionar..."
},
"tabs": {
"filterPlaceholder": "Filtrar"
},
"commandBlock": {
"run": "Executar no terminal",
"copy": "Copiar comando"
},
"colorPicker": {
"placeholder": "Cor hexadecimal"
},
"state": {
"loading": "A carregar…"
},
"dataTable": {
"empty": "Não foram encontrados dados.",
"pagination": {
"showingPage": "A mostrar página {{current}} de {{total}}",
"showingPageShort": "p {{current}}/{{total}}",
"rowsPerPage": "Linhas por página",
"perPage": "{{size}} / página",
"perPageShort": "{{size}}/p",
"first": "Primeira",
"prev": "Anterior",
"next": "Seguinte",
"last": "Última"
}
},
"pagination": {
"label": "Paginação",
"previous": "Anterior",
"next": "Seguinte",
"morePages": "Mais páginas",
"goToPrevious": "Ir para a página anterior",
"goToNext": "Ir para a página seguinte"
},
"carousel": {
"previous": "Diapositivo anterior",
"next": "Diapositivo seguinte"
},
"breadcrumb": {
"more": "Mais"
},
"theme": {
"toggle": "Alternar modo de cor"
},
"error": {
"forbidden": {
"title": "Acesso não autorizado",
"description": "Não tem autorização para executar esta ação.",
"hint": "Se acha que isto é um erro, contacte o seu gestor."
},
"unauthorized": {
"title": "Credenciais inválidas",
"description": "O seu token de acesso expirou",
"hint": "Inicie sessão novamente"
},
"server": {
"title": "Erro do servidor"
}
},
"logo": {
"icon": "Ícone Officer",
"logo": "Logótipo Officer"
}
}
@@ -0,0 +1,16 @@
import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json';
type UiTranslationKey = TranslationKeys<typeof en>;
// The `ui.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase();
// Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
const t = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
return { t, locale };
};
+1
View File
@@ -45,6 +45,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"hooks": "*", "hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
+3 -1
View File
@@ -1,12 +1,14 @@
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useColorMode } from './ThemeProvider'; import { useColorMode } from './ThemeProvider';
import { useTranslation } from '@/components/locales';
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean }; type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
export const ThemeToggle = (props: ThemeToggleProps) => { export const ThemeToggle = (props: ThemeToggleProps) => {
const { colorDark, colorLight } = props; const { colorDark, colorLight } = props;
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
const toggle = () => { const toggle = () => {
setColorMode(colorMode === 'dark' ? 'light' : 'dark'); setColorMode(colorMode === 'dark' ? 'light' : 'dark');
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
colorDark && 'text-black', colorDark && 'text-black',
)} )}
/> />
<span className="sr-only">Toggle color mode</span> <span className="sr-only">{t('theme.toggle')}</span>
</div> </div>
); );
}; };
+15 -11
View File
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react'; import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Breadcrumb = React.forwardRef< const Breadcrumb = React.forwardRef<
HTMLElement, HTMLElement,
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
); );
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'; BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => ( const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
<span const { t } = useTranslation();
role="presentation" return (
aria-hidden="true" <span
className={cn('flex h-9 w-9 items-center justify-center', className)} role="presentation"
{...props} aria-hidden="true"
> className={cn('flex h-9 w-9 items-center justify-center', className)}
<MoreHorizontal className="h-4 w-4" /> {...props}
<span className="sr-only">More</span> >
</span> <MoreHorizontal className="h-4 w-4" />
); <span className="sr-only">{t('breadcrumb.more')}</span>
</span>
);
};
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'; BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export { export {
+7 -4
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type CarouselApi = UseEmblaCarouselType[1]; type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>( const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => { ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel(); const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
className={cn( className={cn(
'absolute h-8 w-8 rounded-full', 'absolute h-8 w-8 rounded-full',
orientation === 'horizontal' orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2' ? '-start-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90', : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className, className,
)} )}
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
{...props} {...props}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">{t('carousel.previous')}</span>
</Button> </Button>
); );
}, },
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>( const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => { ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel(); const { orientation, scrollNext, canScrollNext } = useCarousel();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
className={cn( className={cn(
'absolute h-8 w-8 rounded-full', 'absolute h-8 w-8 rounded-full',
orientation === 'horizontal' orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2' ? '-end-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className, className,
)} )}
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
{...props} {...props}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">{t('carousel.next')}</span>
</Button> </Button>
); );
}, },
+23 -19
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
<DialogPortal> const { t } = useTranslation();
<DialogOverlay /> return (
<DialogPrimitive.Content <DialogPortal>
ref={ref} <DialogOverlay />
className={cn( <DialogPrimitive.Content
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', ref={ref}
className, className={cn(
)} 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
{...props} className,
> )}
{children} {...props}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> >
<X className="h-4 w-4" /> {children}
<span className="sr-only">Close</span> <DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
</DialogPrimitive.Close> <X className="h-4 w-4" />
</DialogPrimitive.Content> <span className="sr-only">{t('button.close')}</span>
</DialogPortal> </DialogPrimitive.Close>
)); </DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+3 -1
View File
@@ -3,10 +3,12 @@ import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useColorMode } from '@/components/ui/ThemeProvider'; import { useColorMode } from '@/components/ui/ThemeProvider';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>( export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -19,7 +21,7 @@ export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, Reac
> >
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle color mode</span> <span className="sr-only">{t('theme.toggle')}</span>
</Button> </Button>
); );
}, },
+49 -26
View File
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ButtonProps } from '@/components/ui/button'; import type { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
<nav const { t } = useTranslation();
role="navigation" return (
aria-label="pagination" <nav
className={cn('mx-auto flex w-full justify-center', className)} role="navigation"
{...props} aria-label={t('pagination.label')}
/> className={cn('mx-auto flex w-full justify-center', className)}
); {...props}
/>
);
};
Pagination.displayName = 'Pagination'; Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>( const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
); );
PaginationLink.displayName = 'PaginationLink'; PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 pl-2.5', className)} {...props}> const { t } = useTranslation();
<ChevronLeft className="h-4 w-4" /> return (
<span>Previous</span> <PaginationLink
</PaginationLink> aria-label={t('pagination.goToPrevious')}
); size="default"
className={cn('gap-1 ps-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>{t('pagination.previous')}</span>
</PaginationLink>
);
};
PaginationPrevious.displayName = 'PaginationPrevious'; PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 pr-2.5', className)} {...props}> const { t } = useTranslation();
<span>Next</span> return (
<ChevronRight className="h-4 w-4" /> <PaginationLink
</PaginationLink> aria-label={t('pagination.goToNext')}
); size="default"
className={cn('gap-1 pe-2.5', className)}
{...props}
>
<span>{t('pagination.next')}</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
};
PaginationNext.displayName = 'PaginationNext'; PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => ( const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}> const { t } = useTranslation();
<MoreHorizontal className="h-4 w-4" /> return (
<span className="sr-only">More pages</span> <span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
</span> <MoreHorizontal className="h-4 w-4" />
); <span className="sr-only">{t('pagination.morePages')}</span>
</span>
);
};
PaginationEllipsis.displayName = 'PaginationEllipsis'; PaginationEllipsis.displayName = 'PaginationEllipsis';
export { export {
+16 -12
View File
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Sheet = SheetPrimitive.Root; const Sheet = SheetPrimitive.Root;
@@ -51,18 +52,21 @@ interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {} extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = 'right', className, children, ...props }, ref) => ( ({ side = 'right', className, children, ...props }, ref) => {
<SheetPortal> const { t } = useTranslation();
<SheetOverlay /> return (
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> <SheetPortal>
{children} <SheetOverlay />
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<X className="h-4 w-4" /> {children}
<span className="sr-only">Close</span> <SheetPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
</SheetPrimitive.Close> <X className="h-4 w-4" />
</SheetPrimitive.Content> <span className="sr-only">{t('button.close')}</span>
</SheetPortal> </SheetPrimitive.Close>
), </SheetPrimitive.Content>
</SheetPortal>
);
},
); );
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName;
@@ -1,212 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Play } from 'lucide-react';
export default function StartConfettiButton({
isDraftLike,
onStart,
className,
label = 'Start',
}: {
isDraftLike: boolean;
onStart?: () => void;
className?: string;
label?: string;
}) {
const [showConfetti, setShowConfetti] = useState(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rafRef = useRef<number | null>(null);
// ---- Confetti Engine (inline, light) ----
const runConfetti = useCallback(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
// size canvas
const resize = () => {
const { innerWidth: w, innerHeight: h } = window;
canvas.width = Math.floor(w * DPR);
canvas.height = Math.floor(h * DPR);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
};
resize();
const onResize = () => resize();
window.addEventListener('resize', onResize);
const colors = [
'#ef4444', // red-500
'#f59e0b', // amber-500
'#10b981', // emerald-500
'#3b82f6', // blue-500
'#8b5cf6', // violet-500
'#ec4899', // pink-500
'#22c55e', // green-500
'#eab308', // yellow-500
];
type Particle = {
x: number;
y: number;
w: number;
h: number;
vx: number;
vy: number;
rot: number;
rotSpeed: number;
tilt: number;
tiltSpeed: number;
color: string;
alpha: number;
decay: number;
shape: 'rect' | 'triangle';
};
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
const particles: Particle[] = [];
const bursts = 5;
const perBurst = 80;
const spread = Math.PI / 3;
const gravity = 0.25;
const air = 0.002;
const floorFriction = 0.96;
for (let b = 0; b < bursts; b++) {
const originX = (window.innerWidth / (bursts + 1)) * (b + 1);
const originY = window.innerHeight * 0.15;
const angleBase = -Math.PI / 2;
for (let i = 0; i < perBurst; i++) {
const angle = angleBase + rand(-spread, spread);
const speed = rand(6, 12);
particles.push({
x: originX + rand(-8, 8),
y: originY + rand(-8, 8),
w: rand(4, 8),
h: rand(6, 14),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rot: rand(0, Math.PI * 2),
rotSpeed: rand(-0.2, 0.2),
tilt: rand(-0.8, 0.8),
tiltSpeed: rand(-0.02, 0.02),
color: colors[(Math.random() * colors.length) | 0] ?? '#ef4444',
alpha: 1,
decay: rand(0.004, 0.01),
shape: Math.random() < 0.3 ? 'triangle' : 'rect',
});
}
}
const tick = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
// physics
p.vy += gravity;
p.vx *= 1 - air;
p.vy *= 1 - air * 0.5;
p.x += p.vx;
p.y += p.vy;
p.rot += p.rotSpeed;
p.tilt += p.tiltSpeed;
p.alpha -= p.decay;
// floor bounce & friction
const floor = window.innerHeight - 6;
if (p.y > floor) {
p.y = floor;
p.vy *= -0.45;
p.vx *= floorFriction;
p.rotSpeed *= floorFriction;
p.tiltSpeed *= floorFriction;
}
// draw
if (p.alpha > 0) {
ctx.globalAlpha = Math.max(0, p.alpha);
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot + p.tilt);
ctx.fillStyle = p.color;
if (p.shape === 'rect') {
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.beginPath();
ctx.moveTo(0, -p.h / 2);
ctx.lineTo(p.w / 2, p.h / 2);
ctx.lineTo(-p.w / 2, p.h / 2);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
}
const alive = particles.some((p) => p.alpha > 0.02);
if (alive) {
rafRef.current = requestAnimationFrame(tick);
} else {
// cleanup
ctx.clearRect(0, 0, canvas.width, canvas.height);
window.removeEventListener('resize', onResize);
}
};
tick();
}, []);
const handleStart = () => {
onStart?.();
if (isDraftLike) {
setShowConfetti(true);
}
};
useEffect(() => {
if (!showConfetti) return;
runConfetti();
const t = setTimeout(() => {
setShowConfetti(false);
}, 2200);
return () => {
clearTimeout(t);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [showConfetti, runConfetti]);
return (
<>
<Button
size="sm"
onClick={handleStart}
className={`min-w-24 ${isDraftLike ? 'bg-green-600 text-white hover:bg-green-700' : ''} ${className ?? ''}`}
disabled={!isDraftLike}
title={isDraftLike ? 'Start the experiment' : 'Only available for Draft/Created'}
>
<Play className="w-4 h-4 mr-2" />
{label}
</Button>
{/* Confetti Overlay */}
{showConfetti && (
<div
className="fixed inset-0 z-100 pointer-events-none mask-[radial-gradient(80%_80%_at_50%_40%,#000,transparent)]"
aria-hidden
>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div className="absolute inset-x-0 top-0 h-32 bg-linear-to-b from-white/60 to-transparent" />
</div>
)}
</>
);
}
+8 -4
View File
@@ -11,15 +11,19 @@ export const I18nContext = createContext<I18nContextValue>({
type I18nProviderProps = { type I18nProviderProps = {
translations: TranslationMap; translations: TranslationMap;
locale: string; locale: string;
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
fallbackLocale?: string;
children: ReactNode; children: ReactNode;
}; };
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => { export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
const value = useMemo<I18nContextValue>(() => { const value = useMemo<I18nContextValue>(() => {
const t = (key: string, params?: InterpolationParams) => const t = (key: string, params?: InterpolationParams) => {
interpolate(resolveKey(translations, locale, key), params); const count = typeof params?.count === 'number' ? params.count : undefined;
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
};
return { t, locale }; return { t, locale };
}, [translations, locale]); }, [translations, locale, fallbackLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}; };
+5 -2
View File
@@ -1,4 +1,7 @@
export { I18nProvider, I18nContext } from './I18nProvider'; export { I18nProvider, I18nContext } from './I18nProvider';
export { useTranslation } from './useTranslation'; export { useTranslation } from './useTranslation';
export { resolveKey, interpolate } from './resolve'; export { useFormat } from './useFormat';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types'; export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
export { findMissingKeys } from './parity';
export type { MissingKey } from './parity';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
+27
View File
@@ -0,0 +1,27 @@
import { isPluralForms } from './resolve';
type Catalog = Record<string, unknown>;
const flattenKeys = (obj: Catalog, prefix = ''): string[] =>
Object.entries(obj).flatMap(([k, v]) => {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v) && !isPluralForms(v)) {
return flattenKeys(v as Catalog, path);
}
return [path];
});
export type MissingKey = { locale: string; key: string };
/**
* Per D2, every shipped locale is a COMPLETE catalog — no regional inheritance. So a key `en` has
* and a locale's own file lacks is not a fallback case, it's a red build (docs/localization.md,
* "Tooling and guardrails").
*/
export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
const enKeys = flattenKeys(en);
return Object.entries(locales).flatMap(([locale, catalog]) => {
const ownKeys = new Set(flattenKeys(catalog));
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
});
};
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
import { findMissingKeys } from './parity';
import type { TranslationKeys, TranslationMap } from './types';
describe('resolveKey', () => {
const translations: TranslationMap = {
'pt-PT': { greeting: 'Olá {{name}}' },
'en-GB': { greeting: 'Hello {{name}}', onlyInEnglish: 'English only' },
};
test('resolves from the active locale', () => {
expect(resolveKey(translations, 'pt-PT', 'greeting', 'en-GB')).toBe('Olá {{name}}');
});
test('falls back to the fallback locale on a miss', () => {
expect(resolveKey(translations, 'pt-PT', 'onlyInEnglish', 'en-GB')).toBe('English only');
});
test('renders the raw key when even the fallback is missing', () => {
expect(resolveKey(translations, 'pt-PT', 'nowhere', 'en-GB')).toBe('nowhere');
});
test('never walks a chain between two non-fallback locales', () => {
const withThird: TranslationMap = { ...translations, 'pt-BR': {} };
// pt-BR has no key of its own and must NOT inherit pt-PT's — only the fallback locale counts.
expect(resolveKey(withThird, 'pt-BR', 'greeting', 'en-GB')).toBe('Hello {{name}}');
});
test('selects a plural branch via Intl.PluralRules', () => {
const plurals: TranslationMap = {
'en-GB': { files: { one: '{{count}} file', other: '{{count}} files' } },
};
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 1)).toBe('{{count}} file');
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 2)).toBe('{{count}} files');
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 0)).toBe('{{count}} files');
});
});
describe('interpolate', () => {
test('substitutes params', () => {
expect(interpolate('Hello {{name}}', { name: 'Ana' })).toBe('Hello Ana');
});
test('leaves an unknown placeholder untouched', () => {
expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}');
});
test('passes a plain string through with no params', () => {
expect(interpolate('Hello')).toBe('Hello');
});
});
describe('isPluralForms', () => {
test('accepts an open record of CLDR categories', () => {
expect(isPluralForms({ one: 'a', other: 'b' })).toBe(true);
expect(isPluralForms({ other: 'b' })).toBe(true);
});
test('rejects a plain nested catalog node', () => {
expect(isPluralForms({ title: 'a', description: 'b' })).toBe(false);
});
test('rejects non-objects', () => {
expect(isPluralForms('a string')).toBe(false);
expect(isPluralForms(null)).toBe(false);
expect(isPluralForms(['one', 'other'])).toBe(false);
});
});
describe('localeDirection', () => {
test('ltr for English and Portuguese', () => {
expect(localeDirection('en-GB')).toBe('ltr');
expect(localeDirection('pt-PT')).toBe('ltr');
});
test('rtl for Arabic and Hebrew, by language subtag', () => {
expect(localeDirection('ar')).toBe('rtl');
expect(localeDirection('ar-EG')).toBe('rtl');
expect(localeDirection('he-IL')).toBe('rtl');
});
});
describe('findMissingKeys', () => {
test('flags a key en has and a locale lacks', () => {
const en = { common: { save: 'Save', cancel: 'Cancel' } };
const ptPT = { common: { save: 'Guardar' } };
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([{ locale: 'pt-PT', key: 'common.cancel' }]);
});
test('a complete catalog has no gaps', () => {
const en = { common: { save: 'Save' } };
const ptPT = { common: { save: 'Guardar' } };
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
test('a plural-forms leaf counts as one key, not one per category', () => {
const en = { files: { one: '{{count}} file', other: '{{count}} files' } };
const ptPT = { files: { one: '{{count}} ficheiro', other: '{{count}} ficheiros' } };
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
/**
* Type-level, not behavioural: a plural entry must be addressable by its own key and must NOT
* contribute its CLDR categories to the union. Asserted here because nothing else would notice —
* the failure mode is `t('files', { count })` refusing to compile while `t('files.one')` compiles.
*/
describe('TranslationKeys', () => {
test('stops at a plural leaf and still walks plain nesting', () => {
const catalog = {
files: { one: '{{count}} file', other: '{{count}} files' },
button: { save: 'Save', nested: { deep: 'Deep' } },
};
type Key = TranslationKeys<typeof catalog>;
const assertKey = <K extends Key>(k: K) => k;
expect(assertKey('files')).toBe('files');
expect(assertKey('button.save')).toBe('button.save');
expect(assertKey('button.nested.deep')).toBe('button.nested.deep');
// @ts-expect-error a plural category is not a key on its own
assertKey('files.one');
// @ts-expect-error a branch node is not a key
assertKey('button');
});
});
Binary file not shown.
+21 -3
View File
@@ -2,15 +2,33 @@ export type TranslationMap = Record<string, Record<string, unknown>>;
export type InterpolationParams = Record<string, string | number>; export type InterpolationParams = Record<string, string | number>;
/**
* A pluralizable catalog entry — CLDR's category set (Intl.PluralRules), never a fixed
* `{one, other}` shape, because some locales need up to all six (see docs/localization.md D7).
*/
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
export type I18nContextValue = { export type I18nContextValue = {
t: (key: string, params?: InterpolationParams) => string; t: (key: string, params?: InterpolationParams) => string;
locale: string; locale: string;
}; };
/**
* A node that IS a plural entry, so `TranslationKeys` stops at it rather than walking into it.
*
* Without this, `{ one, other }` contributed `foo.one` and `foo.other` to the key union and `t('foo',
* { count })` did not typecheck at all — the plural machinery landed in phase 0 with unit tests, and the
* type gap only surfaced in phase 2 when a real catalog first used one. Mirrors `isPluralForms` at
* runtime: every key a CLDR category, every value a string.
*/
type IsPluralLeaf<T> = keyof T extends Intl.LDMLPluralRule ? (T[keyof T] extends string ? true : false) : false;
export type TranslationKeys<T, Prefix extends string = ''> = T extends string export type TranslationKeys<T, Prefix extends string = ''> = T extends string
? Prefix ? Prefix
: T extends Record<string, unknown> : T extends Record<string, unknown>
? { ? IsPluralLeaf<T> extends true
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>; ? Prefix
}[keyof T & string] : {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
: never; : never;
+32
View File
@@ -0,0 +1,32 @@
import { useContext, useMemo } from 'react';
import { I18nContext } from './I18nProvider';
type DateInput = Date | number | string;
/**
* Date, time and number formatting bound to the active locale, via `Intl` (D7) — the replacement for
* the ~23 `toLocale*()` call sites that otherwise follow the browser's locale and quietly contradict
* the rest of the UI. Moving those call sites is phase 1-3 work; this is just the hook they move to.
*/
export const useFormat = () => {
const { locale } = useContext(I18nContext);
return useMemo(() => {
const date = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, options).format(new Date(value));
const time = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, { timeStyle: 'short', ...options }).format(new Date(value));
const dateTime = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', ...options }).format(new Date(value));
const number = (value: number, options?: Intl.NumberFormatOptions) =>
new Intl.NumberFormat(locale, options).format(value);
const relative = (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) =>
new Intl.RelativeTimeFormat(locale, options).format(value, unit);
return { date, time, dateTime, number, relative };
}, [locale]);
};
+1
View File
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"hooks": "workspace:*", "hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*", "state": "workspace:*",
"widgets": "workspace:*" "widgets": "workspace:*"
} }
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react'; import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/sonner'; import { toast } from '@/components/ui/sonner';
@@ -12,7 +12,12 @@ import { useChat, EmbeddableChat } from '../Chat';
import { DirPickerModal } from '../ChatHistory/DirPickerModal'; import { DirPickerModal } from '../ChatHistory/DirPickerModal';
import type { ChatMessage } from '../Chat/types'; 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,
useCommandHqChildren,
useCommandHqCommander,
useCommandHqCommanderActions,
} from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format'; import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide'; import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
@@ -96,6 +101,10 @@ const startBrief = (name: string): string =>
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.', 'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
'If you CANNOT — ask me what you should be watching for here. One question, and wait.', 'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
'', '',
'Once you are running, you owe me exactly two messages here per event, and I should never have to ask',
'for them: one line when the thing you watch for happens, and a short report when the work is done.',
'The job panel is not where I read things. The skill explains how to wake this conversation yourself.',
'',
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag', 'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,', 'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.', 'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
@@ -386,6 +395,62 @@ function NewCommander({
); );
} }
/**
* The agents this commander spawned, each a link to its own transcript.
*
* A spawned agent has always been fully readable — it writes an ordinary transcript under the project
* directory for its cwd, and `/chat/<id>` renders it live like any other session. What was missing was
* anybody being told the id existed. The job panel showed its output as a text stream in a small box with
* no link, so the only way to find the session was to know to go looking.
*
* Renders nothing when there are none, rather than an empty state. A commander that has not fired yet is
* the normal case and does not need a box explaining that.
*/
const SpawnedChildren = ({ sessionId }: { sessionId: string }) => {
const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId);
if (!children.length) return null;
return (
<div className="shrink-0 border-b border-border px-4 py-2">
<div className="flex items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Spawned</span>
<span>{children.length}</span>
{/*
Cost is stated as a floor when any child's is unknown, never as a confident total. A commander's
line reading $0.28 for a night that spent several dollars is the specific understatement this
exists to stop.
*/}
{totalUsd > 0 && (
<span>
· ${totalUsd.toFixed(2)}
{unknownCost ? '+' : ''}
</span>
)}
{unknownCost > 0 && (
<span title={`${unknownCost} child${unknownCost === 1 ? '' : 'ren'} did not report a cost`}>
· {unknownCost} unknown
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{children.map((c) => (
<Link
key={c.spawnedSessionId}
to={`/chat/${c.spawnedSessionId}`}
title={`spawned ${new Date(c.firstSeenAt).toLocaleString()}${c.finishedAt ? `, finished ${new Date(c.finishedAt).toLocaleString()}` : ' — still running'}`}
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-foreground/80 hover:border-primary/50 hover:text-foreground"
>
{c.spawnedSessionId.slice(0, 8)}
{!c.finishedAt && (
<span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-duck-orange align-middle" />
)}
</Link>
))}
</div>
</div>
);
};
type MetaBarProps = { type MetaBarProps = {
sessionId: string; sessionId: string;
title: string | undefined; title: string | undefined;
@@ -572,6 +637,7 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
isGenerating={chat.isGenerating} isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession} onDisconnect={chat.disconnectSession}
/> />
<SpawnedChildren sessionId={sessionId} />
<EmbeddableChat <EmbeddableChat
chat={chat} chat={chat}
sessionId={undefined} sessionId={undefined}
@@ -102,3 +102,39 @@ export function useCommandHqCommanderActions(sessionId: string) {
return { setCategory, rename, disarmWatcher, deleteCommander }; return { setCategory, rename, disarmWatcher, deleteCommander };
} }
export type SpawnedChild = {
spawnedSessionId: string;
firstSeenAt: string;
finishedAt: string | null;
totalUsd: number | null;
outcome: string | null;
};
/**
* The agents this commander spawned.
*
* The backend ingests the loop's record file on every read, so calling this often is free and there is no
* timer to stop working quietly. Which is the reason this hook exists at all: the endpoint and the table
* were built, and nothing ever called them — so the table sat empty and a spawned agent stayed invisible
* even though its transcript was sitting there, readable, the whole time.
*/
export function useCommandHqChildren(sessionId: string | undefined) {
const client = useClient();
const { data, isLoading } = useQuery<{ children: SpawnedChild[]; totalUsd: number; unknownCost: number }>({
queryKey: ['command-hq', 'children', sessionId ?? ''],
queryFn: () => client.get(`/command-hq/commanders/${sessionId}/children`),
enabled: !!sessionId,
// A spawn appears while the panel is open, so a stale window here means watching a stale list during
// exactly the minutes somebody is watching.
staleTime: 0,
refetchInterval: 15_000,
retry: false,
});
return {
children: data?.children ?? [],
totalUsd: data?.totalUsd ?? 0,
unknownCost: data?.unknownCost ?? 0,
isLoading,
};
}
+2 -2
View File
@@ -1,5 +1,7 @@
export * from './hooks'; export * from './hooks';
export * from './channels'; export * from './channels';
// The panel apps' own i18n catalog — I18nBridge merges it into the app-wide map under `panel.*`.
export { panelCatalogs } from './locales';
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence. // For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
export { usePageTitleOverride, usePublishPageTitle } from './page-title'; export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title'; export type { PageTitleOverride } from './page-title';
@@ -49,13 +51,11 @@ export type {
CommandHqCostSummary, CommandHqCostSummary,
} from './apps/CommandHQ'; } from './apps/CommandHQ';
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need // /calendar and /contacts select with a query param rather than a path segment, so what the screens need
// from the app is the param name and the link builder. // from the app is the param name and the link builder.
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared'; export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared'; export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
export { export {
useFilesAPI, useFilesAPI,
useTasks, useTasks,
@@ -0,0 +1 @@
{}
@@ -0,0 +1,8 @@
import type { TranslationMap } from 'i18n';
import enGB from './en-GB.json';
import ptPT from './pt-PT.json';
/** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
export { useTranslation } from './useTranslation';

Some files were not shown because too many files have changed in this diff Show More