Compare commits

...
Author SHA1 Message Date
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
72 changed files with 1593 additions and 582 deletions
+3
View File
@@ -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,
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
- `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
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
+2
View File
@@ -210,6 +210,7 @@
"dotenv": "^16.4.7",
"embla-carousel-react": "^8.5.1",
"hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.4",
@@ -271,6 +272,7 @@
"version": "0.0.1",
"dependencies": {
"hooks": "workspace:*",
"i18n": "workspace:*",
"state": "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
also about **5% deployed**, and that gap is the whole job.
| | |
| --- | --- |
| `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/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
| | |
| ----------------------------------------- | -------------------------------------------------------------------------- |
| `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/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
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
@@ -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
runtime and unused.
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
from `pt`.
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`:
| area | files | occurrences |
| --- | --- | --- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
| area | files | occurrences |
| --------------------- | ------- | ----------- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
**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
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
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,
and it is the owner's own language, so getting it wrong is felt immediately.
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
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.
```
pt-BR → pt → en
en-GB → en
```
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
carried the shared bulk and a regional file carried only the differences. That is the conventional design
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.**
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
differently. Two near-identical full catalogs would drift within a month.
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
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.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
widening on read over a database migration — a stale bare code should resolve, not fail.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
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
@@ -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
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.
### D5. Keys, not English source text
@@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
- `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory.
- `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:
@@ -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.
- **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
names, database values.
- **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
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
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
one guard that stops a locale silently degrading into English.
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
`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
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.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
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.
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.
+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",
"version": "2026.08.23.02",
"version": "2026.08.23.03",
"private": true,
"type": "module",
"workspaces": [
@@ -30,7 +30,7 @@
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
"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",
"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",
"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",
+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
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
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
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
+67 -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
* 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$/;
// 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 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<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
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') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
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.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
@@ -62,9 +81,29 @@ function isHuman(value: string | undefined): value is string {
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.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;
}
/**
* 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, '');
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
@@ -96,7 +135,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++;
let source: string;
try {
source = readFileSync(file, 'utf8');
source = stripComments(readFileSync(file, 'utf8'));
} catch {
continue;
}
@@ -118,7 +157,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
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) {
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)}`);
@@ -128,9 +169,19 @@ const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.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(`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(` needing params ${interpolated.size}`);
@@ -149,10 +200,16 @@ if (listPrefix) {
}
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) {
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.');
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));
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales';
const LANGUAGES = [
{ code: 'en', label: 'English' },
@@ -56,7 +57,8 @@ export const Languages = () => {
const removeSpoken = (code: string) => {
const next = spoken.filter((s) => s !== code);
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';
save(updates);
};
@@ -104,7 +106,7 @@ export const Languages = () => {
)}
</div>
{/* Default language */}
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select
@@ -112,9 +114,9 @@ export const Languages = () => {
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"
>
{spoken.map((code) => (
{SHIPPED_LOCALES.map(({ code, label }) => (
<option key={code} value={code}>
{getLabel(code)}
{label}
</option>
))}
</select>
+30 -5
View File
@@ -1,11 +1,30 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react';
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 en from '../locales/en.json';
import pt from '../locales/pt.json';
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
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 = {
children: ReactNode;
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
const { settings } = useSettings();
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 (
<I18nProvider translations={translations} locale={locale}>
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
{children}
</I18nProvider>
);
+23 -3
View File
@@ -1,12 +1,32 @@
import type { TranslationKeys } from 'i18n';
import type { InterpolationParams } from 'i18n';
import { useCallback } from 'react';
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 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 = () => {
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';
@@ -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([]);
});
});
+1
View File
@@ -41,6 +41,7 @@ export * from './anthropic-accounts';
export * from './theme-favorites';
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
export * from './sendoff';
export * from './roles';
export * from './command-hq';
export * from './dashboards';
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 './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 './roles/schema'; // roles
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
+7 -3
View File
@@ -1,11 +1,15 @@
import { Input } from '@/components/ui/input';
import { useTranslation } from '@/components/locales';
type ColorPickerProps = { value?: string; name: string; label?: string };
export const ColorPicker = (props: ColorPickerProps) => {
const { value = '#000000', name, label } = props;
const { t } = useTranslation();
return (
<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="h-12 overflow-hidden rounded-full p-0">
<Input
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
className="absolute h-12 w-24 rounded-full border-0 text-lg"
type="color"
name={name}
placeholder="Font Weight"
/>
</div>
<div>
<Input
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}
placeholder={t('colorPicker.placeholder')}
/>
</div>
</div>
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/components/locales';
type CommandBlockProps = {
label?: string;
@@ -9,6 +10,7 @@ type CommandBlockProps = {
};
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = () => {
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
return (
<div className="inline-flex flex-col gap-1">
{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>
{onRun && (
<button
type="button"
onClick={() => onRun(command)}
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" />
</button>
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
type="button"
onClick={copy}
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" />}
</button>
+10 -6
View File
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
// Loading, failed, empty. See docs/design-language-interface.md § 6.
//
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
</div>
);
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
<Shell>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label}</p>
</Shell>
);
export const LoadingBlock = ({ label }: { label?: string }) => {
const { t } = useTranslation();
return (
<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
@@ -1,7 +1,21 @@
import type { Column } from './DataTable';
import type { ReactNode } from 'react';
import { describe, expect, test } from 'bun:test';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nProvider } from 'i18n';
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 = {
id: number;
@@ -55,7 +69,7 @@ const TestWrapper = ({
describe('DataTable', () => {
describe('rendering', () => {
test('renders table with data', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
expect(screen.getByRole('table')).toBeDefined();
expect(screen.getByText('Alpha')).toBeDefined();
@@ -63,7 +77,7 @@ describe('DataTable', () => {
});
test('renders column headers', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
// Component renders both desktop and mobile versions (hidden via CSS)
// 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', () => {
render(<TestWrapper pageSize={3} />);
render(withI18n(<TestWrapper pageSize={3} />));
const rows = screen.getAllByRole('row');
// 1 header row + 3 data rows
@@ -82,20 +96,20 @@ describe('DataTable', () => {
});
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.queryByRole('table')).toBeNull();
});
test('shows default no data message', () => {
render(<TestWrapper data={[]} />);
render(withI18n(<TestWrapper data={[]} />));
expect(screen.getByText('No Data Found.')).toBeDefined();
});
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();
});
@@ -106,7 +120,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} columns={columns} />;
};
const { container } = render(<NullWrapper />);
const { container } = render(withI18n(<NullWrapper />));
expect(container.innerHTML).toBe('');
});
});
@@ -118,7 +132,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} />;
};
render(<AutoColumnsWrapper />);
render(withI18n(<AutoColumnsWrapper />));
// Should generate columns from object keys (rendered for both mobile/desktop)
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
@@ -130,7 +144,7 @@ describe('DataTable', () => {
describe('sorting', () => {
test('clicking sortable column header triggers sort', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
// Get the first "Name" element (desktop version)
const nameHeaders = screen.getAllByText('Name');
@@ -143,7 +157,7 @@ describe('DataTable', () => {
});
test('clicking same column twice reverses sort order', () => {
render(<TestWrapper pageSize={10} />);
render(withI18n(<TestWrapper pageSize={10} />));
const nameHeaders = screen.getAllByText('Name');
fireEvent.click(nameHeaders[0]!); // asc
@@ -156,7 +170,7 @@ describe('DataTable', () => {
});
test('sort icon is displayed for sortable columns', () => {
render(<TestWrapper />);
render(withI18n(<TestWrapper />));
// Each sortable column should have the ArrowUpDown icon
const headers = screen.getAllByRole('columnheader');
@@ -169,19 +183,19 @@ describe('DataTable', () => {
describe('pagination', () => {
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();
});
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();
});
test('next button navigates to next page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -192,7 +206,7 @@ describe('DataTable', () => {
});
test('previous button navigates to previous page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -205,7 +219,7 @@ describe('DataTable', () => {
});
test('first button goes to first page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton);
@@ -217,7 +231,7 @@ describe('DataTable', () => {
});
test('last button goes to last page', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const lastButton = screen.getByRole('button', { name: 'Last' });
fireEvent.click(lastButton);
@@ -226,7 +240,7 @@ describe('DataTable', () => {
});
test('navigation buttons are disabled at boundaries', () => {
render(<TestWrapper pageSize={5} />);
render(withI18n(<TestWrapper pageSize={5} />));
const firstBtn = screen.getByRole('button', { name: 'First' });
const prevBtn = screen.getByRole('button', { name: 'Prev' });
@@ -258,7 +272,7 @@ describe('DataTable', () => {
{ 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('Name').length).toBeGreaterThan(0);
@@ -271,7 +285,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: true },
];
render(<TestWrapper cols={conditionalColumns} />);
render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
});
@@ -283,11 +297,11 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: () => showStatus },
];
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.queryByText('Status')).toBeNull();
showStatus = true;
rerender(<TestWrapper cols={conditionalColumns} />);
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
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');
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
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.getByText('Edit Alpha')).toBeDefined();
@@ -351,7 +365,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Name' },
];
render(<TestWrapper cols={tooltipColumns} />);
render(withI18n(<TestWrapper cols={tooltipColumns} />));
// The tooltip indicator (ⓘ) should be present in the ID column headers
const idHeaders = screen.getAllByText(/ID/);
@@ -367,7 +381,7 @@ describe('DataTable', () => {
{ 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)
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
@@ -396,7 +410,7 @@ describe('DataTable', () => {
);
};
render(<SearchableTable />);
render(withI18n(<SearchableTable />));
const searchInput = screen.getByTestId('search-input');
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
@@ -420,7 +434,7 @@ describe('DataTable', () => {
);
};
render(<ExternalSortTable />);
render(withI18n(<ExternalSortTable />));
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 { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const DataTable = <T,>(props: DataTableProps<T>) => {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id';
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
if (rawData.length === 0) {
return (
<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}</>}
</div>
);
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
<span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
{sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
</div>
</TooltipTrigger>
{tooltip && (
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/components/locales';
type PaginationBarProps<T> = {
dataManager: UseDataControlReturn<T>;
};
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
const { t } = useTranslation();
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
{/* LEFT */}
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
<span className="hidden min-[500px]:inline">
Showing page {currentPage} of {pageCount}
{t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
</span>
<span className="min-[500px]:hidden">
p {currentPage}/{pageCount}
{t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
</span>
<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
data-[state=open]:bg-muted
"
aria-label="Rows per page"
aria-label={t('dataTable.pagination.rowsPerPage')}
>
<SelectValue className="text-muted-foreground">
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
<span className="min-[500px]:hidden">{pageSize}/p</span>
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
</SelectValue>
</SelectTrigger>
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
data-[state=checked]:text-muted-foreground
"
>
<span className="hidden min-[500px]:inline">{n} / page</span>
<span className="min-[500px]:hidden">{n}/p</span>
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
</SelectItem>
))}
</SelectContent>
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(1)}
disabled={currentPage === 1}
aria-label="First"
aria-label={t('dataTable.pagination.first')}
>
«
</Button>
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Prev"
aria-label={t('dataTable.pagination.prev')}
>
‹
</Button>
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next"
aria-label={t('dataTable.pagination.next')}
>
›
</Button>
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass}
onClick={() => changePage(pageCount)}
disabled={currentPage === pageCount}
aria-label="Last"
aria-label={t('dataTable.pagination.last')}
>
»
</Button>
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type ConfirmDialogProps = {
open: boolean;
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
export const ConfirmDialog = (props: ConfirmDialogProps) => {
const [confirming, setConfirming] = useState(false);
const { t } = useTranslation();
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 () => {
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">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
{t('button.cancel')}
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={disabled || confirming}
className="sm:ml-2"
className="sm:ms-2"
>
{confirmLabel}
{confirmLabel ?? t('button.confirm')}
</Button>
</div>
</DialogContent>
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import { useTranslation } from '@/components/locales';
type PaperDialogProps = {
open: boolean;
@@ -11,6 +12,8 @@ type PaperDialogProps = {
};
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
const { t } = useTranslation();
const handleOpenAutoFocus = (ev: Event) => {
ev.preventDefault();
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"
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="sr-only">Close</span>
<span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close>
{children}
</DialogPrimitive.Content>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Server Error
{t('error.server.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Unauthorized Access
{t('error.forbidden.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">
Your are not authorized to perform this action.
</DialogDescription>
<DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
</DialogHeader>
<p>If you think this is an error, please contact your manager.</p>
<p>{t('error.forbidden.hint')}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Server Error
{t('error.server.title')}
</DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index';
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
const { signout } = useAuth();
const { t } = useTranslation();
const onOpenChange = (value: boolean) => {
if (!value) {
setApiError(null);
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" />
Invalid Credentials
{t('error.unauthorized.title')}
</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>
<p>Please Signin again</p>
<p>{t('error.unauthorized.hint')}</p>
<DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}>
Ok
{t('button.ok')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,17 +1,14 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
};
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig[size];
return (
<img
src="/officer-logo.svg"
alt="Officer Icon"
style={{ width: config.width, height: 'auto' }}
/>
);
return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
}
const sizeConfig = {
@@ -1,3 +1,5 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
square?: boolean;
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
}
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.regular[size];
return (
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
}
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.square[size];
return (
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
}
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 { Input } from '@/components/ui/input';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
type SearchInputProps = {
className?: string;
@@ -12,7 +13,8 @@ type 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 inputRef = useRef<HTMLInputElement | null>(null);
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
type="button"
variant="outline"
size="icon"
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'}
aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
onClick={() => {
setIsMobileSearchOpen((v) => !v);
if (!isMobileSearchOpen) {
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" />}
</Button>
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
className={cn(
'relative w-fit max-w-lg',
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',
className,
)}
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
{!!value ? (
<button
type="button"
aria-label="Clear search"
aria-label={t('search.clear')}
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" />
</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" />
</span>
)}
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
placeholder={placeholder}
value={value}
onChange={(ev) => handleSearch(ev.target.value)}
className="pl-10 pr-4 text-sm"
className="ps-10 pe-4 text-sm"
/>
</div>
</div>
+3 -1
View File
@@ -1,10 +1,12 @@
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const Select = (props: SelectProps) => {
const { t } = useTranslation();
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;
+3 -1
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from '@/components/locales';
type TabsProps = {
tabList: { value: string; label: string }[];
@@ -8,6 +9,7 @@ type TabsProps = {
setActiveTab: (tab: string) => void;
};
const TabsComponent = (props: TabsProps) => {
const { t } = useTranslation();
const { tabList, activeTab, setActiveTab } = props;
const tabsListRef = useRef<HTMLDivElement>(null);
return (
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
<div className="block lg:hidden">
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Filter" />
<SelectValue placeholder={t('tabs.filterPlaceholder')} />
</SelectTrigger>
<SelectContent>
{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",
"embla-carousel-react": "^8.5.1",
"hooks": "*",
"i18n": "*",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.4",
+3 -1
View File
@@ -1,12 +1,14 @@
import { Moon, Sun } from 'lucide-react';
import { cn } from 'helpers/cn';
import { useColorMode } from './ThemeProvider';
import { useTranslation } from '@/components/locales';
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
export const ThemeToggle = (props: ThemeToggleProps) => {
const { colorDark, colorLight } = props;
const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
const toggle = () => {
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
colorDark && 'text-black',
)}
/>
<span className="sr-only">Toggle color mode</span>
<span className="sr-only">{t('theme.toggle')}</span>
</div>
);
};
+15 -11
View File
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Breadcrumb = React.forwardRef<
HTMLElement,
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
const { t } = useTranslation();
return (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t('breadcrumb.more')}</span>
</span>
);
};
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
+7 -4
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { t } = useTranslation();
return (
<Button
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
className={cn(
'absolute h-8 w-8 rounded-full',
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',
className,
)}
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
<span className="sr-only">{t('carousel.previous')}</span>
</Button>
);
},
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
const { t } = useTranslation();
return (
<Button
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
className={cn(
'absolute h-8 w-8 rounded-full',
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',
className,
)}
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
<span className="sr-only">{t('carousel.next')}</span>
</Button>
);
},
+23 -19
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Dialog = DialogPrimitive.Root;
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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',
className,
)}
{...props}
>
{children}
<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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
>(({ className, children, ...props }, ref) => {
const { t } = useTranslation();
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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',
className,
)}
{...props}
>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
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 { useColorMode } from '@/components/ui/ThemeProvider';
import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, ...props }, ref) => {
const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
return (
<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" />
<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>
);
},
+49 -26
View File
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
const { t } = useTranslation();
return (
<nav
role="navigation"
aria-label={t('pagination.label')}
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
};
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
);
PaginationLink.displayName = '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}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
const { t } = useTranslation();
return (
<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';
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}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
const { t } = useTranslation();
return (
<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';
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
const { t } = useTranslation();
return (
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t('pagination.morePages')}</span>
</span>
);
};
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
+16 -12
View File
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Sheet = SheetPrimitive.Root;
@@ -51,18 +52,21 @@ interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
({ side = 'right', className, children, ...props }, ref) => {
const { t } = useTranslation();
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">{t('button.close')}</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
},
);
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 = {
translations: TranslationMap;
locale: string;
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
fallbackLocale?: string;
children: ReactNode;
};
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
const value = useMemo<I18nContextValue>(() => {
const t = (key: string, params?: InterpolationParams) =>
interpolate(resolveKey(translations, locale, key), params);
const t = (key: string, params?: InterpolationParams) => {
const count = typeof params?.count === 'number' ? params.count : undefined;
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
};
return { t, locale };
}, [translations, locale]);
}, [translations, locale, fallbackLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
+5 -2
View File
@@ -1,4 +1,7 @@
export { I18nProvider, I18nContext } from './I18nProvider';
export { useTranslation } from './useTranslation';
export { resolveKey, interpolate } from './resolve';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types';
export { useFormat } from './useFormat';
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>;
/**
* 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 = {
t: (key: string, params?: InterpolationParams) => 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
? Prefix
: T extends Record<string, unknown>
? {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
? IsPluralLeaf<T> extends true
? Prefix
: {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
: 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": {
"hooks": "workspace:*",
"i18n": "workspace:*",
"state": "workspace:*",
"widgets": "workspace:*"
}
@@ -96,6 +96,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 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',
'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.',
+2 -2
View File
@@ -1,5 +1,7 @@
export * from './hooks';
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.
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
@@ -49,13 +51,11 @@ export type {
CommandHqCostSummary,
} from './apps/CommandHQ';
// /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.
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
export {
useFilesAPI,
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';
@@ -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
// (no panel is converted in this phase) — the test still runs so it is load-bearing from day one.
describe('officerdev (panel.*) locale catalogs', () => {
test('pt-PT has every key en-GB has', () => {
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
});
});
@@ -0,0 +1 @@
{}
@@ -0,0 +1,19 @@
import type { InterpolationParams, TranslationKeys } from 'i18n';
import { useCallback } from 'react';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json';
type PanelTranslationKey = TranslationKeys<typeof en>;
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
// pass the same bare key the JSON file uses.
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: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
[baseT],
);
return { t, locale };
};
+13 -2
View File
@@ -5,6 +5,13 @@ import { useAuth } from 'hooks/useAuth';
const QUERY_KEY = ['USER_SETTINGS'];
/**
* UI locales went from bare codes to regional BCP 47 tags (docs/localization.md D2). A stale bare
* code must resolve rather than fail, and this is a one-time widening on read, not a DB migration.
*/
const WIDEN_BARE_LOCALE: Record<string, string> = { en: 'en-GB', pt: 'pt-PT' };
const widenLocale = (code: string): string => WIDEN_BARE_LOCALE[code] ?? code;
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
ai: {
@@ -14,7 +21,11 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
},
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
languages: {
...DEFAULT_SETTINGS.languages,
...saved.languages,
default: widenLocale(saved.languages?.default ?? DEFAULT_SETTINGS.languages.default),
},
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
});
@@ -121,7 +132,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
},
languages: {
spoken: ['en'],
default: 'en',
default: 'en-GB',
translateTo: 'en',
},
tts: {
+4 -3
View File
@@ -49,9 +49,10 @@ handling.
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
`<form>` can't span two panels without restructuring the wizard.
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a
plain controlled textarea with no submit of its own), so there's nothing to wrap yet.
- `ColorPicker` has zero call sites anywhere in the repo right now; it is shaped to be embedded in a
future caller's own form (uncontrolled inputs with `name` attributes), so there's nothing to wrap yet.
`MarkdownEditor` was named here too and was deleted on 2026-08-23 during the localization pass — it
had no call sites and hardcoded another product's logo URL.
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
obvious Enter target (which of N matches?).