Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .claude/skills/objectui-contributor/guides/console-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,14 @@ The contract for "add a new metadata type" is therefore mostly *not* a UI change

### MetadataResourceConfig — the override surface

Every field is optional. The ones you reach for most (see `registry.ts` for the full
~30-field interface, which is documented field by field):
Every field except `type` is optional. The fence below is a deliberately
SIMPLIFIED teaching copy of the published `MetadataResourceConfig`
(`import type { MetadataResourceConfig } from '@object-ui/app-shell';`), KEPT
rather than replaced by an import: it shows the 22 fields you reach for most out
of the **28** the interface declares, and the excerpt is what makes the section
usable. Nothing checks the copy against the real type — `registry.ts` documents
all 28 field by field, and the six not shown here are `hiddenFields`,
`fieldOrder`, `createSeed`, `createSchema`, `createHint` and `emptyStateHint`.

```typescript
interface MetadataResourceConfig {
Expand Down Expand Up @@ -280,6 +286,13 @@ item, or nothing at all is the answer more often than a bespoke page is.
type NavigationContextType = 'home' | 'app';
```

⚠️ Unlike every other type quoted in this guide, `NavigationContextType` is
INTERNAL: `@object-ui/app-shell` exports only `.` (→ `dist/index.d.ts`) and
`./styles.css`, and its entry re-exports `useNavigationContext` but not this
type. So there is no import a consumer can write for it — a contributor reaches
it through the source path above, and the fence is a quotation of that file
rather than a copy standing in for a published name.

- `HomeLayout` sets context to `'home'` → sidebar shows workspace nav
- `ConsoleLayout` sets context to `'app'` → sidebar shows app-specific nav

Expand Down
58 changes: 28 additions & 30 deletions scripts/check-skill-examples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -592,13 +592,8 @@ const REMEDY_FLOOR =
* would keep covering a site nobody has looked at since.
*
* The corpus at objectui#7463's branch point was FOUR rows, measured under
* `--measure` before the assertion was armed. One is left, and it is not a
* mechanical unmark:
*
* - `plugin-development.md:92` `defaultValue` — the guide is FAITHFUL prose,
* not rot. `ComponentInput.defaultValue` really is `any` in
* `packages/types/src/base.ts`, so the honest fix is to the platform type,
* not to the guide restating it. Fixing the guide alone would make it lie.
* `--measure` before the assertion was armed. All four are retired, and none of
* them by a mechanical unmark.
*
* The three `testing.md` rows were retired by objectui#7494, which answered the
* skills judgement each of them was waiting on and taught the honest idiom in
Expand All @@ -608,11 +603,19 @@ const REMEDY_FLOOR =
* field through a cast. Both fences stay MARKED; the rows went in the same
* commit as the guide edit, because a row whose red is gone fails as STALE.
*
* The fourth — `plugin-development.md:92` `defaultValue` — was retired the same
* way by the fence repair. Its reason had gone out of date under it: the row
* said the guide was FAITHFUL prose because `ComponentInput.defaultValue`
* "really is `any`", and it is not — the platform type has carried it as an
* ADR-0049 retirement tombstone (`defaultValue?: never`) for some time, so the
* guide's `any` was rot after all, and the honest fix WAS to the guide. That
* fence now imports `ComponentInput` instead of re-declaring it, which removes
* the `any` along with the copy. The list is empty; an empty shrink-only list is
* the terminal state it is shaped for, not a disabled one.
*
* @type {ReadonlySet<string>}
*/
export const KNOWN_BARE_ANY_EXAMPLES = new Set([
'skills/objectui/guides/plugin-development.md:92 property `defaultValue`',
]);
export const KNOWN_BARE_ANY_EXAMPLES = new Set([]);

