@marktiderman/genesis-forms 0.0.0-stage → 0.1.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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/conformance/after-class-back-after-a-jump.scenario.json +217 -0
  4. package/conformance/after-class-contact-yes-completed.scenario.json +230 -0
  5. package/conformance/after-class-contact-yes-requires-name-and-email.scenario.json +218 -0
  6. package/conformance/after-class-happy-path.scenario.json +237 -0
  7. package/conformance/after-class-invalid-email-rejected.scenario.json +227 -0
  8. package/conformance/after-class-low-rating-skips-went-well.scenario.json +223 -0
  9. package/conformance/after-class-server-drops-unknown-and-hidden-answers.scenario.json +236 -0
  10. package/conformance/after-class-server-ignores-a-client-sent-coach.scenario.json +219 -0
  11. package/conformance/contact-details-anonymous-form-skips-identifying-fields.scenario.json +125 -0
  12. package/conformance/contact-details-named-form-asks-for-identifying-fields.scenario.json +129 -0
  13. package/conformance/monthly-pulse-default-ending.scenario.json +148 -0
  14. package/conformance/monthly-pulse-promoter-ending-via-jump.scenario.json +128 -0
  15. package/dist/engine-Cp5t2_Am.d.ts +75 -0
  16. package/dist/engine-LsPRTzt3.d.cts +75 -0
  17. package/dist/index.cjs +1205 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.d.cts +374 -0
  20. package/dist/index.d.ts +374 -0
  21. package/dist/index.js +1174 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/server.cjs +988 -0
  24. package/dist/server.cjs.map +1 -0
  25. package/dist/server.d.cts +52 -0
  26. package/dist/server.d.ts +52 -0
  27. package/dist/server.js +984 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/testing.cjs +1299 -0
  30. package/dist/testing.cjs.map +1 -0
  31. package/dist/testing.d.cts +129 -0
  32. package/dist/testing.d.ts +129 -0
  33. package/dist/testing.js +1293 -0
  34. package/dist/testing.js.map +1 -0
  35. package/dist/validate-j8Z188mF.d.cts +175 -0
  36. package/dist/validate-j8Z188mF.d.ts +175 -0
  37. package/package.json +79 -3
@@ -0,0 +1,125 @@
1
+ {
2
+ "scenarioVersion": 1,
3
+ "name": "contact-details: anonymous form skips identifying fields",
4
+ "description": "With settings.anonymous true, identifying fields are off the path and not required; the server drops client-sent values for them and lists them in ignored.",
5
+ "tags": [
6
+ "contact-details",
7
+ "identifying",
8
+ "anonymous",
9
+ "server",
10
+ "security"
11
+ ],
12
+ "definition": {
13
+ "schemaVersion": 1,
14
+ "id": "contact-details-anonymous",
15
+ "title": "Tell us about your visit",
16
+ "blocks": [
17
+ {
18
+ "kind": "field",
19
+ "id": "overall",
20
+ "key": "overall",
21
+ "type": "rating",
22
+ "label": "How was your visit?",
23
+ "required": true,
24
+ "props": {
25
+ "max": 5
26
+ }
27
+ },
28
+ {
29
+ "kind": "field",
30
+ "id": "name",
31
+ "key": "name",
32
+ "type": "short-text",
33
+ "label": "Your name",
34
+ "required": true,
35
+ "identifying": true
36
+ },
37
+ {
38
+ "kind": "field",
39
+ "id": "email",
40
+ "key": "email",
41
+ "type": "email",
42
+ "label": "Email",
43
+ "required": true,
44
+ "identifying": true
45
+ },
46
+ {
47
+ "kind": "field",
48
+ "id": "phone",
49
+ "key": "phone",
50
+ "type": "phone",
51
+ "label": "Phone",
52
+ "identifying": true
53
+ },
54
+ {
55
+ "kind": "field",
56
+ "id": "comments",
57
+ "key": "comments",
58
+ "type": "long-text",
59
+ "label": "Anything else?"
60
+ }
61
+ ],
62
+ "endings": [
63
+ {
64
+ "id": "default",
65
+ "title": "Thanks!"
66
+ }
67
+ ],
68
+ "settings": {
69
+ "anonymous": true
70
+ }
71
+ },
72
+ "events": [
73
+ {
74
+ "type": "answer",
75
+ "field": "overall",
76
+ "value": 5
77
+ },
78
+ {
79
+ "type": "answer",
80
+ "field": "name",
81
+ "value": "Ada"
82
+ },
83
+ {
84
+ "type": "answer",
85
+ "field": "email",
86
+ "value": "ada@example.com"
87
+ },
88
+ {
89
+ "type": "answer",
90
+ "field": "phone",
91
+ "value": "+1 555 010 0199"
92
+ },
93
+ {
94
+ "type": "submit"
95
+ }
96
+ ],
97
+ "expect": {
98
+ "path": [
99
+ "overall",
100
+ "comments"
101
+ ],
102
+ "visible": [
103
+ "overall",
104
+ "comments"
105
+ ],
106
+ "current": "overall",
107
+ "status": "completed",
108
+ "ending": "default",
109
+ "answers": {
110
+ "overall": 5
111
+ },
112
+ "server": {
113
+ "ok": true,
114
+ "answers": {
115
+ "overall": 5
116
+ },
117
+ "context": {},
118
+ "ignored": [
119
+ "name",
120
+ "email",
121
+ "phone"
122
+ ]
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,129 @@
1
+ {
2
+ "scenarioVersion": 1,
3
+ "name": "contact-details: named form asks for identifying fields",
4
+ "description": "With settings.anonymous false, name, email and phone are asked, validated and stored (email and phone trimmed).",
5
+ "tags": [
6
+ "contact-details",
7
+ "identifying"
8
+ ],
9
+ "definition": {
10
+ "schemaVersion": 1,
11
+ "id": "contact-details",
12
+ "title": "Tell us about your visit",
13
+ "blocks": [
14
+ {
15
+ "kind": "field",
16
+ "id": "overall",
17
+ "key": "overall",
18
+ "type": "rating",
19
+ "label": "How was your visit?",
20
+ "required": true,
21
+ "props": {
22
+ "max": 5
23
+ }
24
+ },
25
+ {
26
+ "kind": "field",
27
+ "id": "name",
28
+ "key": "name",
29
+ "type": "short-text",
30
+ "label": "Your name",
31
+ "required": true,
32
+ "identifying": true
33
+ },
34
+ {
35
+ "kind": "field",
36
+ "id": "email",
37
+ "key": "email",
38
+ "type": "email",
39
+ "label": "Email",
40
+ "required": true,
41
+ "identifying": true
42
+ },
43
+ {
44
+ "kind": "field",
45
+ "id": "phone",
46
+ "key": "phone",
47
+ "type": "phone",
48
+ "label": "Phone",
49
+ "identifying": true
50
+ },
51
+ {
52
+ "kind": "field",
53
+ "id": "comments",
54
+ "key": "comments",
55
+ "type": "long-text",
56
+ "label": "Anything else?"
57
+ }
58
+ ],
59
+ "endings": [
60
+ {
61
+ "id": "default",
62
+ "title": "Thanks!"
63
+ }
64
+ ],
65
+ "settings": {
66
+ "anonymous": false
67
+ }
68
+ },
69
+ "events": [
70
+ {
71
+ "type": "answer",
72
+ "field": "overall",
73
+ "value": 4
74
+ },
75
+ {
76
+ "type": "answer",
77
+ "field": "name",
78
+ "value": "Ada"
79
+ },
80
+ {
81
+ "type": "answer",
82
+ "field": "email",
83
+ "value": " ada@example.com "
84
+ },
85
+ {
86
+ "type": "answer",
87
+ "field": "phone",
88
+ "value": "+1 (555) 010-0199"
89
+ },
90
+ {
91
+ "type": "submit"
92
+ }
93
+ ],
94
+ "expect": {
95
+ "path": [
96
+ "overall",
97
+ "name",
98
+ "email",
99
+ "phone",
100
+ "comments"
101
+ ],
102
+ "visible": [
103
+ "overall",
104
+ "name",
105
+ "email",
106
+ "phone",
107
+ "comments"
108
+ ],
109
+ "current": "overall",
110
+ "status": "completed",
111
+ "ending": "default",
112
+ "answers": {
113
+ "overall": 4,
114
+ "name": "Ada",
115
+ "email": "ada@example.com",
116
+ "phone": "+1 (555) 010-0199"
117
+ },
118
+ "server": {
119
+ "ok": true,
120
+ "answers": {
121
+ "overall": 4,
122
+ "name": "Ada",
123
+ "email": "ada@example.com",
124
+ "phone": "+1 (555) 010-0199"
125
+ },
126
+ "context": {}
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,148 @@
1
+ {
2
+ "scenarioVersion": 1,
3
+ "name": "monthly-pulse: default ending",
4
+ "description": "NPS 8 is one under the jump's boundary: every question is asked and the default ending is reached.",
5
+ "tags": [
6
+ "monthly-pulse",
7
+ "ending"
8
+ ],
9
+ "definition": {
10
+ "schemaVersion": 1,
11
+ "id": "monthly-pulse",
12
+ "title": "Monthly check-in",
13
+ "blocks": [
14
+ {
15
+ "kind": "field",
16
+ "id": "recommend",
17
+ "key": "recommend",
18
+ "type": "nps",
19
+ "label": "How likely are you to recommend CrossFit Fairfield to a friend?",
20
+ "required": true
21
+ },
22
+ {
23
+ "kind": "field",
24
+ "id": "why",
25
+ "key": "why",
26
+ "type": "long-text",
27
+ "label": "What's the main reason for your score?"
28
+ },
29
+ {
30
+ "kind": "field",
31
+ "id": "frequency",
32
+ "key": "frequency",
33
+ "type": "single-choice",
34
+ "label": "How often did you train with us this month?",
35
+ "props": {
36
+ "options": [
37
+ {
38
+ "value": "1-4",
39
+ "label": "1–4 times"
40
+ },
41
+ {
42
+ "value": "5-8",
43
+ "label": "5–8 times"
44
+ },
45
+ {
46
+ "value": "9-12",
47
+ "label": "9–12 times"
48
+ },
49
+ {
50
+ "value": "13+",
51
+ "label": "13 or more"
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ ],
57
+ "layout": {
58
+ "mode": "focus"
59
+ },
60
+ "logic": [
61
+ {
62
+ "id": "r1",
63
+ "when": {
64
+ "op": "gte",
65
+ "left": {
66
+ "field": "recommend"
67
+ },
68
+ "right": {
69
+ "value": 9
70
+ }
71
+ },
72
+ "effect": "jump",
73
+ "from": "recommend",
74
+ "to": {
75
+ "ending": "promoter"
76
+ }
77
+ }
78
+ ],
79
+ "endings": [
80
+ {
81
+ "id": "default",
82
+ "title": "Thanks for the honest feedback."
83
+ },
84
+ {
85
+ "id": "promoter",
86
+ "title": "Thank you! Bring a friend to a free class anytime."
87
+ }
88
+ ],
89
+ "settings": {
90
+ "anonymous": true
91
+ }
92
+ },
93
+ "events": [
94
+ {
95
+ "type": "answer",
96
+ "field": "recommend",
97
+ "value": 8
98
+ },
99
+ {
100
+ "type": "next"
101
+ },
102
+ {
103
+ "type": "answer",
104
+ "field": "why",
105
+ "value": "Busy month"
106
+ },
107
+ {
108
+ "type": "next"
109
+ },
110
+ {
111
+ "type": "answer",
112
+ "field": "frequency",
113
+ "value": "5-8"
114
+ },
115
+ {
116
+ "type": "submit"
117
+ }
118
+ ],
119
+ "expect": {
120
+ "path": [
121
+ "recommend",
122
+ "why",
123
+ "frequency"
124
+ ],
125
+ "visible": [
126
+ "recommend",
127
+ "why",
128
+ "frequency"
129
+ ],
130
+ "current": "frequency",
131
+ "status": "completed",
132
+ "ending": "default",
133
+ "answers": {
134
+ "recommend": 8,
135
+ "why": "Busy month",
136
+ "frequency": "5-8"
137
+ },
138
+ "server": {
139
+ "ok": true,
140
+ "answers": {
141
+ "recommend": 8,
142
+ "why": "Busy month",
143
+ "frequency": "5-8"
144
+ },
145
+ "context": {}
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,128 @@
1
+ {
2
+ "scenarioVersion": 1,
3
+ "name": "monthly-pulse: promoter ending via jump",
4
+ "description": "NPS 9 or more jumps straight to the promoter ending; why and frequency are never asked.",
5
+ "tags": [
6
+ "monthly-pulse",
7
+ "jump",
8
+ "ending"
9
+ ],
10
+ "definition": {
11
+ "schemaVersion": 1,
12
+ "id": "monthly-pulse",
13
+ "title": "Monthly check-in",
14
+ "blocks": [
15
+ {
16
+ "kind": "field",
17
+ "id": "recommend",
18
+ "key": "recommend",
19
+ "type": "nps",
20
+ "label": "How likely are you to recommend CrossFit Fairfield to a friend?",
21
+ "required": true
22
+ },
23
+ {
24
+ "kind": "field",
25
+ "id": "why",
26
+ "key": "why",
27
+ "type": "long-text",
28
+ "label": "What's the main reason for your score?"
29
+ },
30
+ {
31
+ "kind": "field",
32
+ "id": "frequency",
33
+ "key": "frequency",
34
+ "type": "single-choice",
35
+ "label": "How often did you train with us this month?",
36
+ "props": {
37
+ "options": [
38
+ {
39
+ "value": "1-4",
40
+ "label": "1–4 times"
41
+ },
42
+ {
43
+ "value": "5-8",
44
+ "label": "5–8 times"
45
+ },
46
+ {
47
+ "value": "9-12",
48
+ "label": "9–12 times"
49
+ },
50
+ {
51
+ "value": "13+",
52
+ "label": "13 or more"
53
+ }
54
+ ]
55
+ }
56
+ }
57
+ ],
58
+ "layout": {
59
+ "mode": "focus"
60
+ },
61
+ "logic": [
62
+ {
63
+ "id": "r1",
64
+ "when": {
65
+ "op": "gte",
66
+ "left": {
67
+ "field": "recommend"
68
+ },
69
+ "right": {
70
+ "value": 9
71
+ }
72
+ },
73
+ "effect": "jump",
74
+ "from": "recommend",
75
+ "to": {
76
+ "ending": "promoter"
77
+ }
78
+ }
79
+ ],
80
+ "endings": [
81
+ {
82
+ "id": "default",
83
+ "title": "Thanks for the honest feedback."
84
+ },
85
+ {
86
+ "id": "promoter",
87
+ "title": "Thank you! Bring a friend to a free class anytime."
88
+ }
89
+ ],
90
+ "settings": {
91
+ "anonymous": true
92
+ }
93
+ },
94
+ "events": [
95
+ {
96
+ "type": "answer",
97
+ "field": "recommend",
98
+ "value": 9
99
+ },
100
+ {
101
+ "type": "next"
102
+ },
103
+ {
104
+ "type": "submit"
105
+ }
106
+ ],
107
+ "expect": {
108
+ "path": [
109
+ "recommend"
110
+ ],
111
+ "visible": [
112
+ "recommend"
113
+ ],
114
+ "current": "recommend",
115
+ "status": "completed",
116
+ "ending": "promoter",
117
+ "answers": {
118
+ "recommend": 9
119
+ },
120
+ "server": {
121
+ "ok": true,
122
+ "answers": {
123
+ "recommend": 9
124
+ },
125
+ "context": {}
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,75 @@
1
+ import { f as FormEvent, J as Json, e as FieldValidator, a as FormDefinition, F as FieldBlock, B as Block } from './validate-j8Z188mF.js';
2
+
3
+ /** Guards and helpers for event logs and other untrusted JSON. No dependencies. */
4
+
5
+ /**
6
+ * Whether a value is JSON: strings, finite numbers, booleans, null, arrays and
7
+ * plain objects, nested to any depth. Iterative, so a deeply nested value
8
+ * cannot overflow the stack.
9
+ */
10
+ declare function isJson(value: unknown): value is Json;
11
+ /** Whether `value` is a well-formed `FormEvent` of a known type. */
12
+ declare function isFormEvent(value: unknown): value is FormEvent;
13
+ /**
14
+ * Merge consecutive answer events to the same field, keeping the last value.
15
+ * Text inputs log one event per keystroke; the merged log replays to the same
16
+ * state, because the path is derived from values alone.
17
+ */
18
+ declare function compactEvents(events: FormEvent[]): FormEvent[];
19
+
20
+ interface FormState {
21
+ /** Answers keyed by block id. */
22
+ values: Record<string, Json | undefined>;
23
+ /** URL and host inputs (untrusted) and server-resolved inputs, by name. */
24
+ hidden: Record<string, Json | undefined>;
25
+ /** Block ids the respondent steps through, given the answers so far. */
26
+ path: string[];
27
+ /** The block on screen in focus layout. */
28
+ current: string | null;
29
+ /** Visited blocks, for Back. */
30
+ history: string[];
31
+ errors: Record<string, string>;
32
+ status: "in_progress" | "completed";
33
+ /** The ending reached once completed. */
34
+ ending: string | null;
35
+ events: FormEvent[];
36
+ }
37
+ interface CreateFormOptions {
38
+ /** Prefilled answers by field id. Logged as leading `answer` events with `source: "prefill"`. */
39
+ initialValues?: Record<string, Json>;
40
+ /** Hidden inputs by name. Declared ones are typed with `coerceHidden`, as the server types them. */
41
+ hidden?: Record<string, Json | undefined>;
42
+ validators?: Record<string, FieldValidator>;
43
+ /** Timestamps on events. Pass `() => undefined` to record none (anonymous forms). */
44
+ clock?: () => number | undefined;
45
+ }
46
+ interface FormEngine {
47
+ readonly definition: FormDefinition;
48
+ getState(): FormState;
49
+ subscribe(listener: () => void): () => void;
50
+ /** A field's id, from its key. */
51
+ idOf(key: string): string;
52
+ field(id: string): FieldBlock | undefined;
53
+ block(id: string): Block | undefined;
54
+ isVisible(id: string): boolean;
55
+ /**
56
+ * Set an answer. Like `next`, `back` and `submit`, it does nothing once the
57
+ * response is completed: a completed response is final, so its stored
58
+ * answers are the ones that were validated.
59
+ */
60
+ setValue(id: string, value: Json | undefined): void;
61
+ /** Validate the current step and move on. Returns false if it has errors. */
62
+ next(): boolean;
63
+ back(): void;
64
+ /** Validate every visible field and complete. Returns false if it has errors or is already completed. */
65
+ submit(): boolean;
66
+ /** Text with `{{recall}}` tokens resolved. */
67
+ text(template: string): string;
68
+ /** Visible field values only, keyed by id: what a submit stores. */
69
+ answers(): Record<string, Json>;
70
+ }
71
+ declare function createForm(definition: FormDefinition, options?: CreateFormOptions): FormEngine;
72
+ /** Rebuild a form's state from its event log. Timestamps are ignored. */
73
+ declare function replay(definition: FormDefinition, events: FormEvent[], options?: Omit<CreateFormOptions, "clock" | "initialValues">): FormEngine;
74
+
75
+ export { type CreateFormOptions as C, type FormEngine as F, type FormState as a, isJson as b, createForm as c, compactEvents as d, isFormEvent as i, replay as r };
@@ -0,0 +1,75 @@
1
+ import { f as FormEvent, J as Json, e as FieldValidator, a as FormDefinition, F as FieldBlock, B as Block } from './validate-j8Z188mF.cjs';
2
+
3
+ /** Guards and helpers for event logs and other untrusted JSON. No dependencies. */
4
+
5
+ /**
6
+ * Whether a value is JSON: strings, finite numbers, booleans, null, arrays and
7
+ * plain objects, nested to any depth. Iterative, so a deeply nested value
8
+ * cannot overflow the stack.
9
+ */
10
+ declare function isJson(value: unknown): value is Json;
11
+ /** Whether `value` is a well-formed `FormEvent` of a known type. */
12
+ declare function isFormEvent(value: unknown): value is FormEvent;
13
+ /**
14
+ * Merge consecutive answer events to the same field, keeping the last value.
15
+ * Text inputs log one event per keystroke; the merged log replays to the same
16
+ * state, because the path is derived from values alone.
17
+ */
18
+ declare function compactEvents(events: FormEvent[]): FormEvent[];
19
+
20
+ interface FormState {
21
+ /** Answers keyed by block id. */
22
+ values: Record<string, Json | undefined>;
23
+ /** URL and host inputs (untrusted) and server-resolved inputs, by name. */
24
+ hidden: Record<string, Json | undefined>;
25
+ /** Block ids the respondent steps through, given the answers so far. */
26
+ path: string[];
27
+ /** The block on screen in focus layout. */
28
+ current: string | null;
29
+ /** Visited blocks, for Back. */
30
+ history: string[];
31
+ errors: Record<string, string>;
32
+ status: "in_progress" | "completed";
33
+ /** The ending reached once completed. */
34
+ ending: string | null;
35
+ events: FormEvent[];
36
+ }
37
+ interface CreateFormOptions {
38
+ /** Prefilled answers by field id. Logged as leading `answer` events with `source: "prefill"`. */
39
+ initialValues?: Record<string, Json>;
40
+ /** Hidden inputs by name. Declared ones are typed with `coerceHidden`, as the server types them. */
41
+ hidden?: Record<string, Json | undefined>;
42
+ validators?: Record<string, FieldValidator>;
43
+ /** Timestamps on events. Pass `() => undefined` to record none (anonymous forms). */
44
+ clock?: () => number | undefined;
45
+ }
46
+ interface FormEngine {
47
+ readonly definition: FormDefinition;
48
+ getState(): FormState;
49
+ subscribe(listener: () => void): () => void;
50
+ /** A field's id, from its key. */
51
+ idOf(key: string): string;
52
+ field(id: string): FieldBlock | undefined;
53
+ block(id: string): Block | undefined;
54
+ isVisible(id: string): boolean;
55
+ /**
56
+ * Set an answer. Like `next`, `back` and `submit`, it does nothing once the
57
+ * response is completed: a completed response is final, so its stored
58
+ * answers are the ones that were validated.
59
+ */
60
+ setValue(id: string, value: Json | undefined): void;
61
+ /** Validate the current step and move on. Returns false if it has errors. */
62
+ next(): boolean;
63
+ back(): void;
64
+ /** Validate every visible field and complete. Returns false if it has errors or is already completed. */
65
+ submit(): boolean;
66
+ /** Text with `{{recall}}` tokens resolved. */
67
+ text(template: string): string;
68
+ /** Visible field values only, keyed by id: what a submit stores. */
69
+ answers(): Record<string, Json>;
70
+ }
71
+ declare function createForm(definition: FormDefinition, options?: CreateFormOptions): FormEngine;
72
+ /** Rebuild a form's state from its event log. Timestamps are ignored. */
73
+ declare function replay(definition: FormDefinition, events: FormEvent[], options?: Omit<CreateFormOptions, "clock" | "initialValues">): FormEngine;
74
+
75
+ export { type CreateFormOptions as C, type FormEngine as F, type FormState as a, isJson as b, createForm as c, compactEvents as d, isFormEvent as i, replay as r };