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

ScreenRead fromFunction
The menucard.fronds[].doors[]a door with no schema is not furniture
A list's columnsVisibility.output + the shapetableColumnsOf
A form's inputsVisibility.input + the lifecycle axisformFieldsOf
Its boundsshapeminlength, max, patternfield.attrs
A row's identitythe primary roleFieldSet.of(fields).primary
The buttonsdoor.opsno create op, no create button
A refusalVALIDATION_FAILEDerrorsByField, per field
Topologyrpc.topologythe 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 namestitle, 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/admin and 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.

Built with Fougere — this site runs on the framework it documents.