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
// 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

ScreenRead fromFunction
The menucard.fronds[].facades[]a facade whose list answers no entity is not furniture
A list's columnsVisibility.output + the shapetableColumnsOf
A form's inputsVisibility.input + the lifecycle axisformFieldsOf
Its boundsshape → minLength, max, patternfield.attrs
A referencethe relation role, then the target's first text()field.reference, searched twenty at a time
A row's identitythe primary roleFieldSet.of(fields).primary
The buttonsfacade.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
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/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 — 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.