Back-office
A back-office asks four questions: which subjects exist, what a row shows, what a form
supplies, and which verbs are allowed. The identity card already answers all four, so
@fougere/admin generates nothing and declares nothing — it reads rpc.discover at load
time and renders the answer.
That is the difference from a CMS. Payload, Strapi and Directus sell the derivation of an admin from a content declaration; here the declaration was already there, doing five other jobs, and the panel is its sixth projection.
pnpm add @fougere/admin react-admin
// app/admin.tsx — the whole file
import { FougereAdmin } from '@fougere/admin/react';
export default () => <FougereAdmin />;
Three lines, every entity, forever. A new class Invoice extends entity({…}) appears in
the menu on the next load, because the card grew a door — not because a file was written.
What each screen is read from
| Screen | Read from | Function |
|---|---|---|
| The menu | card.fronds[].doors[] | a door with no schema is not furniture |
| A list's columns | Visibility.output + the shape | tableColumnsOf |
| A form's inputs | Visibility.input + the lifecycle axis | formFieldsOf |
| Its bounds | shape → minlength, max, pattern | field.attrs |
| A row's identity | the primary role | FieldSet.of(fields).primary |
| The buttons | door.ops | no create op, no create button |
| A refusal | VALIDATION_FAILED | errorsByField, per field |
| Topology | rpc.topology | the one screen the card does not answer |
tableColumnsOf and formFieldsOf are duals of one another and are the same pair
Forms already uses — one reads what may leave, the other what a
client may supply. The panel adds no third reading.
The gradient reaches it
The panel talks to the call endpoint and knows nothing else. So this changes nothing about it:
// fougere.config.ts
remotes: { billing: 'https://billing.internal' }
The invoice door moves to another process, the card still announces it, the same build
renders the same screens. A headless CMS cannot do this — its admin is bound to its
own API by construction, and the process boundary is the product.
Facets — declared, never guessed
Some things a renderer wants are not in the shape. oneOf('draft', 'published') says the
set is closed; it never says which member means published. That is a real statement
about a domain, and a human makes it:
import { defineAdminExtension } from '@fougere/admin';
defineAdminExtension({
resource: 'post',
facets: {
editorial: {
title: 'title',
state: { field: 'status', draft: ['draft'], published: ['published'] },
},
},
})
Nothing is inferred, and that is deliberate. An earlier version read the field names —
title, status, ['draft', 'pending', 'review'] — and an entity spelling titre or
brouillon lost its facet with no message. Fougere recognises a field by its form,
never by a word, and a silent wrong answer is worse than an absent one. With no facet
declared, the derived table is still the whole back-office.
Extensions are deltas
An extension names only what it changes. It never snapshots a resource, so a field added to the entity tomorrow still appears:
defineAdminExtension({
resource: 'post',
label: 'Articles',
fields: { internalNote: { hidden: true }, title: { label: 'Headline' } },
operations: { publish: { confirm: 'Publish this article?' } },
})
For anything finer, one renderer replaces one field, and every React Admin prop still
works — theme, layout, authProvider, i18nProvider, dashboard:
<FougereAdmin
renderers={{ fields: { 'user.role': ({ column }) => <ChipField source={column.name} /> } }}
resourceComponents={{ post: { list: MyPostList } }}
/>
It speaks no language
Every visible sentence goes through a key: fougere.admin.* for the panel's own widgets,
entity.field for a field — the convention formFieldsOf states, with the derived name
as its fallback. So an untranslated admin is English, and a i18nProvider filling those
keys is the whole translation.
polyglotI18nProvider(() => ({
...frenchMessages,
fougere: { admin: { overview: { title: "Vue d'ensemble" } } },
post: { title: 'Titre', status: 'Statut' },
}), 'fr')
The one page that is not a door
Every screen above renders a door. Topology renders the app:
catalog here 1 entity · 1 door → blog (12)
blog elsewhere its shape is published by the process that owns it ← catalog (12)
It is read from rpc.topology, not from the card — the card says what a process hosts,
this says what shape it is in: which Fronds run in the process the panel is talking to,
which answered from somewhere else, and every call path observed between them. Nothing in
it is declared: a Frond is remote because it answered, never because remotes: said so.
That op is served by @fougere/observability, so a panel
pointed at an app that does not observe itself gets a refusal — and the page says which two
lines are missing. The menu entry stays either way: hiding it would hide the one place that
can explain the absence.
What it will not do
- Remove a resource or an operation. Hiding a door in the browser while the façade
still serves it reads as a permission and enforces nothing. What a door serves to one
audience is what a surface states — point the panel at
/_fougere/call/adminand the card answers restricted. A field may still be hidden: that changes nothing about what is reachable. - Write outside the façade. It runs in a browser, so it can only reach the call endpoint — judges, presenters and middlewares are on the path of every read and write it makes. This is a consequence, not a precaution.
- Aggregate. Storage's only aggregate is
count, so the dashboard counts rows and says so. "Revenue over thirty days" is an operation to write, in a repository; the panel renders its result, it does not invent the query. - Own your pages. It is a panel for whoever administers the data. The application's own screens stay yours, built on queries and commands.
One known gap
A list's total does not survive the wire: ListResult extends Array, and
JSON.stringify drops an array's properties. The provider does not depend on it — it asks
for one row more than the page needs and reports pageInfo — so pagination is exact.
A row count in a header may be absent under a split.
Next: The gradient — moving a Frond out of process.