@reventlessdev/trait-attachments 1.0.0-alpha.3 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,18 @@ type config = {
51
51
  noun: string,
52
52
  /** The attachment field, named for the store it draws from: `"productImage"`. */
53
53
  file: string,
54
+ /**
55
+ How many attachments this host's entity may hold. Absent ⇒ `Many`, so a graft
56
+ written before this key existed emits exactly what it emitted before.
57
+
58
+ It is not one rule among the emitted text: it changes which commands exist.
59
+ `Single` emits `Set{Entity}{Noun}` (which replaces), a `Remove{Entity}{Noun}`
60
+ that names no reference — there is only one, and asking a caller to name it is
61
+ asking them to repeat what the row already says — and no primary command at
62
+ all, because a set of one has nothing to choose between. The view field
63
+ changes with them: one captioned image, not an array of them.
64
+ */
65
+ cardinality?: Attachments_Rules.cardinality,
54
66
  /** The host event that brings the entity into existence: `"ProductAdded"`. */
55
67
  created: string,
56
68
  /** Whether that event carries the entity id. `false` for a payload-less
@@ -58,6 +70,17 @@ type config = {
58
70
  createdCarriesEntityId?: bool,
59
71
  /** The view whose lifecycle states `transition` names: `"Products"`. */
60
72
  view: string,
73
+ /**
74
+ The name of that view's lifecycle enum *type*: `"shelfStatus"`. Only read when
75
+ `transition` is given, because only then is a `lifecycleState` binding emitted.
76
+
77
+ Carried rather than derived: the enum is a type on the host's view and nothing
78
+ in the other names reaches it — `Products` has a `shelfStatus`, but a host may
79
+ call its own `state`, `stage` or `phase`. Absent leaves a `TODO(graft)` marker
80
+ in its place, which does not compile; that is deliberate, and better than a
81
+ guess that compiles against the wrong type.
82
+ */
83
+ lifecycleType?: string,
61
84
  /** The semantic type of the reference. `Reventless.UploadableImage.t` unless
62
85
  the host attaches something other than pictures. */
63
86
  refType?: string,
@@ -108,11 +131,20 @@ type names = {
108
131
  notAttached: string,
109
132
  }
110
133
 
134
+ let cardinalityOf = (c: config): Attachments_Rules.cardinality =>
135
+ c.cardinality->Option.getOr(Many)
136
+
137
+ let isSingle = (c: config): bool => cardinalityOf(c) == Single
138
+
111
139
  let namesOf = (c: config): names => {
112
140
  let subject = c.entity ++ c.noun
113
141
  {
114
142
  slice: subject ++ "s",
115
- attachCmd: "Attach" ++ subject,
143
+ // `Set` rather than `Attach` for a bounded set: the command replaces what is
144
+ // there, and `Attach` would name the wrong half of what it does. The *event*
145
+ // stays `Attached` at both cardinalities — an image was attached is the fact,
146
+ // and a replacement is that fact preceded by a removal.
147
+ attachCmd: (isSingle(c) ? "Set" : "Attach") ++ subject,
116
148
  removeCmd: "Remove" ++ subject,
117
149
  setPrimaryCmd: "SetPrimary" ++ subject,
118
150
  setAltTextCmd: "Set" ++ subject ++ "AltText",
@@ -127,6 +159,62 @@ let namesOf = (c: config): names => {
127
159
 
128
160
  let refTypeOf = (c: config) => c.refType->Option.getOr("Reventless.UploadableImage.t")
129
161
 
162
+ // What the VIEW holds, as against what an event's reference field holds. An
163
+ // event names a file; a view holds that file together with the text that goes
164
+ // with it, because a cell renderer is handed a field and a value and never the
165
+ // row — so a caption in a sibling field is a caption no cell can draw.
166
+ //
167
+ // Read off the reference type by name, as `contentArgOf` is. A host attaching
168
+ // something the vocabulary has no composite for keeps the bare reference, which
169
+ // is the shape it has today rather than a guess at one it does not.
170
+ let viewTypeOf = (c: config): string =>
171
+ refTypeOf(c)->String.includes("Image") ? "Reventless.CaptionedImage.t" : refTypeOf(c)
172
+
173
+ // The collection field a member-selecting command picks out of. Named for the
174
+ // plural of the attachment field, which is what the view calls it.
175
+ let setFieldOf = (c: config): string => c.file ++ "s"
176
+
177
+ /**
178
+ The type a command field takes when it means *select one of the ones I have*
179
+ rather than *here is a new file*.
180
+
181
+ This is the distinction the whole graft used to be unable to make. Remove,
182
+ choose-primary and caption all name a reference the row already holds, and all
183
+ three were typed as the uploadable they select among — so a UI reading the
184
+ declaration bound an upload input to each of them, which on a remove command
185
+ offers the caller the one thing it cannot do.
186
+
187
+ `Single` declares no such field at all, so this is only ever reached for the
188
+ commands where a choice genuinely exists.
189
+
190
+ Spelled as a reference to a binding rather than inline: an `@s.matches(…)`
191
+ attribute has to fit on one line to parse, and the call with both names in it
192
+ does not — which is also the better shape, since the collection is one answer
193
+ and three inline copies are three chances for one to name a field that has moved.
194
+ */
195
+ let selectionBinding = "selected"
196
+
197
+ let selectionTypeOf = (_c: config): string => `@s.matches(${selectionBinding}) string`
198
+
199
+ // What the members are, as the selection's own declaration states it — so a form
200
+ // drawing a picker shows thumbnails rather than a list of paths, without having
201
+ // to reach the collection field's element type.
202
+ //
203
+ // Read off the reference type by name, which is the only thing this module ever
204
+ // does. A host attaching something the vocabulary has no word for gets no
205
+ // content stated, and a reader falls back to its own rules — which is the honest
206
+ // answer rather than a guess with a default in it.
207
+ let contentArgOf = (c: config): string => {
208
+ let ref = refTypeOf(c)
209
+ if ref->String.includes("Image") {
210
+ `~content=Reventless.Semantic.Id.imageRef, `
211
+ } else if ref->String.includes("File") {
212
+ `~content=Reventless.Semantic.Id.fileRef, `
213
+ } else {
214
+ ""
215
+ }
216
+ }
217
+
130
218
  // `@authorize` is the host's policy, so an absent one emits nothing at all rather
131
219
  // than a permissive default — a graft that silently declared "anyone" would be
132
220
  // worse than one that declares nothing.
@@ -150,14 +238,31 @@ let commandAttributes = (c: config): string =>
150
238
  // typechecker and matched as strings at assembly; in `Guards([Products.Listed])` they are
151
239
  // constructor references the compiler resolves, so a config typo is a build
152
240
  // error naming it. Same input, and the difference is only who checks it.
153
- let commandTransitionBinding = (c: config): array<string> => {
241
+ // Every command this graft declares, in declaration order. `SetPrimary` is
242
+ // absent for a bounded set — the one thing that changes the *shape* of the
243
+ // emitted surface rather than a rule inside it — and every emission below reads
244
+ // this list rather than repeating the condition.
245
+ let commandNames = (c: config): array<string> => {
154
246
  let n = namesOf(c)
155
- let arms = [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
156
- ->Array.map(cmd => ` | ${cmd}(_)`)
157
- ->Array.join("\n")
247
+ isSingle(c)
248
+ ? [n.attachCmd, n.removeCmd, n.setAltTextCmd]
249
+ : [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
250
+ }
251
+
252
+ let commandTransitionBinding = (c: config): array<string> => {
253
+ let arms = commandNames(c)->Array.map(cmd => ` | ${cmd}(_)`)->Array.join("\n")
254
+ // The enum's *type*, which `lifecycleType` names. Absent leaves the marker,
255
+ // and the marker does not compile — which is the honest outcome for a graft
256
+ // that declared the states its commands are legal in and not the type they
257
+ // are constructors of. A guess here would compile against whatever enum the
258
+ // view happens to have first.
259
+ let lifecycleType = switch c.lifecycleType {
260
+ | Some(t) => `${c.view}.${t}`
261
+ | None => `${c.view}.<lifecycle> // TODO(graft): the enum's name`
262
+ }
158
263
  switch c.transition {
159
264
  | Some(states) => [
160
- `type lifecycleState = ${c.view}.<lifecycle> // TODO(graft): the enum's name`,
265
+ `type lifecycleState = ${lifecycleType}`,
161
266
  `let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {`,
162
267
  ` open Reventless.Transition`,
163
268
  ` switch command {`,
@@ -192,15 +297,31 @@ let lines = (ls: array<string>) => ls->Array.join("\n")
192
297
  let sliceSpec = (c: config): string => {
193
298
  let n = namesOf(c)
194
299
  let ref = refTypeOf(c)
300
+ let sel = selectionTypeOf(c)
301
+ let single = isSingle(c)
195
302
  let attrs = commandAttributes(c)
196
303
  let createdArm =
197
304
  c.createdCarriesEntityId->Option.getOr(true)
198
305
  ? ` | ${c.created}({ ${c.entityId}: string})`
199
306
  : ` | ${c.created}`
307
+ // Present only where a choice exists. A bounded set has one member, so
308
+ // nothing consumes or emits a primary — and the trait's rules never produce
309
+ // the fact, so listing the event would declare one nothing can write.
310
+ let primaryArm = (prefix: string) =>
311
+ single ? [] : [` | ${n.primarySet}({ ${prefix}${c.file}: ${ref}})`]
312
+ let headline =
313
+ single
314
+ ? [
315
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s single attachment — set it, remove`,
316
+ `// it, caption it. A graft of the Attachments trait; the set's rules are the`,
317
+ ]
318
+ : [
319
+ `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
320
+ `// choose the primary, caption. A graft of the Attachments trait; the rules are`,
321
+ ]
200
322
  lines([
201
- `// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
202
- `// choose the primary, caption. A graft of the Attachments trait; the set's rules`,
203
- `// are the trait's and are asserted by its conformance suite, bound in the tests.`,
323
+ ...headline,
324
+ `// trait's and are asserted by its conformance suite, bound in the tests.`,
204
325
  `//`,
205
326
  `// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
206
327
  `// ordinary source from here on — edit it freely.`,
@@ -212,17 +333,45 @@ let sliceSpec = (c: config): string => {
212
333
  createdArm,
213
334
  ` | ${n.attached}({ ${c.file}: ${ref}})`,
214
335
  ` | ${n.removed}({ ${c.file}: ${ref}})`,
215
- ` | ${n.primarySet}({ ${c.file}: ${ref}})`,
336
+ ...primaryArm(""),
216
337
  ` | ${n.altTextSet}({ ${c.file}: ${ref}, altText: string})`,
217
338
  ` // TODO(graft): add the events this host's own refusal turns on — whatever`,
218
339
  ` // moves it into a state where attachments may not be changed.`,
219
340
  ``,
341
+ ...(single
342
+ ? [
343
+ `// One reference field, on ${n.attachCmd}, and it accepts a new file — so`,
344
+ `// it is typed as the uploadable it is and a form binds an upload input to it.`,
345
+ `// Neither other command names a reference: with one attachment there is`,
346
+ `// nothing to choose between, and asking a caller to name it would be asking`,
347
+ `// them to repeat what the row already says.`,
348
+ ]
349
+ : [
350
+ `// The reference fields divide into two kinds, and the division is the point.`,
351
+ `// The one on ${n.attachCmd} accepts a new file, so it is typed as the`,
352
+ `// uploadable it is and a form binds an upload input to it. The others name a`,
353
+ `// file the row ALREADY holds, so they are typed as selections out of`,
354
+ `// \`${setFieldOf(c)}\` and a form offers those instead of an uploader.`,
355
+ `//`,
356
+ `// Bound once rather than spelled three times: the collection is one answer,`,
357
+ `// and three copies are three chances for one to name a field that has moved.`,
358
+ `let ${selectionBinding} = Reventless.MemberRef.of_(~view="${c.view}", ${contentArgOf(
359
+ c,
360
+ )}~field="${setFieldOf(c)}")`,
361
+ ``,
362
+ ]),
220
363
  `@schema`,
221
364
  `type command =`,
222
365
  `${attrs}${n.attachCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
223
- `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
224
- `${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
225
- `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
366
+ // The bounded set's remove names nothing. This is the reported defect in its
367
+ // purest form — the old command asked for an upload in order to delete.
368
+ single
369
+ ? `${attrs}${n.removeCmd}({ ${c.entityId}: string})`
370
+ : `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`,
371
+ ...(single ? [] : [`${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`]),
372
+ single
373
+ ? `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, altText: string})`
374
+ : `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${sel}, altText: string})`,
226
375
  ``,
227
376
  `@schema`,
228
377
  `type error =`,
@@ -234,7 +383,7 @@ let sliceSpec = (c: config): string => {
234
383
  `type event =`,
235
384
  ` | ${n.attached}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
236
385
  ` | ${n.removed}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
237
- ` | ${n.primarySet}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
386
+ ...primaryArm(`${c.entityId}: string, `),
238
387
  ` | ${n.altTextSet}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
239
388
  ``,
240
389
  ...commandTransitionBinding(c),
@@ -250,6 +399,7 @@ let sliceSpec = (c: config): string => {
250
399
 
251
400
  let sliceBehavior = (c: config): string => {
252
401
  let n = namesOf(c)
402
+ let single = isSingle(c)
253
403
  lines([
254
404
  `@@reventless.behavior`,
255
405
  ``,
@@ -264,10 +414,17 @@ let sliceBehavior = (c: config): string => {
264
414
  `let evolve = (state, event) => {`,
265
415
  ` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
266
416
  ` switch event {`,
267
- ` | ${c.created}(_) => {...state, exists: true}`,
417
+ // A creation event that carries no id is a bare constructor, so a wildcard
418
+ // payload does not compile against it. The spec above already branches on
419
+ // this; the fold has to branch with it.
420
+ c.createdCarriesEntityId->Option.getOr(true)
421
+ ? ` | ${c.created}(_) => {...state, exists: true}`
422
+ : ` | ${c.created} => {...state, exists: true}`,
268
423
  ` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
269
424
  ` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
270
- ` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`,
425
+ ...(single
426
+ ? []
427
+ : [` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`]),
271
428
  ` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
272
429
  ` // TODO(graft): fold this host's own events into its own state.`,
273
430
  ` }`,
@@ -279,28 +436,62 @@ let sliceBehavior = (c: config): string => {
279
436
  ` ${c.entityId},`,
280
437
  ` Attachments.Attach({ref: ${c.file}, altText}),`,
281
438
  ` )`,
282
- ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
283
- ` ${c.entityId},`,
284
- ` Attachments.Remove({ref: ${c.file}}),`,
285
- ` )`,
286
- ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
287
- ` ${c.entityId},`,
288
- ` Attachments.SetPrimary({ref: ${c.file}}),`,
289
- ` )`,
290
- ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
291
- ` ${c.entityId},`,
292
- ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
293
- ` )`,
439
+ // `Clear` is what a ref-less remove maps onto: the op that empties the set,
440
+ // whatever it holds. Nothing here has to look the member up.
441
+ ...(single
442
+ ? [` | ${n.removeCmd}({${c.entityId}}) => (${c.entityId}, Attachments.Clear)`]
443
+ : [
444
+ ` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
445
+ ` ${c.entityId},`,
446
+ ` Attachments.Remove({ref: ${c.file}}),`,
447
+ ` )`,
448
+ ` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
449
+ ` ${c.entityId},`,
450
+ ` Attachments.SetPrimary({ref: ${c.file}}),`,
451
+ ` )`,
452
+ ]),
453
+ ...(single
454
+ ? [
455
+ ` | ${n.setAltTextCmd}({${c.entityId}, altText}) => (`,
456
+ ` ${c.entityId},`,
457
+ // Not punned: a single-field inline record whose field shares its name
458
+ // with the variable filling it is read as a record copy, and the
459
+ // anonymous type then escapes its constructor.
460
+ ` Attachments.SetPrimaryAltText({altText: altText}),`,
461
+ ` )`,
462
+ ]
463
+ : [
464
+ ` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
465
+ ` ${c.entityId},`,
466
+ ` Attachments.SetAltText({ref: ${c.file}, altText}),`,
467
+ ` )`,
468
+ ]),
294
469
  ` }`,
295
470
  ``,
471
+ // `Some`/`None` for a bounded set only. `fact` is the trait's type and so
472
+ // lists a primary at both cardinalities, but a graft with no primary command
473
+ // can never decide one — and an arm that fabricated some other event to keep
474
+ // the switch total would be writing a fact nothing happened.
296
475
  `let toEvent = (${c.entityId}, fact) =>`,
297
476
  ` switch fact {`,
298
477
  ` | Attachments.Attached({ref, altText}) =>`,
299
- ` ${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})`,
300
- ` | Attachments.Removed({ref}) => ${n.removed}({${c.entityId}, ${c.file}: ref})`,
301
- ` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`,
478
+ ` ${single ? "Some(" : ""}${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})${single
479
+ ? ")"
480
+ : ""}`,
481
+ ` | Attachments.Removed({ref}) => ${single
482
+ ? `Some(${n.removed}({${c.entityId}, ${c.file}: ref}))`
483
+ : `${n.removed}({${c.entityId}, ${c.file}: ref})`}`,
484
+ ...(single
485
+ ? [
486
+ ` // Unreachable: no command of this graft chooses a primary, because a set`,
487
+ ` // of one has nothing to choose between. It contributes no event.`,
488
+ ` | Attachments.PrimarySet(_) => None`,
489
+ ]
490
+ : [` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`]),
302
491
  ` | Attachments.AltTextSet({ref, altText}) =>`,
303
- ` ${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})`,
492
+ ` ${single ? "Some(" : ""}${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})${single
493
+ ? ")"
494
+ : ""}`,
304
495
  ` }`,
305
496
  ``,
306
497
  `let decide = (state, command) =>`,
@@ -311,10 +502,13 @@ let sliceBehavior = (c: config): string => {
311
502
  ` // an \`else if\` returning the error added above. A graft with no extra refusal`,
312
503
  ` // is a complete graft, so leaving this is legitimate.`,
313
504
  ` let (${c.entityId}, op) = toOp(command)`,
314
- ` switch state.attachments->Attachments.decide(op) {`,
505
+ single
506
+ ? ` switch state.attachments->Attachments.decide(~cardinality=Single, op) {`
507
+ : ` switch state.attachments->Attachments.decide(op) {`,
315
508
  ` | Error(#NotAttached) => Error(${n.notAttached})`,
316
- ` | Ok(None) => Ok([])`,
317
- ` | Ok(Some(fact)) => Ok([toEvent(${c.entityId}, fact)])`,
509
+ single
510
+ ? ` | Ok(facts) => Ok(facts->Array.filterMap(toEvent(${c.entityId}, _)))`
511
+ : ` | Ok(facts) => Ok(facts->Array.map(toEvent(${c.entityId}, _)))`,
318
512
  ` }`,
319
513
  ` }`,
320
514
  ``,
@@ -329,6 +523,7 @@ let sliceBehavior = (c: config): string => {
329
523
 
330
524
  let conformanceBinding = (c: config): string => {
331
525
  let n = namesOf(c)
526
+ let single = isSingle(c)
332
527
  let id = "e1"
333
528
  let refA = c.refA->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000001/a`)
334
529
  let refB = c.refB->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000002/b`)
@@ -352,27 +547,42 @@ let conformanceBinding = (c: config): string => {
352
547
  ` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
353
548
  ` let attachedC = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
354
549
  ` let removedC = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
355
- ` let primarySetC = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
550
+ ...(single
551
+ ? []
552
+ : [
553
+ ` let primarySetC = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
554
+ ]),
356
555
  ` let altTextSetC = (ref, altText): ${n.slice}.consumedEvent =>`,
357
556
  ` ${n.altTextSet}({ ${c.file}: ref, altText})`,
358
557
  ``,
359
558
  ` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
360
- ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
361
- ` let setPrimary = ref =>`,
362
- ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
363
- ` let setAltText = (ref, altText) =>`,
364
- ` ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
559
+ ...(single
560
+ ? [` let clear = ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}"})`]
561
+ : [
562
+ ` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
563
+ ` let setPrimary = ref =>`,
564
+ ` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
565
+ ]),
566
+ single
567
+ ? ` let setAltText = altText => ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", altText})`
568
+ : ` let setAltText = (ref, altText) =>\n ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
365
569
  ``,
366
570
  ` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
367
571
  ` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
368
- ` let primarySet = ref =>`,
369
- ` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
572
+ ...(single
573
+ ? []
574
+ : [
575
+ ` let primarySet = ref =>`,
576
+ ` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
577
+ ]),
370
578
  ` let altTextSet = (ref, altText) =>`,
371
579
  ` ${n.slice}.${n.altTextSet}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
372
580
  ` let notAttached = ${n.slice}.${n.notAttached}`,
373
581
  `}`,
374
582
  ``,
375
- `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
583
+ single
584
+ ? `module Conformance = TraitAttachments.Attachments_Conformance.MakeSingle(Binding)`
585
+ : `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
376
586
  ``,
377
587
  `Conformance.register()`,
378
588
  ``,
@@ -385,50 +595,117 @@ let conformanceBinding = (c: config): string => {
385
595
  // `switch` the host wrote. Placing an arm in it is the one part of a graft this
386
596
  // module deliberately does not automate.
387
597
 
388
- let projectionPatch = (c: config): patch => {
598
+ // The bounded set's projection, which is a different patch rather than the same
599
+ // one with a branch in it: with one attachment the view carries the value itself
600
+ // and no collection at all, so there is no set to fold over and no primary to
601
+ // put first. Three assignments.
602
+ //
603
+ // The removal and the caption both guard on the reference they name. A
604
+ // replacement decides two facts — the old leaves, then the new arrives — and the
605
+ // guard is what makes each arm depend only on the row it finds rather than on
606
+ // those two reaching the projection in the order they were decided.
607
+ let singleProjectionPatch = (c: config): patch => {
389
608
  let n = namesOf(c)
390
609
  {
391
610
  into: `StateViewSliceStream/${c.view}_Projection.res`,
392
- at: `the projection's \`switch\`, and two fields on \`${c.view}\`'s state`,
611
+ at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
393
612
  contents: lines([
394
- `// On the view's state, two fields — the set, and its primary as one string.`,
395
- `// The second is not redundancy: a card, a gallery tile and a reference cell`,
396
- `// each read one image-semantic string per row, so without it every tile is blank.`,
613
+ `// On the view's state, one field — the reference and its text, in one value.`,
614
+ `// No collection: this entity holds one attachment, so the field a card, a`,
615
+ `// gallery tile and a list cell read IS the whole of what it has.`,
397
616
  `//`,
398
- `// ${c.file}s: array<{${c.file}: ${refTypeOf(c)}, altText?: string}>,`,
399
- `// ${c.file}?: ${refTypeOf(c)},`,
617
+ `// ${c.file}?: ${viewTypeOf(c)},`,
400
618
  ``,
401
619
  `| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
402
620
  ` Update(${c.entityId}, state => {`,
403
- ` let ${c.file}s = Array.concat(state.${c.file}s, [{${c.file}: ${c.file}, altText: ?altText}])`,
404
- ` {...state, ${c.file}s, ${c.file}: ?withPrimary(${c.file}s, state.primaryChosen)}`,
621
+ ` ...state,`,
622
+ ` ${c.file}: {ref: ${c.file}, altText: ?altText},`,
405
623
  ` })`,
406
624
  `| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
625
+ ` Update(${c.entityId}, state =>`,
626
+ ` // Guarded on the reference: a removal that names something this row no`,
627
+ ` // longer holds — the first half of a replacement, arriving late — must not`,
628
+ ` // blank the one it does.`,
629
+ ` heldRef(state) == Some(${c.file}) ? {...state, ${c.file}: ?None} : state`,
630
+ ` )`,
631
+ `| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
632
+ ` Update(${c.entityId}, state =>`,
633
+ ` switch state.${c.file} {`,
634
+ ` | Some(held) if held.ref == ${c.file} => {...state, ${c.file}: {...held, altText}}`,
635
+ ` | _ => state`,
636
+ ` }`,
637
+ ` )`,
638
+ ``,
639
+ `// The reference this row holds, if it holds one. Named because both guards`,
640
+ `// above ask the same question of a value that is no longer the reference itself.`,
641
+ `let heldRef = (state: ${c.view}.state) => state.${c.file}->Option.map(held => held.ref)`,
642
+ ]),
643
+ }
644
+ }
645
+
646
+ let manyProjectionPatch = (c: config): patch => {
647
+ let n = namesOf(c)
648
+ let set = setFieldOf(c)
649
+ {
650
+ into: `StateViewSliceStream/${c.view}_Projection.res`,
651
+ at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
652
+ contents: lines([
653
+ `// On the view's state, one field — the set, primary first.`,
654
+ `//`,
655
+ `// The primary is the FIRST member rather than a scalar beside the set. That is`,
656
+ `// what a card, a gallery tile and a list cell read, so there is no second field`,
657
+ `// to keep in step with the set and no arm that can forget to. The text rides`,
658
+ `// inside each member for the same reason: a cell renderer is handed a field and`,
659
+ `// a value and never the row, so a caption in a sibling field is one no cell can`,
660
+ `// draw.`,
661
+ `//`,
662
+ `// What it costs, stated plainly: attachment order stops being readable off the`,
663
+ `// view. The log still has it.`,
664
+ `//`,
665
+ `// ${set}: array<${viewTypeOf(c)}>,`,
666
+ ``,
667
+ `// Appended, so the first attached is the primary until one is chosen.`,
668
+ `| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
669
+ ` Update(${c.entityId}, state =>`,
670
+ ` state.${set}->Array.some(m => m.ref == ${c.file})`,
671
+ ` ? state`,
672
+ ` : {`,
673
+ ` ...state,`,
674
+ ` ${set}: state.${set}->Array.concat([{ref: ${c.file}, altText: ?altText}]),`,
675
+ ` }`,
676
+ ` )`,
677
+ `// Removing the head promotes the next member with no arm to say so.`,
678
+ `| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
407
679
  ` Update(${c.entityId}, state => {`,
408
- ` let ${c.file}s = state.${c.file}s->Array.filter(m => m.${c.file} != ${c.file})`,
409
- ` {...state, ${c.file}s, ${c.file}: ?withPrimary(${c.file}s, state.primaryChosen)}`,
680
+ ` ...state,`,
681
+ ` ${set}: state.${set}->Array.filter(m => m.ref != ${c.file}),`,
410
682
  ` })`,
411
683
  `| ${n.primarySet}({${c.entityId}, ${c.file}}) =>`,
412
- ` Update(${c.entityId}, state => {...state, ${c.file}: Some(${c.file})})`,
684
+ ` Update(${c.entityId}, state => primaryFirst(state, ${c.file}))`,
413
685
  `| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
414
686
  ` Update(${c.entityId}, state => {`,
415
687
  ` ...state,`,
416
- ` ${c.file}s: state.${c.file}s->Array.map(m =>`,
417
- ` m.${c.file} == ${c.file} ? {...m, altText} : m`,
418
- ` ),`,
688
+ ` ${set}: state.${set}->Array.map(m => m.ref == ${c.file} ? {...m, altText} : m),`,
419
689
  ` })`,
420
690
  ``,
421
- `// The primary a reader should show: the one chosen, else the first attached —`,
422
- `// the same rule the trait applies, over the view's own rows.`,
423
- `let withPrimary = (members, chosen) =>`,
424
- ` TraitAttachments.Attachments_Rules.primaryOf(`,
691
+ `// Choosing the primary is moving it to the front the trait's rule, applied`,
692
+ `// over the view's rows. The only arm that reorders; the rest leave the head`,
693
+ `// where they found it.`,
694
+ `let primaryFirst = (state: ${c.view}.state, chosen) => {`,
695
+ ` ...state,`,
696
+ ` ${set}: TraitAttachments.Attachments_Rules.primaryFirst(`,
425
697
  ` ~chosen,`,
426
- ` ~attached=members->Array.map(m => m.${c.file}),`,
427
- ` )`,
698
+ ` ~members=state.${set},`,
699
+ ` ~ref=m => m.ref,`,
700
+ ` ),`,
701
+ `}`,
428
702
  ]),
429
703
  }
430
704
  }
431
705
 
706
+ let projectionPatch = (c: config): patch =>
707
+ isSingle(c) ? singleProjectionPatch(c) : manyProjectionPatch(c)
708
+
432
709
  /**
433
710
  Emit a graft.
434
711