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.
npm install @fougere/admin react-admin
pnpm add @fougere/admin react-admin
bun add @fougere/admin react-admin
yarn 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 facade — not because a file was written.
What each screen is read from
| Screen | Read from | Function |
|---|---|---|
| The menu | card.fronds[].facades[] | a facade whose list answers no entity 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 reference | the relation role, then the target's first text() | field.reference, searched twenty at a time |
| A row's identity | the primary role | FieldSet.of(fields).primary |
| The buttons | facade.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
fronds: { billing: 'https://billing.internal' }
The invoice facade 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 facade
Every screen above renders a facade. Topology renders the app:
catalog here 1 entity · 1 facade → blog (12)
blog elsewhere its shape is published by the process that owns it ← catalog (12)
search declared, unheard declared at search.internal:4500 — nothing has answered from it
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. None of that
is declared: a Frond is elsewhere because it answered, never because fronds: said so.
The last line is the one the counted half cannot hold. A Frond that never answered is absent
from it, so the page would show a smaller healthy system exactly when a node is down — it
comes from the report's declared half, which counts nothing and is never mixed with the
rest. An unheard Frond is not an opaque one: opaque means it answered and keeps its shape to
itself.
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 facade in the browser while the façade
still serves it reads as a permission and enforces nothing. What a facade 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 — validates, 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 the handler writes itself and that answers an array carries no total: an array has
nowhere to hold it. Crud's list answers a page — { items, total } when the provider asks
for a count, which it does — and that crosses whole, split or not. The provider does not depend
on it either way: it asks for one row more than the page needs and reports pageInfo, so
pagination is exact, and only a row count in a header may be absent.
Next: The gradient — moving a Frond out of process.