Skip to content

feat: add reusable UI component metadata contracts #46

Description

@artiphishle

Summary

Add provider-neutral UI component metadata contracts to @ankhorage/contracts so core ZORA and standalone ZORA extension packages can expose component metadata through the same stable contract.

Today, @ankhorage/zora owns ZoraComponentMeta, ZoraComponentMetaRegistry, prop schemas, slot metadata, event metadata, and related types internally. That works for core ZORA, but extension packages such as @ankhorage/zora-chessboard need to export metadata with the same shape without depending on ZORA-specific type ownership forever.

Motivation

Standalone ZORA extension packages should be discoverable by tools in the same way as core ZORA components.

Examples:

@ankhorage/zora
@ankhorage/zora-chessboard
@ankhorage/zora-charts
@ankhorage/zora-calendar
@ankhorage/zora-map

Each package should be able to export a metadata registry that satisfies a shared contract.

Current state

@ankhorage/contracts currently contains component event DTO contracts, such as:

ComponentEventDto
ComponentEventDtoKind
KnownComponentEventDto

But it does not currently define the broader UI component metadata contract used by ZORA.

@ankhorage/zora currently owns:

ZoraComponentMeta
ZoraComponentMetaRegistry
ZoraComponentPropSchema
ZoraComponentEventMeta
ZoraComponentSlotMeta

Proposed direction

Move or duplicate-neutralize the generic metadata shape into @ankhorage/contracts with provider-neutral names.

Suggested names:

UiComponentCategory
UiComponentPropType
UiComponentPropValue
UiComponentPropSchema
UiComponentPropArrayItemSchema
UiComponentBlueprint
UiComponentI18nMeta
UiComponentEventPayloadKind
UiComponentEventPayloadFieldType
UiComponentEventPayloadFieldMeta
UiComponentEventMeta
UiComponentSlotMeta
UiComponentMeta
UiComponentMetaRegistry
UiComponentPackageManifest

Exact names can be refined during implementation.

Suggested contracts

The initial shape should closely match current ZORA metadata, but without ZORA-specific naming.

export type UiComponentCategory = 'foundation' | 'component' | 'pattern' | 'layout';

export type UiComponentPropType =
  | 'string'
  | 'number'
  | 'boolean'
  | 'enum'
  | 'color'
  | 'spacing'
  | 'radius'
  | 'shadow'
  | 'typographySize'
  | 'typographyWeight'
  | 'action'
  | 'imageAsset'
  | 'array';

export type UiComponentPropValue =
  | string
  | number
  | boolean
  | null
  | readonly UiComponentPropValue[]
  | { readonly [key: string]: UiComponentPropValue };

export interface UiComponentPropArrayItemSchema {
  key: string;
  schema: UiComponentPropSchema;
}

export interface UiComponentPropSchema {
  type: UiComponentPropType;
  category: string;
  label?: string;
  enum?: readonly (string | number)[];
  default?: UiComponentPropValue;
  itemSchema?: readonly UiComponentPropArrayItemSchema[];
}

export interface UiComponentBlueprint {
  label: string;
  icon?: {
    name: string;
    provider?: string;
  };
  defaultProps?: Readonly<Record<string, UiComponentPropValue>>;
}

export interface UiComponentI18nMeta {
  fields: readonly {
    keyProp: string;
    defaultTextProp: string;
  }[];
}

export type UiComponentEventPayloadKind = ComponentEventDtoKind | (string & {});

export type UiComponentEventPayloadFieldType =
  | 'boolean'
  | 'number'
  | 'object'
  | 'record'
  | 'string'
  | 'unknown';

export interface UiComponentEventPayloadFieldMeta {
  readonly path: string;
  readonly type: UiComponentEventPayloadFieldType;
  readonly label?: string;
  readonly description?: string;
}

export interface UiComponentEventMeta {
  readonly label: string;
  readonly eventType: UiComponentEventPayloadKind;
  readonly description?: string;
  readonly payloadFields?: readonly UiComponentEventPayloadFieldMeta[];
}

export interface UiComponentSlotMeta {
  label?: string;
  allowedChildren?: readonly string[];
}

export interface UiComponentMeta {
  name: string;
  category: UiComponentCategory;
  description?: string;
  directManifestNode: boolean;
  allowedChildren: readonly string[];
  blueprint?: UiComponentBlueprint;
  events?: Readonly<Record<string, UiComponentEventMeta>>;
  i18n?: UiComponentI18nMeta;
  slots?: Readonly<Record<string, UiComponentSlotMeta>>;
  note?: string;
  props: Readonly<Record<string, UiComponentPropSchema>>;
}

export type UiComponentMetaRegistry = Readonly<Record<string, UiComponentMeta>>;

Consider whether an extension/package-level manifest is useful:

export interface UiComponentPackageManifest {
  packageName: string;
  displayName?: string;
  components: UiComponentMetaRegistry;
}

Migration strategy

Recommended staged approach:

  1. Add the neutral types to @ankhorage/contracts without changing ZORA yet.
  2. Update @ankhorage/zora to re-export or alias its current ZoraComponent* types from the new contract types.
  3. Keep existing ZORA public type names for compatibility.
  4. Update extension packages such as @ankhorage/zora-chessboard to type metadata against @ankhorage/contracts once available.

Example compatibility aliases in ZORA later:

export type ZoraComponentMeta = UiComponentMeta;
export type ZoraComponentMetaRegistry = UiComponentMetaRegistry;

Acceptance criteria

  • @ankhorage/contracts exports provider-neutral UI component metadata types.
  • Types are independent from React, React Native, ZORA, Surface, and runtime implementations.
  • Types reuse existing ComponentEventDtoKind where appropriate.
  • Existing @ankhorage/contracts build/lint/test checks pass.
  • Tests cover at least one valid UiComponentMeta object with events, props, and slots.
  • A changeset is included.

Out of scope

  • Updating @ankhorage/zora in the same PR.
  • Updating @ankhorage/zora-chessboard in the same PR.
  • Runtime registry changes.
  • Manifest rendering behavior changes.
  • Studio UI changes.

Verification

Run before merging:

bun install
bun run knip
bun run lint
bun run build
bun run test

Implementation constraints

  • Keep TypeScript strict.
  • Do not use any, as any, broad casts, @ts-ignore, @ts-expect-error, or eslint disables.
  • Do not weaken tsconfig strictness.
  • Keep contracts standalone and independent from UI/runtime packages.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions