@phis/contracts 0.4.0 → 0.6.0

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.
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The Form descriptor: what a form's fields are, how they are laid out on its grid, what it says when
3
+ * it succeeds or is refused -- and the one parser that decides whether a stored value is one.
4
+ *
5
+ * A descriptor is written by a Module's preset or by the Form Builder, stored by phis-server and drawn
6
+ * by the site UI. All three read it here, so a descriptor the server accepts is one the renderer can
7
+ * draw, and the other way round. What stays in the UI is how the grid becomes CSS.
8
+ */
9
+ import { type PhiRuntimeConditionExpression } from "./conditions.js";
10
+ import { type PhiControlOption, type PhiControlOptionsProviderConfig } from "./controls.js";
11
+ import { type PhiResponsiveValue, type PhiSpacingToken } from "./layout.js";
12
+ export type PhiFormProviderKey = `${string}/${string}`;
13
+ export type PhiFormLabelSetKey = `${string}/${string}`;
14
+ export declare const PHI_FORM_DESCRIPTOR_SCHEMA_VERSION: 1;
15
+ /**
16
+ * Where a piece of a form's wording comes from.
17
+ *
18
+ * `literal` is the word itself. `label` is a key into the form's label set, which is how anything a
19
+ * visitor reads gets translated. `config` is a value the Widget was placed with -- the target of a
20
+ * consent link, say, which is a property of where the form stands rather than of the form: the same
21
+ * registration form points at one site's terms in one Area and another's elsewhere, and only the
22
+ * placement knows which, and in which language's path.
23
+ */
24
+ export type PhiFormTextDescriptor = {
25
+ kind: "literal";
26
+ value: string;
27
+ } | {
28
+ kind: "label";
29
+ key: string;
30
+ fallback: string;
31
+ } | {
32
+ kind: "config";
33
+ key: string;
34
+ fallback: string;
35
+ };
36
+ /** The number of tracks every form grid is divided into. */
37
+ export declare const PHI_FORM_GRID_TRACKS: 24;
38
+ /**
39
+ * Where one element lies on the form grid, written the way CSS Grid writes it: `start` is the line the
40
+ * element begins at and `end` is the line it stops before, both counted from 1, so the last line is 25.
41
+ *
42
+ * A range rather than a width, because a width can only say how much room something takes and never
43
+ * where the room is. A label at 1-7 with an input at 7-19 leaves 19-25 empty, and a tool button can
44
+ * then say 21-25 and stand in that gap on the same row -- neither of which a span can express. Ranges
45
+ * also carry the responsive case without a second concept: a label at 1-25 above an input at 1-25 is
46
+ * the stacked form, because two elements that both claim the whole width cannot share a row.
47
+ */
48
+ export type PhiFormGridRange = {
49
+ /** First line the element occupies, 1 to 24. */
50
+ start: number;
51
+ /** Line the element stops before, 2 to 25. Exclusive, as in CSS. */
52
+ end: number;
53
+ };
54
+ export type PhiFormResponsiveGridRange = PhiResponsiveValue<PhiFormGridRange>;
55
+ export type PhiFormLogicalAlignment = "start" | "center" | "end";
56
+ /**
57
+ * What a form's rows look like where a field says nothing of its own.
58
+ *
59
+ * There is no `columns` and no `labelPlacement` here any more: a two-column form is fields whose
60
+ * ranges lie in 1-13 and 13-25, and a stacked form is a label whose range is the full width. One
61
+ * mechanism decides all of it, and the responsive sets decide it per measured width.
62
+ */
63
+ export type PhiFormLayoutDescriptor = {
64
+ gap?: PhiResponsiveValue<PhiSpacingToken>;
65
+ /**
66
+ * What stands between two columns of a row, where a row has two.
67
+ *
68
+ * The grid's own `column-gap` cannot say this: it would fall between every pair of adjacent elements,
69
+ * so a label beside its control would be pushed away from it by the same amount that separates one
70
+ * field from the next -- and that distance is already said, per cell, as the label's own gutter. This
71
+ * is the other distance, and it is laid on the cell that opens a column: a field placed at 13-25 is
72
+ * moved off the field placed at 1-13, and one placed at 1-13 is not moved at all.
73
+ *
74
+ * Absent, it follows `gap`: the space between two fields side by side is the space between two fields
75
+ * one above the other, which is the reading that needs no second number. A row's two columns are then
76
+ * further apart than a label is from its own control, which is half of it -- the hierarchy a
77
+ * two-column form needs to read as two columns.
78
+ */
79
+ columnGap?: PhiResponsiveValue<PhiSpacingToken>;
80
+ labelAlign?: Exclude<PhiFormLogicalAlignment, "center">;
81
+ label?: PhiFormResponsiveGridRange;
82
+ control?: PhiFormResponsiveGridRange;
83
+ };
84
+ /**
85
+ * Where this one field's parts lie, overriding the layout's defaults.
86
+ *
87
+ * `label` and `control` are column ranges on the 24-track grid, not content, and each mode is decided
88
+ * from what is stated for it:
89
+ * - both: the label in its range, the control in its range;
90
+ * - only `control`: the field has no label column. The control takes exactly that range, and a label,
91
+ * where the field has one, stands above it inside the same range;
92
+ * - only `label`: the field has no control column. The label takes exactly that range, and whatever the
93
+ * field draws as its control stands under it inside the same range;
94
+ * - neither, or no placement: the layout's ranges.
95
+ *
96
+ * The part left out is never taken from the layout: a control moved to 7-19 beside a layout label at 1-9
97
+ * would overlap it and fall onto a second row without anyone having asked for it.
98
+ *
99
+ * Elements are placed in declaration order and CSS Grid does not go back to fill a gap it has passed,
100
+ * so a field meant to stand beside the one before it is declared after it and the row fills from the
101
+ * inline start. That is the whole ordering rule.
102
+ */
103
+ export type PhiFormFieldPlacementDescriptor = {
104
+ label?: PhiFormResponsiveGridRange;
105
+ control?: PhiFormResponsiveGridRange;
106
+ };
107
+ export type PhiFormValidationRuleDescriptor = {
108
+ providerKey: PhiFormProviderKey;
109
+ message?: PhiFormTextDescriptor;
110
+ config?: Record<string, unknown>;
111
+ };
112
+ export type PhiFormOptionDescriptor = Omit<PhiControlOption, "label" | "description"> & {
113
+ label: PhiFormTextDescriptor;
114
+ description?: PhiFormTextDescriptor;
115
+ };
116
+ export type PhiFormFieldDescriptor = {
117
+ key: string;
118
+ fieldProviderKey: PhiFormProviderKey;
119
+ label?: PhiFormTextDescriptor;
120
+ controlLabel?: PhiFormTextDescriptor;
121
+ description?: PhiFormTextDescriptor;
122
+ placeholder?: PhiFormTextDescriptor;
123
+ autoComplete?: string;
124
+ initialValue?: unknown;
125
+ options?: readonly PhiFormOptionDescriptor[];
126
+ optionsProvider?: PhiControlOptionsProviderConfig | null;
127
+ validation?: readonly PhiFormValidationRuleDescriptor[];
128
+ visibleWhen?: PhiRuntimeConditionExpression;
129
+ disabledWhen?: PhiRuntimeConditionExpression;
130
+ placement?: PhiFormFieldPlacementDescriptor;
131
+ config?: Record<string, unknown>;
132
+ };
133
+ /**
134
+ * What a form shows when a submit is accepted.
135
+ *
136
+ * Declared rather than coded, because every form that submits to a handler has this moment and each of
137
+ * them used to answer it in its own component: the Contact form with a toast, the Registration with an
138
+ * alert, each with its own wording and its own idea of whether the fields stay filled in.
139
+ *
140
+ * Absent means the form says nothing of its own. That is the right answer wherever something else is
141
+ * listening -- a Controller that closes an Overlay on `submitSuccess`, a page that navigates away.
142
+ */
143
+ /**
144
+ * What the form's own submit looks like, where one is drawn.
145
+ *
146
+ * Whether one is drawn is not said here. That is the placement's question: the same form stands on a
147
+ * page of its own with a button under its fields, and in a dialog whose footer carries the button --
148
+ * or with no button anywhere, submitted by a Button Widget through the `submit` capability, or by
149
+ * Enter where the placement allows it. The Form Widget answers it (`inline` or `external`); the form
150
+ * answers only what its button says and where on its grid it stands, because that is geometry of the
151
+ * same grid its fields are laid out on.
152
+ */
153
+ export type PhiFormSubmitDescriptor = {
154
+ /** What it says. Absent is `PHI_FORM_DEFAULT_SUBMIT_LABEL`. */
155
+ label?: PhiFormTextDescriptor;
156
+ /** Where inside its range it sits. Absent is `start`: under the first input, where the eye is. */
157
+ align?: PhiFormLogicalAlignment;
158
+ /**
159
+ * The tracks it stands on. Absent is the layout's control range -- the tracks a field that says
160
+ * nothing about itself puts its control on, including the label column a Form Layout may move.
161
+ */
162
+ control?: PhiFormResponsiveGridRange;
163
+ };
164
+ /** What a submit says where the descriptor names nothing: the form's own word for it, else "Submit". */
165
+ export declare const PHI_FORM_DEFAULT_SUBMIT_LABEL: {
166
+ readonly kind: "label";
167
+ readonly key: "actions.submitLabel";
168
+ readonly fallback: "Submit";
169
+ };
170
+ export type PhiFormSuccessDescriptor = {
171
+ title: PhiFormTextDescriptor;
172
+ text?: PhiFormTextDescriptor;
173
+ /** Whether the fields go back to their initial values, ready for another entry. */
174
+ reset?: boolean;
175
+ /**
176
+ * Whether the form has done its one job once it succeeds: the success stays, the fields and the
177
+ * Widget's submit go. A confirmation link is spent by its first submit, and a button left standing
178
+ * would only offer to spend it again.
179
+ */
180
+ complete?: boolean;
181
+ };
182
+ export type PhiFormDescriptor = {
183
+ schemaVersion: typeof PHI_FORM_DESCRIPTOR_SCHEMA_VERSION;
184
+ key: string;
185
+ labelSetKey?: PhiFormLabelSetKey;
186
+ fields: readonly PhiFormFieldDescriptor[];
187
+ layout?: PhiFormLayoutDescriptor;
188
+ success?: PhiFormSuccessDescriptor;
189
+ /**
190
+ * What the form says when the server refuses it, by the `code` that refusal carries.
191
+ *
192
+ * The counterpart of `success`, and there for the same reason: what a visitor reads has to be in
193
+ * their language, and a response body cannot be. A handler answers `{ code: "invalid_credentials" }`
194
+ * and the wording is looked up here, in the form's own label set. A code nothing maps falls back to
195
+ * the body's `error` line, which is English because its other reader is a log.
196
+ */
197
+ errors?: Readonly<Record<string, PhiFormTextDescriptor>>;
198
+ /**
199
+ * Whether what has been typed survives leaving the page, for as long as the tab is open.
200
+ *
201
+ * For the long form somebody fills in once and would have to fill in again after following a link to
202
+ * read the terms. Session storage, never local: a half-finished registration on a shared machine is
203
+ * not something to leave behind, and it is cleared the moment the form is accepted.
204
+ */
205
+ persistDraft?: boolean;
206
+ /**
207
+ * That the server accepts this form only with a guard token: `issuedAt` and `formToken`, signed by
208
+ * phis-server when the form was shown and refused if it comes back too fast or too late.
209
+ *
210
+ * The browser asks for the token when the form mounts (`/api/site/forms?phase=guard`) and adds it to
211
+ * what it submits. It is not rendered into the page, so a page with a guarded form is the same for
212
+ * every visitor and can be cached.
213
+ */
214
+ guard?: boolean;
215
+ submit?: PhiFormSubmitDescriptor;
216
+ };
217
+ /** The last grid line, one past the last track, because `end` is exclusive. */
218
+ export declare const PHI_FORM_GRID_LAST_LINE: number;
219
+ export declare function parsePhiFormDescriptor(value: unknown): PhiFormDescriptor;
220
+ /**
221
+ * What a form looks like when it says nothing: labels beside their controls at a third of the width,
222
+ * and stacked once the form is measured narrow, which is the one place a label beside a short input
223
+ * leaves the input no room. Line 9 of 24 is the third; the control takes everything after it.
224
+ *
225
+ * A third is the house column, the one the Login states for itself in `PHI_LOGIN_FORM_LAYOUT_CONFIG`:
226
+ * eight tracks hold a two-word label without wrapping it, and sixteen still read as the wider half.
227
+ */
228
+ export declare const PHI_FORM_DEFAULT_LAYOUT: {
229
+ readonly gap: {
230
+ readonly compact: "sm";
231
+ readonly medium: "base";
232
+ readonly wide: "base";
233
+ };
234
+ readonly labelAlign: "start";
235
+ readonly label: {
236
+ readonly compact: {
237
+ readonly start: 1;
238
+ readonly end: number;
239
+ };
240
+ readonly medium: {
241
+ readonly start: 1;
242
+ readonly end: 9;
243
+ };
244
+ readonly wide: {
245
+ readonly start: 1;
246
+ readonly end: 9;
247
+ };
248
+ };
249
+ readonly control: {
250
+ readonly compact: {
251
+ readonly start: 1;
252
+ readonly end: number;
253
+ };
254
+ readonly medium: {
255
+ readonly start: 9;
256
+ readonly end: number;
257
+ };
258
+ readonly wide: {
259
+ readonly start: 9;
260
+ readonly end: number;
261
+ };
262
+ };
263
+ };
264
+ /**
265
+ * The ranges a form reaches for again and again, named once.
266
+ *
267
+ * A field written as the same range for its label and its control is a stacked field, because the two
268
+ * cannot share a row; the half-width pairs are how a two-column form is said now that `columns` is
269
+ * gone. All three collapse to the full width when the form is measured narrow, which is the only
270
+ * width at which two columns of anything are worse than one.
271
+ */
272
+ export declare const PHI_FORM_ROW_FULL: {
273
+ readonly compact: {
274
+ readonly start: 1;
275
+ readonly end: number;
276
+ };
277
+ readonly medium: {
278
+ readonly start: 1;
279
+ readonly end: number;
280
+ };
281
+ readonly wide: {
282
+ readonly start: 1;
283
+ readonly end: number;
284
+ };
285
+ };
286
+ export declare const PHI_FORM_ROW_END_HALF: {
287
+ readonly compact: {
288
+ readonly start: 1;
289
+ readonly end: number;
290
+ };
291
+ readonly medium: {
292
+ readonly start: 13;
293
+ readonly end: number;
294
+ };
295
+ readonly wide: {
296
+ readonly start: 13;
297
+ readonly end: number;
298
+ };
299
+ };
300
+ /** Labels above their controls at every width: one range for both parts of every field. */
301
+ export declare const PHI_FORM_STACKED_LAYOUT: {
302
+ readonly label: {
303
+ readonly compact: {
304
+ readonly start: 1;
305
+ readonly end: number;
306
+ };
307
+ readonly medium: {
308
+ readonly start: 1;
309
+ readonly end: number;
310
+ };
311
+ readonly wide: {
312
+ readonly start: 1;
313
+ readonly end: number;
314
+ };
315
+ };
316
+ readonly control: {
317
+ readonly compact: {
318
+ readonly start: 1;
319
+ readonly end: number;
320
+ };
321
+ readonly medium: {
322
+ readonly start: 1;
323
+ readonly end: number;
324
+ };
325
+ readonly wide: {
326
+ readonly start: 1;
327
+ readonly end: number;
328
+ };
329
+ };
330
+ };
331
+ export declare const PHI_FORM_STACKED_END_HALF: {
332
+ readonly label: {
333
+ readonly compact: {
334
+ readonly start: 1;
335
+ readonly end: number;
336
+ };
337
+ readonly medium: {
338
+ readonly start: 13;
339
+ readonly end: number;
340
+ };
341
+ readonly wide: {
342
+ readonly start: 13;
343
+ readonly end: number;
344
+ };
345
+ };
346
+ readonly control: {
347
+ readonly compact: {
348
+ readonly start: 1;
349
+ readonly end: number;
350
+ };
351
+ readonly medium: {
352
+ readonly start: 13;
353
+ readonly end: number;
354
+ };
355
+ readonly wide: {
356
+ readonly start: 13;
357
+ readonly end: number;
358
+ };
359
+ };
360
+ };
361
+ /** One field of a two-column form whose labels stand beside their controls. */
362
+ export declare const PHI_FORM_SIDE_START_HALF: {
363
+ readonly label: {
364
+ readonly compact: {
365
+ readonly start: 1;
366
+ readonly end: 7;
367
+ };
368
+ readonly medium: {
369
+ readonly start: 1;
370
+ readonly end: 5;
371
+ };
372
+ readonly wide: {
373
+ readonly start: 1;
374
+ readonly end: 5;
375
+ };
376
+ };
377
+ readonly control: {
378
+ readonly compact: {
379
+ readonly start: 7;
380
+ readonly end: number;
381
+ };
382
+ readonly medium: {
383
+ readonly start: 5;
384
+ readonly end: 13;
385
+ };
386
+ readonly wide: {
387
+ readonly start: 5;
388
+ readonly end: 13;
389
+ };
390
+ };
391
+ };
392
+ export declare const PHI_FORM_SIDE_END_HALF: {
393
+ readonly label: {
394
+ readonly compact: {
395
+ readonly start: 1;
396
+ readonly end: 7;
397
+ };
398
+ readonly medium: {
399
+ readonly start: 13;
400
+ readonly end: 17;
401
+ };
402
+ readonly wide: {
403
+ readonly start: 13;
404
+ readonly end: 17;
405
+ };
406
+ };
407
+ readonly control: {
408
+ readonly compact: {
409
+ readonly start: 7;
410
+ readonly end: number;
411
+ };
412
+ readonly medium: {
413
+ readonly start: 17;
414
+ readonly end: number;
415
+ };
416
+ readonly wide: {
417
+ readonly start: 17;
418
+ readonly end: number;
419
+ };
420
+ };
421
+ };
422
+ /**
423
+ * Two columns filled the way a grid fills them, for a form long enough that saying it per field would
424
+ * be a list of alternations nobody can read or keep correct.
425
+ *
426
+ * A field that brings its own placement keeps it and, where it takes the whole width, starts the next
427
+ * field on a fresh row -- which is what the grid did when the column count decided this and not the
428
+ * field. Authoring sugar over the contract, never a second contract: what it produces is ordinary
429
+ * per-field placement.
430
+ */
431
+ export declare function phiFormFlowHalfColumns(fields: readonly PhiFormFieldDescriptor[]): PhiFormFieldDescriptor[];
432
+ /** A field that takes the whole row in a form that is otherwise two columns. */
433
+ export declare const PHI_FORM_STACKED_FULL: {
434
+ readonly label: {
435
+ readonly compact: {
436
+ readonly start: 1;
437
+ readonly end: number;
438
+ };
439
+ readonly medium: {
440
+ readonly start: 1;
441
+ readonly end: number;
442
+ };
443
+ readonly wide: {
444
+ readonly start: 1;
445
+ readonly end: number;
446
+ };
447
+ };
448
+ readonly control: {
449
+ readonly compact: {
450
+ readonly start: 1;
451
+ readonly end: number;
452
+ };
453
+ readonly medium: {
454
+ readonly start: 1;
455
+ readonly end: number;
456
+ };
457
+ readonly wide: {
458
+ readonly start: 1;
459
+ readonly end: number;
460
+ };
461
+ };
462
+ };
463
+ export type PhiResolvedFormResponsiveGridRange = {
464
+ compact: PhiFormGridRange;
465
+ medium: PhiFormGridRange;
466
+ wide: PhiFormGridRange;
467
+ };
468
+ export declare const PHI_FORM_RESPONSIVE_MODES: readonly ["compact", "medium", "wide"];
469
+ export type PhiFormResponsiveMode = (typeof PHI_FORM_RESPONSIVE_MODES)[number];
470
+ export type PhiResolvedFormLayout = {
471
+ gap: {
472
+ compact: PhiSpacingToken;
473
+ medium: PhiSpacingToken;
474
+ wide: PhiSpacingToken;
475
+ };
476
+ columnGap: {
477
+ compact: PhiSpacingToken;
478
+ medium: PhiSpacingToken;
479
+ wide: PhiSpacingToken;
480
+ };
481
+ labelAlign: "start" | "end";
482
+ label: PhiResolvedFormResponsiveGridRange;
483
+ control: PhiResolvedFormResponsiveGridRange;
484
+ };
485
+ export declare function assertPhiFormGridRange(range: PhiFormGridRange, path: string): PhiFormGridRange;
486
+ export declare function createPhiFormLiteralText(value: string): PhiFormTextDescriptor;
487
+ export declare function createPhiFormLabelText(key: string, fallback: string): PhiFormTextDescriptor;
488
+ export declare function resolvePhiFormText(text: PhiFormTextDescriptor, labels?: Readonly<Record<string, string>>, formConfig?: Readonly<Record<string, unknown>>): string;
489
+ export declare function assertPhiFormLabelSetKey(value: string): asserts value is PhiFormLabelSetKey;
490
+ export declare function resolvePhiFormResponsiveGridRange(value: PhiFormResponsiveGridRange | undefined, fallback: PhiResolvedFormResponsiveGridRange, path: string): PhiResolvedFormResponsiveGridRange;
491
+ export declare function resolvePhiFormLayout(layout?: PhiFormLayoutDescriptor): PhiResolvedFormLayout;
492
+ /**
493
+ * The range one part of a placement states for one mode, cascading only from the smaller modes of that
494
+ * same part -- never from the layout, which is the other part's business to decide.
495
+ */
496
+ export declare function statedPhiFormGridRange(value: PhiFormResponsiveGridRange | undefined, mode: PhiFormResponsiveMode): PhiFormGridRange | undefined;