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