Skip to content

Commit 24b8838

Browse files
dmealingclaude
andcommitted
docs(spec): image support (view.image + <ImageUpload> + metaobjects-ui-web provider)
Cycle 2 of the forms+images FR. Adds a view.image form control backed by a new TS-applied concern provider (metaobjects-ui-web / ui-web.json) that gives presentation-only view attrs a durable cross-port home — also un-defers @rows. Decisions: @store (not @bucket), context-provider adapter seam, optional form.css subpath export, react-easy-crop optional peer. Storage stays field.string (opaque key), no cross-language logic. Per Fable architecture review (option B). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 4250fd6 commit 24b8838

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Image support: `view.image` + `<ImageUpload>` + the `metaobjects-ui-web` provider
2+
3+
_Design — 2026-07-19_
4+
5+
## Summary
6+
7+
The second of two cycles promoting a proven, test-gated "metadata-driven forms + images"
8+
implementation from a downstream consumer into the shared library. Cycle 1 shipped form-control
9+
view-dispatch + `@formExclude` (`0.18.0`). This cycle adds **image support**: a `view.image` form
10+
control that renders a metadata-driven upload/crop widget, backed by a new **TypeScript-applied
11+
concern provider (`metaobjects-ui-web`)** that gives presentation-only view attributes a durable
12+
cross-port home — which also **un-defers `@rows`** from Cycle 1.
13+
14+
The field itself stays `field.string` (only an opaque storage key is stored and wired), so there
15+
is **no cross-language storage or runtime logic** — image handling is entirely a TypeScript-web
16+
presentation concern.
17+
18+
## Motivation
19+
20+
- Cycle 1 deferred `@rows` because presentation attributes on TS-only view subtypes had no clean
21+
cross-port home (`ui.json`'s `extends` throws where `view.textarea` is deregistered in the non-TS
22+
ports; core `view.json` breaks the FR-033 "core owns zero view attrs" invariant). `view.image`
23+
needs **five** such attributes, so it hits the same wall — harder. This cycle resolves the
24+
general problem, then builds image support on top.
25+
- The upload/crop UX (`<ImageUpload>`) is proven in a downstream consumer but hard-imports a
26+
concrete upload adapter, so it must be made adapter-agnostic to be library-safe.
27+
28+
## Decisions already settled
29+
30+
| Decision | Choice | Rationale |
31+
| --- | --- | --- |
32+
| Cross-port home for view attrs | **`metaobjects-ui-web` — a TS-applied concern provider** backed by a new `spec/metamodel/ui-web.json` | Extends the *existing* subtype-level asymmetry (TS registers generic views, non-TS ports don't, manifest excludes them as `PresentationOnly`) to the attr level. FR-033's invariant stays literally green — the attrs live in a concern provider, never core. Adopted after a Fable architecture review of options A–D (re-register cross-port / relax FR-033 / bring-your-own-recipe all rejected). |
33+
| `view.image` subtype | **Bare in core `view.json`** (zero attrs) | Same shape as the other 13 view subtypes; the attrs live in `ui-web.json`. |
34+
| Image-attr naming | **`@store`** (not `@bucket`) | `@bucket` is S3-flavored; `@store` is vendor-neutral for the storage-namespace hint. |
35+
| Adapter injection | **Context provider** (`<ImageUploadAdapterProvider>` + `useImageUploadAdapter()`) | The generated form emits `<ImageUpload>` with no adapter prop, so it must come from context — matching the existing `EntityFetcherProvider` / `CellRendererProvider` pattern in the client packages. |
36+
| Default styling (`form.css`) | **Optional CSS subpath export** `@metaobjectsdev/react/form.css` | Ship the semantic classes with theme-portable `var(--color-*)` fallbacks; adopters opt in with an import. A new packaging pattern, but standard for component libraries. |
37+
| react-easy-crop | **Optional peer dep, lazy-loaded** | Matches the RHF/zod optional-peer idiom; non-image consumers pay nothing. |
38+
39+
## Design
40+
41+
### Unit A — Metamodel foundation (`metaobjects-ui-web`)
42+
43+
The mechanism generalizes the proven per-port *application* asymmetry: the truth (subtypes + attrs)
44+
lives in the shared spec tree; what varies per port is *which provider a port applies*.
45+
46+
- **`spec/metamodel/view.json`** — add a bare `view.image` entry:
47+
`{ "type": "view", "subType": "image", "description": "Image upload/display control; the field stores an opaque storage key (field.string)." }`.
48+
Zero attrs. Sync the committed C#/Python `view.json` copies (byte-identity gates; inert there —
49+
`view.image` is unregistered in those ports, so it is carried but never processed).
50+
- **New `spec/metamodel/ui-web.json`** (provider `metaobjects-ui-web`) — an `extends[]` carrying:
51+
- `view.textarea``@rows` (int).
52+
- `view.image``@aspectRatio` (double), `@maxEdge` (int), `@store` (string), `@accept`
53+
(string[]), `@maxBytes` (int).
54+
- **TypeScript applies it, nobody else.** Regenerate the embedded metamodel (produces
55+
`ui-web-definition.embedded.ts`); add a `uiWebProvider` (id `metaobjects-ui-web`, deps
56+
`["metaobjects-core-types"]`, `applyProviderDefinition(registry, UI_WEB_DEFINITION, {})` — a
57+
clone of the existing `ui-provider.ts`) and register it in `coreProviders`. Add constants
58+
(`VIEW_SUBTYPE_IMAGE`, `VIEW_TEXTAREA_ATTR_ROWS`, `VIEW_IMAGE_ATTR_*`) and `image` into
59+
`VIEW_SUBTYPES`. Add a `ui-web-definition-embed.test.ts` drift gate.
60+
- **Cross-port ports mirror the file but never apply it.** The C#/Python/Java spec loaders enforce
61+
a set-equality gate over `spec/metamodel/`, so `ui-web.json` must be added to each port's spec
62+
file list (Python `SPEC_FILES`, C# `SpecFiles` + the csproj embedded-resource list, Java
63+
`SPEC_FILES`) and `cp`'d to the C#/Python committed copies. **No non-TS provider applies
64+
`metaobjects-ui-web`** — the directives are parsed but never applied (the registered-target
65+
throw stays the tripwire if a port ever wires it wrongly).
66+
- **FR-033 invariant stays green.** `view-definition-completeness.test.ts`'s "core registers NO own
67+
attrs" assertion composes a **core-only** registry (which excludes `ui-web`), so it is unchanged
68+
and passing. Its per-subtype EXPECTED table gains `textarea: { rows }` and `image: { …5 }`
69+
(these assert the *effective* registry). Regenerate the `metamodel-docs*` goldens (the new attrs
70+
become documented — desirable).
71+
- **Conformance manifest unchanged.** `view.textarea`/`view.image` are `PresentationOnly`-excluded,
72+
so `expected-registry.json` gains nothing. Regen and assert **no diff** (the tripwire).
73+
- **Un-defer `@rows`:** the `codegen-ts-react` `formFile` textarea branch moves from fixed
74+
`rows={4}` to reading `@rows` off the view child (default 4).
75+
76+
### Unit B — Runtime primitives
77+
78+
- **`@metaobjectsdev/runtime-web`** (zero-React core):
79+
- `canvasToJpegBlob(canvas, quality = 0.9): Promise<Blob>` — the shared `canvas.toBlob` promise wrapper.
80+
- `reencodeJpeg(file, maxEdge = 2000): Promise<Blob>` — whole-image re-encode + EXIF-strip.
81+
- `ImageUploadAdapter` **type**`{ upload(blob: Blob, opts: { store?: string }): Promise<{ key: string }>; imageUrl(key: string): string }`. (Contract only, no concrete impl — matching how `EntityFetcher` is a contract here.)
82+
- `ImageMeta` type — `{ aspectRatio?: number; maxEdge?: number; store?: string; accept?: string[]; maxBytes?: number }`.
83+
- **`@metaobjectsdev/react`** (React bindings):
84+
- `<ImageUpload value onChange meta className? >` — controlled (`value: string | null` storage
85+
key; `onChange: (key: string | null) => void`), matching the `<CurrencyInput>` idiom: pick →
86+
react-easy-crop modal (aspect from `meta.aspectRatio`) → canvas re-encode bounded by
87+
`meta.maxEdge` (strips EXIF) → adapter `upload``onChange(key)`; shows the current image via
88+
`adapter.imageUrl(value)` with Replace/Remove.
89+
- `<ImageUploadAdapterProvider value={adapter}>` + `useImageUploadAdapter()` — the context seam;
90+
`<ImageUpload>` reads its adapter from context (throws a clear error if used outside a provider).
91+
- `cropToBlob(src, area, maxEdge): Promise<Blob>` — the crop-region re-encode helper.
92+
- **react-easy-crop** is an optional peer dep, lazy-loaded inside the crop modal so non-image
93+
consumers never load it.
94+
- **`@metaobjectsdev/react/form.css`** — an optional stylesheet subpath export: the semantic
95+
classes (`metaobjects-field*`, `metaobjects-form-*`, `metaobjects-image-*`) with theme-portable
96+
`var(--color-*)` fallbacks. Adopters opt in with `import "@metaobjectsdev/react/form.css"`.
97+
98+
### Unit C — Codegen
99+
100+
- **`codegen-ts-react` `formFile`** — add a `view.image` branch to `fieldControlFor`:
101+
`<Controller name="<field>" control={form.control} render={({ field }) => <ImageUpload value={field.value ?? null} onChange={field.onChange} meta={{ /* the 5 attrs */ }} />} />`
102+
(RHF `Controller` for the controlled widget, like the value-object path already uses
103+
`form.control`). The `<ImageUpload>` + `Controller` imports are emitted **only when the entity
104+
has an image field** (gated, like the array-of-VO `useFieldArray` import).
105+
106+
## Storage contract (unchanged, cross-language-safe)
107+
108+
The field storing an image is `field.string` (e.g. `@maxLength 80`). Only the opaque object key is
109+
stored in the column and travels on the wire. Bytes are uploaded through a separate multipart
110+
endpoint (the adapter's `upload`); retrieval is a plain `<img src={imageUrl(key)}>`. Replace = a new
111+
key. No other port needs image logic; the metamodel entry buys cross-port codegen no non-TS port
112+
emits, which is exactly why `view.image` + its attrs are TS-applied only.
113+
114+
## Testing (TDD)
115+
116+
- **Unit A** — a strict-mode load of `view.image` with the five attrs and `view.textarea @rows`
117+
succeeds (no `ERR_UNKNOWN_ATTR`); the `ui-web-definition-embed` drift gate; `expected-registry.json`
118+
unchanged after regen; `view-definition-completeness` green.
119+
- **Unit B**`<ImageUpload>` tests: renders the current image from `imageUrl(value)`; pick →
120+
(mocked crop) → adapter `upload` called with the re-encoded blob → `onChange(key)`; Remove →
121+
`onChange(null)`; throws outside a provider. `canvasToJpegBlob`/`reencodeJpeg` util tests.
122+
- **Unit C** — a `renderFormFile` fixture with a `view.image` field emits `<ImageUpload>` via
123+
`Controller` and the gated imports; a non-image entity emits neither; `@rows` now renders
124+
`rows={<n>}` from the view child.
125+
126+
## Deferred / out of scope
127+
128+
- **App-specific server** — the upload/serve routes (Cloudflare Worker / R2 / EXIF re-check) and
129+
the bespoke photo-gallery screens stay in the consumer. The upload/serve *contract* (`POST →
130+
{ key }`, `GET key → bytes`, immutable cache, server-side EXIF re-check) is **documented** as the
131+
adapter's expected backend so adopters can implement it in any stack.
132+
- **`@formExclude` at the route/schema tier**`@formExclude` is form-only today; hardening the
133+
generated `UpdateSchema`/routes to honor it is a separate security conversation, not this cycle.
134+
- **The consumer's local `view.image`/`@rows` provider** must be removed when this ships (TS
135+
`extend` raises `ERR_PROVIDER_ATTR_CONFLICT` on double registration) — a consumer-side
136+
coordination note, not library work.
137+
138+
## Release framing
139+
140+
Coordinated by mechanism, npm by value: the `ui-web.json` mirror files + spec-file-list entries
141+
must land in all ports atomically (the set-equality gates), but **no non-TS behavior changes** (the
142+
provider is TS-applied; the attrs are inert + manifest-excluded elsewhere). So the substantive value
143+
(image codegen + runtime component + `@rows`) is npm-only, while the metamodel foundation is a
144+
mechanical cross-port mirror. Whether to cut all registries or let the inert mirror ride the next
145+
coordinated bump is a release-timing call made when the work is done. Additive minor everywhere.
146+
147+
## Open questions
148+
149+
None — the cross-port home, `view.image` shape, `@store` naming, adapter seam, `form.css` packaging,
150+
and react-easy-crop handling are all settled above.

0 commit comments

Comments
 (0)