Replace the invented area axis with grain and platform

The area taxonomy was wrong, and wrong in a way worth recording. It was
derived from Mobbin-style categories in the abstract rather than from what
the skill can be asked for, and measured against the catalog most of it
described problems that were not there: onboarding, settings, empty-state and
search each had zero entries.

Reframed against demand instead. A user asks for a docs site, an onboarding
flow, a landing page, or a data table, and those differ in how much of the
product is in play. Register already says what kind of work it is; grain says
how much: product, flow, view, region.

Named grain rather than scope because scope already means direction-or-surface
on every roll and 'surface' is already a register value, so a scope of
'surface' would have collided with both.

Platform is the second axis: web, ios, android. Unlike grain it is a hard
filter with no fallback, because a composition that leans on hover or a
pointer does not degrade on a phone into something slightly worse, it stops
working, and an empty deal is a visible gap where a broken one is not.

Both fields are optional and absence means eligible everywhere, so nothing
needs backfilling and no existing roll changes.

The third piece is the one a trace turned up. Asking for an onboarding flow
resolves to register=operate, grain=flow, and the catalog holds zero
flow-grain compositions, so the top-up would have dealt three plausible
single-screen compositions with no signal that none matched. The model would
have improvised the flow structure while believing it was handed one, which is
the same silent plausibility the axis exists to remove. Selection now returns
a match alongside the picks, and the rendered seed says when the structure is
borrowed and why.

Measured at the time of writing: 137 of 173 approved compositions are view
grain, product grain is empty, flow grain holds one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-29 17:36:43 -07:00
co-authored by Claude Opus 5
parent 48e665edf2
commit 86b91a2003
4 changed files with 229 additions and 108 deletions
+21 -11
View File
@@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs';
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
// Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file
// reads the filesystem, and the roll API imports the taxonomy to validate its
// area parameter. Re-exported so existing importers keep working.
import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs';
export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS };
// grain and platform parameters. Re-exported so importers have one place to look.
import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs';
export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform };
// Catalog B: compositions rather than styles. A composition organizes attention,
// sequence, or manipulation on a surface and must survive being dressed in
@@ -63,14 +63,24 @@ export function validateCompositionEntry(composition, { existingForms = new Map(
if (!COMPOSITION_SURFACES.has(composition?.surface)) {
errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`);
}
// Optional, but an area from the wrong surface is a mistake rather than a
// looser tag: it would make the entry unreachable by every real request.
if (composition?.area !== undefined && composition.area !== null) {
const allowed = areasForSurface(composition.surface);
if (!allowed.includes(composition.area)) {
errors.push(
`composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})`
);
// Grain: how much of the product this composes. Optional, and absence means
// eligible at any grain, so nothing needs backfilling.
if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) {
errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`);
}
// Platforms this composition survives. Absence means all of them, so listing
// every platform is the same as omitting the field and is rejected in favour of
// leaving it out; an empty array would exclude the entry from every roll.
if (composition?.platforms !== undefined && composition.platforms !== null) {
const list = composition.platforms;
if (!Array.isArray(list) || list.length === 0) {
errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`);
} else if (list.some(entry => !isPlatform(entry))) {
errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`);
} else if (new Set(list).size !== list.length) {
errors.push(`composition ${id} platforms must not repeat a platform`);
} else if (list.length === COMPOSITION_PLATFORMS.length) {
errors.push(`composition ${id} platforms lists every platform; omit the field instead`);
}
}
if (!Array.isArray(composition?.tags)