/** The baseline key for one bare-`any` finding at one site. */
export function bareAnyRowKey(block, finding) {
Expand Down Expand Up @@ -959,30 +962,25 @@ export function shadowCandidates(blocks) {
* because the name is IN the key: the old row goes stale and the new name
* arrives undeclared, so one edit reds twice and says both halves.
*
* The three rows below were measured under `--measure` on this card's branch
* Four rows were seeded here, measured under `--measure` on this gate's branch
* point (`fedfa3e4a`), where the census found 3 of the 13 marked fences
* shadowing a published name — 23% of everything this gate judges.
* shadowing a published name — 23% of everything this gate judges. ALL FOUR ARE
* RETIRED: objectui#8335 gave each of those three fences one of the per-fence
* judgements objectui#7646's triage laid down, and all three came out as
* IMPORT, in the same commit as this list shrank to nothing. What the rows
* bought was measurable at the moment they went: each of the three copies had
* drifted, and two of them held claims that were flatly false against the
* published surface — `AuthUser` still taught a `roles` member ADR-0090 D3 had
* renamed away, and `ComponentInput` declared `label`, `defaultValue` and
* `advanced` as writable where all three are `never` tombstones.
*
* An EMPTY list is the terminal state this shape is for, not a disabled one:
* the assertion is unchanged and a new offender still reds on sight. The next
* marked fence that re-declares a published name has no row to hide behind.
*
* @type {ReadonlyMap<string, string>}
*/
export const KNOWN_SHADOWED_PUBLISHED_TYPES = new Map([
[
'skills/objectui/guides/auth-permissions.md:59 AuthUser',
"the section is titled '### AuthUser type' and then re-declares it instead of importing it from @object-ui/auth; repair owned by objectui#8335 (governed surface, human merge)",
],
[
'skills/objectui/guides/data-integration.md:81 QueryResult',
"the section is titled '### QueryResult' and then re-declares it instead of importing it from @object-ui/types; repair owned by objectui#8335 (governed surface, human merge)",
],
[
'skills/objectui/guides/plugin-development.md:92 ComponentInput',
'the fence documents ComponentInput including its five ADR-0049 retirement tombstones and is the specimen objectui#7636 paid for — a private copy that was WRONG and green for its whole life; repair owned by objectui#8335 (governed surface, human merge)',
],
[
'skills/objectui/guides/plugin-development.md:92 ComponentInputControlType',
'the same fence re-declares the control-type union ComponentInput.type is annotated with; repair owned by objectui#8335 (governed surface, human merge)',
],
]);
export const KNOWN_SHADOWED_PUBLISHED_TYPES = new Map([]);

/** The ledger key for one shadowed published name in one fence. */
export function shadowedTypeRowKey(block, name) {
Expand Down
10 changes: 10 additions & 0 deletions skills/objectui/guides/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ integration package to install. Day-to-day schema authoring is

Every node in the UI tree follows this shape — enforce it on every input.

⚠️ The fence below is a deliberately SIMPLIFIED teaching copy of the published
`BaseSchema` (`import type { BaseSchema } from '@object-ui/types';`), KEPT
rather than replaced by an import: the published interface declares **21**
members plus an index signature, and this section teaches the SHAPE, not the
member list. Two consequences before you go looking in the `.d.ts`: nothing
checks the copy against the real type, and
`props` / `events` below are **not declared members** — they reach a node
through `BaseSchema`'s `[key: string]: any` index signature, which is why you
will not find them in the published member list.

```typescript
// @object-ui/types — abridged; the full BaseSchema member list is packages/types/src/base.ts
interface BaseSchema {
Expand Down
32 changes: 23 additions & 9 deletions skills/objectui/guides/auth-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,32 @@ function UserBadge() {

<!-- os:check -->
```typescript
interface AuthUser {
id: string;
name: string;
email: string;
image?: string;
role?: string;
roles?: string[];
emailVerified?: boolean;
[key: string]: unknown;
import type { AuthUser } from '@object-ui/auth';

// Imported, not re-declared. The old fence here kept a private copy of this
// type; the copy could not drift loudly, so it went on teaching a member the
// package had already retired. An import cannot do that: a member that goes
// away upstream stops compiling here on the day it goes.
function isTenantAdmin(user: AuthUser): boolean {
return user.tenantId !== undefined && (user.positions ?? []).includes('admin');
}
```

`AuthUser` extends the spec's `IAuthService` principal — `id`, `email`, `name`,
plus the `positions` / `tenantId` an authorization decision reads — and adds the
display-only fields better-auth returns to the client: `image`, `role`,
`emailVerified`. The index signature (`[key: string]: unknown`) is deliberate:
better-auth projects an app's own user columns onto this object, and no local
type can enumerate them.

⛔ **There is no `roles` member.** The hand-copied `roles?: string[]` mirror was
RETIRED with objectui#5424 (maintainer ruling 2026-08-22) once its last reader —
`AuthGuard`'s `requiredRoles` check — moved to `positions`. Framework ADR-0090
D3 renamed `roles` → `positions` with no deprecation window, and the protocol-17
session face emits no `roles` key at all. Reading `user.roles` now resolves
through the index signature as `unknown` rather than as `string[]`. ⛔ Do not
re-declare it, and do not re-emit it as a compatibility shadow of `positions`.

### AuthGuard (route protection)

```typescript
Expand Down
65 changes: 44 additions & 21 deletions skills/objectui/guides/data-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ Components never import fetch libraries directly. They access data through `useD

## DataSource interface

Defined in `packages/types/src/data.ts`:
⚠️ The fence below is a deliberately SIMPLIFIED teaching copy, kept rather than
replaced by an import: the published `DataSource` has **38** members and the
excerpt is what makes this section readable. The real one is
`import type { DataSource } from '@object-ui/types';` (source:
`packages/types/src/data.ts`) — read it before you rely on any member's exact
signature, because nothing checks the copy below against it.

```typescript
interface DataSource<T = any> {
Expand Down Expand Up @@ -61,35 +66,53 @@ that do **not** exist: there is no `saveView` (write through `updateViewConfig`
### QueryParams

```typescript
interface QueryParams {
$select?: string[]; // SELECT specific fields
$filter?: Record<string, any> | FilterArray; // WHERE conditions (FilterArray: @objectstack/spec/data)
$orderby?: string | Record<string, 'asc' | 'desc'> | string[] | Array<{ field: string; order?: 'asc' | 'desc' }>;
$skip?: number; // OFFSET (for pagination)
$top?: number; // LIMIT (page size)
$expand?: string[]; // JOIN/expand related objects
$search?: string; // free-text search term
$searchFields?: string[]; // fields the search term is matched against
$count?: boolean; // ask the backend for `total`
[key: string]: any; // why an unprefixed `limit` type-checks — and is then dropped
}
import type { QueryParams } from '@object-ui/types';

// Imported, not re-declared: the nine keys below are the whole published type,
// so a private copy could only be a second, slower-moving answer.
const params: QueryParams = {
$select: ['name', 'email'], // SELECT specific fields
$filter: { status: 'active' }, // WHERE conditions (or a FilterArray from @objectstack/spec/data)
$orderby: { createdAt: 'desc' },
$skip: 0, // OFFSET (for pagination)
$top: 20, // LIMIT (page size)
$expand: ['owner'], // JOIN/expand related objects
$search: 'acme', // free-text search term
$searchFields: ['name'], // fields the search term is matched against
$count: true, // ask the backend for `total`
};
```

`$orderby` also takes a `string`, a `string[]`, or an
`Array<{ field: string; order?: 'asc' | 'desc' }>` — read the published type for
the full union rather than copying this one arm.

⚠️ `QueryParams` declares those nine keys and **no index signature**. So an
unprefixed `limit` is a compile error at the call site, not a key that
type-checks and is then dropped on the wire. This fence claimed the opposite
while it kept its own copy — which is exactly the drift a copy cannot report.

### QueryResult

<!-- os:check -->
```typescript
interface QueryResult<T = any> {
data: T[]; // Returned data array — required, and NOT named `records`
total?: number; // Total count for pagination
page?: number; // Current page (1-indexed)
pageSize?: number; // Items per page
hasMore?: boolean; // Cursor-based pagination flag
cursor?: string; // Next page cursor
metadata?: Record<string, any>; // Additional metadata
import type { QueryResult } from '@object-ui/types';

// Imported, not re-declared. The fence's old copy was already the WHOLE type,
// member for member, so the copy bought the reader nothing and could only drift.
function summarize(result: QueryResult<{ id: string }>) {
return {
items: result.data, // required, and NOT named `records`
total: result.total ?? result.data.length,
nextCursor: result.hasMore ? result.cursor : undefined,
};
}
```

The whole type is seven members: `data` (required), `total`, `page`
(1-indexed), `pageSize`, `hasMore`, `cursor`, `metadata`. `data` is the one a
mis-remembering adapter gets wrong — it is **not** named `records`.

## Wiring DataSource to SchemaRenderer

### Basic setup
Expand Down
94 changes: 61 additions & 33 deletions skills/objectui/guides/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,34 +90,51 @@ Sixteen keys in total: the eleven on `ComponentMeta` (`@object-ui/types`

<!-- os:check -->
```typescript
type ComponentInputControlType =
| 'string' | 'number' | 'boolean' | 'enum' | 'array' | 'object'
| 'color' | 'date' | 'code' | 'file' | 'slot';

type ComponentInput = {
name: string; // Maps to component prop
import type { ComponentInput, ComponentInputControlType } from '@object-ui/types';

// The eleven control kinds, taken from the published union rather than retyped.
const controlTypes: ComponentInputControlType[] = [
'string', 'number', 'boolean', 'enum', 'array', 'object',
'color', 'date', 'code', 'file', 'slot',
];

// A manifest's inputs, checked against the published type. SIX keys are
// writable — `name`, `type`, `of`, `required`, `enum`, `description`.
const inputs: ComponentInput[] = [
{ name: 'title', type: 'string', required: true, description: 'Heading text' },
// ONE control type, or an ARRAY of them when the input accepts several
// shapes (objectui#3832). Widening the vocabulary is a contract change.
type: ComponentInputControlType | ComponentInputControlType[];
label?: string;
defaultValue?: any;
required?: boolean;
enum?: string[] | Array<{ label: string; value: string }>;
description?: string; // Also where a control hint or a numeric domain goes
advanced?: boolean; // Hide by default in designer

// ADR-0049 RETIREMENT TOMBSTONES (objectui#5905) - DECLARED but UNWRITABLE.
// Authoring one is a `tsc` error here and a named refusal from the Zod
// mirror (`ComponentInputSchema`). They were never read and never published:
// the manifest serializer forwards only `name`, `type`, `required`, `enum`,
// `binding` and `description`, so an authored value was silently dropped.
// Remedy: delete the key and say it in `description`, which IS published.
inputType?: never;
min?: never; max?: never; step?: never;
placeholder?: never; // `BaseSchema.placeholder` is a different key, alive
};
{ name: 'items', type: ['array', 'object'], of: 'object', description: 'Rows to render' },
{ name: 'variant', type: 'enum', enum: ['solid', 'outline'], description: 'Visual style' },
];

// ADR-0049 RETIREMENT TOMBSTONES (objectui#5905) - DECLARED but UNWRITABLE.
// Every key named here is `never` on the published type, so authoring one is a
// `tsc` error and a named refusal from the Zod mirror (`ComponentInputSchema`).
// They were never read and never published: the manifest serializer forwards
// only `name`, `type`, `required`, `enum`, `binding` and `description`, so an
// authored value was silently dropped. Remedy: delete the key and say it in
// `description`, which IS published. A `Pick` rather than a prose list, so a
// tombstone that is deleted upstream stops compiling here instead of lingering.
type ComponentInputTombstones = Pick<
ComponentInput,
| 'label'
| 'defaultValue'
| 'advanced'
| 'inputType'
| 'min'
| 'max'
| 'step'
| 'placeholder' // `BaseSchema.placeholder` is a different key, alive
>;
```

⛔ This fence used to keep a private copy of both names, and the copy was wrong
in four ways at once while every gate stayed green: it declared `label`,
`defaultValue` and `advanced` as WRITABLE where the published type has all three
as `never` tombstones, and it did not carry `of` at all. That is what a
teaching copy of a contract type costs — objectui#7636 paid it first.

### Looking up components

```typescript
Expand Down Expand Up @@ -272,18 +289,29 @@ export function ColorField({

### FieldWidgetComponentProps interface

The seven props a widget author writes, DERIVED from the published type rather
than retyped: the key names are checked against it and the value types come
from it, so this short view cannot drift from the long one.

```typescript
type FieldWidgetComponentProps<T = any> = {
value: T; // Current field value
onChange: (val: T) => void; // Value change callback
field: FieldMetadata; // Field metadata (name, label, type, etc.)
readonly?: boolean; // Read-only mode
disabled?: boolean; // HTML disabled state
className?: string; // Tailwind CSS classes
error?: string; // Active validation message — drive `aria-invalid` with it
};
import type { FieldWidgetComponentProps } from '@object-ui/fields';

type WidgetAuthorProps<T = string> = Pick<
FieldWidgetComponentProps<T>,
| 'value' // Current field value — typed `T`
| 'onChange' // Value change callback — `(val: T) => void`
| 'field' // Field metadata (name, label, type, etc.)
| 'readonly' // Read-only mode
| 'disabled' // HTML disabled state
| 'className' // Tailwind CSS classes
| 'error' // Active validation message — drive `aria-invalid` with it
>;
```

A widget signature normally names the whole type, as the example above does
(`FieldWidgetComponentProps<string>`); the `Pick` here exists to show WHICH
seven a widget author actually writes without keeping a second copy of them.

The slot is named `error` because that is what `FieldWidgetPropsSchema` in
`@objectstack/spec/ui` — the published widget contract — calls it.

Expand Down
9 changes: 9 additions & 0 deletions skills/objectui/rules/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ These top-level schema fields are passed as raw strings:

Every UI component node MUST follow this shape:

⚠️ A deliberately SIMPLIFIED teaching copy of the published `BaseSchema`
(`import type { BaseSchema } from '@object-ui/types';`), KEPT rather than
replaced by an import: the published interface declares **21** members plus an
index signature, and this rule is about the SHAPE, not the full member list.
Nothing checks the copy against the real type. `properties`, `props` and
`events` are **not declared members** — they reach a node through
`BaseSchema`'s `[key: string]: any` index signature, which is exactly why the
two rules below have to state who reads each of them.

```typescript
interface BaseSchema { // abridged — full member list: packages/types/src/base.ts
type: string; // Required: component type identifier
Expand Down
Loading