@sketchscreens/core-schema 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.
package/src/schema.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The SketchScreens contract.
5
+ *
6
+ * One JSON shape, produced by the extractor and consumed by the renderer.
7
+ * It is deliberately stack-agnostic: a Next.js screen and a Flutter screen
8
+ * both reduce to the same `ScreenSpec`, so the renderer draws them identically.
9
+ *
10
+ * Design note: `ElementType` is a CLOSED enum on purpose. Each value maps 1:1
11
+ * onto a renderable hand-drawn primitive (mostly wired-elements). A closed set
12
+ * keeps every map renderable and stops the LLM extractor from inventing element
13
+ * types the renderer can't draw.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Elements — the things that appear on a screen, in top-to-bottom order.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** The closed set of wireframe primitives. Each maps to a renderable widget. */
21
+ export const ElementType = z.enum([
22
+ "heading", // a title / section header
23
+ "text", // a paragraph or helper/caption text
24
+ "input", // a single-line text field
25
+ "textarea", // a multi-line text field
26
+ "button", // a clickable button (may own a nav edge via its label)
27
+ "checkbox", // a single checkbox
28
+ "radio", // a radio option (usually one of a group)
29
+ "toggle", // an on/off switch
30
+ "select", // a dropdown / combo
31
+ "listbox", // a scrollable option list
32
+ "card", // a bordered container / tile
33
+ "image", // an image or avatar placeholder
34
+ "list", // a repeated row list (feeds, tables-as-list)
35
+ "divider", // a visual separator
36
+ "nav", // a nav bar / tab bar / menu
37
+ "tabs", // a tab strip
38
+ ]);
39
+ export type ElementType = z.infer<typeof ElementType>;
40
+
41
+ /** Optional visual role for buttons, so the renderer can vary emphasis. */
42
+ export const ElementVariant = z.enum([
43
+ "primary",
44
+ "secondary",
45
+ "destructive",
46
+ "ghost",
47
+ "link",
48
+ ]);
49
+ export type ElementVariant = z.infer<typeof ElementVariant>;
50
+
51
+ /**
52
+ * Which vertical band of the screen an element sits in. `top` = a header / nav
53
+ * bar pinned to the top; `bottom` = a footer / sticky action bar; `main` = the
54
+ * scrollable body (the default). Lets the sketch mirror the real screen's
55
+ * coarse layout without pixel positions.
56
+ */
57
+ export const ElementRegion = z.enum(["top", "main", "bottom"]);
58
+ export type ElementRegion = z.infer<typeof ElementRegion>;
59
+
60
+ /**
61
+ * Horizontal placement of an element within its band. `full` stretches edge to
62
+ * edge (inputs, nav bars, lists); `center` is a centered block (a hero button,
63
+ * a centered card); `left`/`right` hug a side. Default is `full` for fields and
64
+ * `left` for text.
65
+ */
66
+ export const ElementAlign = z.enum(["left", "center", "right", "full"]);
67
+ export type ElementAlign = z.infer<typeof ElementAlign>;
68
+
69
+ /**
70
+ * One UI element on a screen. `label` is the visible text (button caption,
71
+ * field label, heading text). Order in the `elements` array = top-to-bottom
72
+ * position in the sketch (structural fidelity, not pixel-exact).
73
+ */
74
+ export const ScreenElement = z.object({
75
+ type: ElementType,
76
+ /** Visible text: caption, label, or heading. Optional for dividers/images. */
77
+ label: z.string().optional(),
78
+ /** Placeholder / hint text for inputs. */
79
+ placeholder: z.string().optional(),
80
+ /** Visual emphasis (mainly for buttons). */
81
+ variant: ElementVariant.optional(),
82
+ /** True for password / masked inputs. */
83
+ secure: z.boolean().optional(),
84
+ /** True if the element is marked required in the source. */
85
+ required: z.boolean().optional(),
86
+ /**
87
+ * Optional grouping key. Elements sharing a group render inside one
88
+ * card/section, preserving the source's visual grouping.
89
+ */
90
+ group: z.string().optional(),
91
+ /**
92
+ * Which band of the screen this element sits in (top nav / main / bottom
93
+ * bar). Defaults to "main". Lets the sketch mirror the real layout.
94
+ */
95
+ region: ElementRegion.optional(),
96
+ /**
97
+ * Horizontal placement within the band. Defaults sensibly by type (fields
98
+ * full-width, text left). Set "center" for a centered hero button/card.
99
+ */
100
+ align: ElementAlign.optional(),
101
+ /**
102
+ * Whether `label` is the app's verbatim text or the extractor's SHAPE
103
+ * description of a dynamic region (a list/table it couldn't read literally).
104
+ * Defaults to "verbatim". The renderer styles "descriptive" labels distinctly
105
+ * so a reviewer sees "this is the tool's summary, not the app's words."
106
+ */
107
+ labelKind: z.enum(["verbatim", "descriptive"]).optional(),
108
+ /** Free-form note the extractor wants to surface (e.g. "conditionally shown"). */
109
+ note: z.string().optional(),
110
+ });
111
+ export type ScreenElement = z.infer<typeof ScreenElement>;
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Screens
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** One screen = one node in the map. */
118
+ export const ScreenSpec = z.object({
119
+ /** Stable id, unique within the map. Referenced by edges. */
120
+ id: z.string().min(1),
121
+ /** Human-friendly screen name (e.g. "Login"). */
122
+ name: z.string().min(1),
123
+ /**
124
+ * The route or address of this screen: a URL path (`/settings/profile`),
125
+ * a Flutter widget class (`SettingsScreen`), or an API path group. Optional
126
+ * because not every screen is addressable (e.g. a modal).
127
+ */
128
+ route: z.string().optional(),
129
+ /** Repo-relative path of the file this screen was extracted from. */
130
+ sourceFile: z.string().optional(),
131
+ /** The elements on this screen, in top-to-bottom order. */
132
+ elements: z.array(ScreenElement).default([]),
133
+ /** Optional short description / purpose of the screen. */
134
+ description: z.string().optional(),
135
+ /**
136
+ * Optional label-path for this screen's section, most-general first, using
137
+ * " › " as the separator — e.g. "Settings › AI Settings". Groups cluster and
138
+ * label screens; the renderer nests group labels. Deriveable from the route.
139
+ * (For the overall journey SHAPE, prefer `parent` below.)
140
+ */
141
+ group: z.string().optional(),
142
+ /**
143
+ * Optional id of the screen this one hangs beneath in the JOURNEY tree — the
144
+ * screen a user reaches this one *from*. This is what roots the map as a real
145
+ * flow: an entry screen has no parent; the auth screen's parent is the entry;
146
+ * each feature-section hub's parent is the dashboard; etc. When set, it
147
+ * overrides route-derived nesting so the tree matches how users actually
148
+ * navigate. Must reference another screen's `id`.
149
+ */
150
+ parent: z.string().optional(),
151
+ /**
152
+ * Optional hint that this screen is the app's entry point / root of the
153
+ * journey (what the user sees first). At most one screen should set this; the
154
+ * renderer roots the tree here. If none is set, the renderer infers a root.
155
+ */
156
+ isEntry: z.boolean().optional(),
157
+ /**
158
+ * How this screen is presented. "screen" (default) is a full page/route; the
159
+ * others are overlays. The renderer draws non-"screen" presentations with a
160
+ * distinct overlay frame and attaches them to their opener via an edge rather
161
+ * than the journey backbone.
162
+ */
163
+ presentation: z.enum(["screen", "modal", "drawer", "sheet"]).optional(),
164
+ /**
165
+ * Optional state variant this screen represents (an empty/error/loading view
166
+ * of another screen). The renderer can cluster it beside its base screen.
167
+ */
168
+ state: z.enum(["default", "empty", "error", "loading"]).optional(),
169
+ });
170
+ export type ScreenSpec = z.infer<typeof ScreenSpec>;
171
+
172
+ /** Split a group path ("Settings › AI Settings") into its segments. */
173
+ export function groupSegments(group: string | undefined): string[] {
174
+ if (!group) return [];
175
+ return group
176
+ .split("›")
177
+ .map((s) => s.trim())
178
+ .filter(Boolean);
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Edges — navigation between screens
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** A navigation transition from one screen to another. */
186
+ export const Edge = z.object({
187
+ /** Source screen id. */
188
+ from: z.string().min(1),
189
+ /** Destination screen id. */
190
+ to: z.string().min(1),
191
+ /**
192
+ * What triggers the transition — usually a button/link label
193
+ * ("Continue", "Forgot?"). Rendered as the edge label.
194
+ */
195
+ trigger: z.string().optional(),
196
+ /** How the transition is expressed, if useful (e.g. "redirect", "push", "link"). */
197
+ kind: z.string().optional(),
198
+ });
199
+ export type Edge = z.infer<typeof Edge>;
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // The map
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Which surface this map represents. Each surface is its own map — a web app,
207
+ * a mobile app, a CRM, and a backend are mapped separately, not tangled into one.
208
+ */
209
+ export const Surface = z.enum([
210
+ "web",
211
+ "mobile",
212
+ "crm",
213
+ "admin",
214
+ "backend",
215
+ "desktop",
216
+ "other",
217
+ ]);
218
+ export type Surface = z.infer<typeof Surface>;
219
+
220
+ /** Optional provenance / metadata about how this map was produced. */
221
+ export const ProjectMapMeta = z.object({
222
+ /** The tool version that produced the map. */
223
+ generator: z.string().optional(),
224
+ /** Repo-relative or absolute root the map was extracted from. */
225
+ repoRoot: z.string().optional(),
226
+ /** ISO timestamp string (set by the producer; schema stays time-agnostic). */
227
+ generatedAt: z.string().optional(),
228
+ /** How elements were derived: "agent", "static", or "hybrid". */
229
+ extraction: z.enum(["agent", "static", "hybrid", "manual"]).optional(),
230
+ });
231
+ export type ProjectMapMeta = z.infer<typeof ProjectMapMeta>;
232
+
233
+ /** The contract version this build of SketchScreens speaks. */
234
+ export const CONTRACT_VERSION = 1;
235
+
236
+ /** The whole thing: one surface's screens + the flow between them. */
237
+ export const ProjectMap = z.object({
238
+ /**
239
+ * Contract version. Accepts any positive integer so an OLD renderer reading a
240
+ * NEWER map fails with a clear "upgrade" message (see validate.ts) rather than
241
+ * a generic zod "invalid literal". New fields are additive; bump only on a
242
+ * breaking change.
243
+ */
244
+ version: z.number().int().min(1).default(CONTRACT_VERSION),
245
+ /** Display name for this map (e.g. "AiPhone 360 — Web App"). */
246
+ name: z.string().min(1),
247
+ /** Which surface this represents. */
248
+ surface: Surface,
249
+ /** The screens (nodes). */
250
+ screens: z.array(ScreenSpec).default([]),
251
+ /** The navigation transitions (edges). */
252
+ edges: z.array(Edge).default([]),
253
+ /** Optional provenance. */
254
+ meta: ProjectMapMeta.optional(),
255
+ });
256
+ export type ProjectMap = z.infer<typeof ProjectMap>;
@@ -0,0 +1,165 @@
1
+ import { ProjectMap, CONTRACT_VERSION } from "./schema.js";
2
+
3
+ /** How serious a validation issue is. Errors fail the gate; warnings don't. */
4
+ export type IssueSeverity = "error" | "warning";
5
+
6
+ /** A validation problem found in a ProjectMap. */
7
+ export interface ValidationIssue {
8
+ /** Machine-readable code. */
9
+ code:
10
+ // errors — the map is malformed and must not render
11
+ | "schema"
12
+ | "duplicate_screen_id"
13
+ | "edge_unknown_from"
14
+ | "edge_unknown_to"
15
+ | "self_edge"
16
+ | "parent_unknown"
17
+ | "parent_self"
18
+ | "parent_cycle"
19
+ // warnings — the map renders but something's off
20
+ | "no_screens"
21
+ | "entry_count"
22
+ | "duplicate_edge"
23
+ | "sourcefile_missing"
24
+ | "unsupported_version";
25
+ /** Error (fails the gate) or warning (renders anyway). Defaults to error. */
26
+ severity: IssueSeverity;
27
+ /** Human-readable message. */
28
+ message: string;
29
+ /** JSON-ish path to the offending value, when known. */
30
+ path?: string;
31
+ }
32
+
33
+ export interface ValidationResult {
34
+ /** True when there are no ERROR-severity issues (warnings don't fail it). */
35
+ ok: boolean;
36
+ /** The parsed map when the shape is valid (defaults applied), else undefined. */
37
+ map?: ProjectMap;
38
+ issues: ValidationIssue[];
39
+ }
40
+
41
+ /**
42
+ * Validate a raw object as a ProjectMap.
43
+ *
44
+ * Runs the zod schema first (shape + enums + defaults), then referential
45
+ * checks that zod can't express: unique screen ids and edges that point at
46
+ * screens that actually exist. This is the gate the extractor's output must
47
+ * pass before the renderer will draw it.
48
+ */
49
+ export function validateProjectMap(input: unknown): ValidationResult {
50
+ const parsed = ProjectMap.safeParse(input);
51
+ if (!parsed.success) {
52
+ const issues: ValidationIssue[] = parsed.error.issues.map((i) => ({
53
+ code: "schema",
54
+ severity: "error",
55
+ message: i.message,
56
+ path: i.path.join("."),
57
+ }));
58
+ return { ok: false, issues };
59
+ }
60
+
61
+ const map = parsed.data;
62
+ const issues: ValidationIssue[] = [];
63
+ const err = (code: ValidationIssue["code"], message: string, path?: string) =>
64
+ issues.push({ code, severity: "error", message, path });
65
+ const warn = (code: ValidationIssue["code"], message: string, path?: string) =>
66
+ issues.push({ code, severity: "warning", message, path });
67
+
68
+ // A map from a newer contract version — render what we can, but flag it.
69
+ if (map.version > CONTRACT_VERSION) {
70
+ warn(
71
+ "unsupported_version",
72
+ `Map is contract version ${map.version} but this SketchScreens speaks ${CONTRACT_VERSION} — some things may not render. Upgrade SketchScreens.`,
73
+ );
74
+ }
75
+
76
+ // A map with no screens renders as a blank canvas — warn rather than crash.
77
+ if (map.screens.length === 0) {
78
+ warn("no_screens", "The map has no screens.");
79
+ }
80
+
81
+ // Unique screen ids.
82
+ const seen = new Set<string>();
83
+ for (const screen of map.screens) {
84
+ if (seen.has(screen.id)) {
85
+ err("duplicate_screen_id", `Duplicate screen id "${screen.id}".`, `screens[id=${screen.id}]`);
86
+ }
87
+ seen.add(screen.id);
88
+ }
89
+
90
+ // At most one entry screen; ideally exactly one (the renderer infers if 0).
91
+ const entryCount = map.screens.filter((s) => s.isEntry).length;
92
+ if (entryCount > 1) {
93
+ warn(
94
+ "entry_count",
95
+ `${entryCount} screens are marked isEntry; there should be exactly one (the app's first screen).`,
96
+ );
97
+ }
98
+
99
+ // Edges must reference existing screens, and not point a screen at itself.
100
+ const edgeKeys = new Set<string>();
101
+ map.edges.forEach((edge, idx) => {
102
+ if (!seen.has(edge.from)) {
103
+ err("edge_unknown_from", `Edge #${idx} references unknown source screen "${edge.from}".`, `edges[${idx}].from`);
104
+ }
105
+ if (!seen.has(edge.to)) {
106
+ err("edge_unknown_to", `Edge #${idx} references unknown target screen "${edge.to}".`, `edges[${idx}].to`);
107
+ }
108
+ if (edge.from === edge.to) {
109
+ err("self_edge", `Edge #${idx} points screen "${edge.from}" at itself.`, `edges[${idx}]`);
110
+ }
111
+ const key = `${edge.from}->${edge.to}`;
112
+ if (edgeKeys.has(key)) {
113
+ warn("duplicate_edge", `Edge #${idx} duplicates ${key}.`, `edges[${idx}]`);
114
+ }
115
+ edgeKeys.add(key);
116
+ });
117
+
118
+ // Journey `parent` links: must reference an existing screen, not self, and
119
+ // must not form a cycle (walking parents from any screen must terminate).
120
+ const parentOf = new Map<string, string>();
121
+ for (const screen of map.screens) {
122
+ if (screen.parent === undefined) continue;
123
+ if (screen.parent === screen.id) {
124
+ err("parent_self", `Screen "${screen.id}" lists itself as its parent.`, `screens[id=${screen.id}].parent`);
125
+ continue;
126
+ }
127
+ if (!seen.has(screen.parent)) {
128
+ err("parent_unknown", `Screen "${screen.id}" has unknown parent "${screen.parent}".`, `screens[id=${screen.id}].parent`);
129
+ continue;
130
+ }
131
+ parentOf.set(screen.id, screen.parent);
132
+ }
133
+ // Cycle detection over the parent chains.
134
+ for (const start of parentOf.keys()) {
135
+ const visited = new Set<string>([start]);
136
+ let cur = parentOf.get(start);
137
+ while (cur !== undefined) {
138
+ if (visited.has(cur)) {
139
+ err("parent_cycle", `Parent chain from "${start}" forms a cycle at "${cur}".`, `screens[id=${start}].parent`);
140
+ break;
141
+ }
142
+ visited.add(cur);
143
+ cur = parentOf.get(cur);
144
+ }
145
+ }
146
+
147
+ // The gate fails only on ERROR-severity issues; warnings render anyway.
148
+ const ok = !issues.some((i) => i.severity === "error");
149
+ return { ok, map, issues };
150
+ }
151
+
152
+ /**
153
+ * Like {@link validateProjectMap} but throws on failure. Useful in scripts
154
+ * and tests where an invalid map should hard-stop.
155
+ */
156
+ export function parseProjectMap(input: unknown): ProjectMap {
157
+ const result = validateProjectMap(input);
158
+ if (!result.ok || !result.map) {
159
+ const detail = result.issues
160
+ .map((i) => ` - [${i.code}] ${i.message}${i.path ? ` (${i.path})` : ""}`)
161
+ .join("\n");
162
+ throw new Error(`Invalid ProjectMap:\n${detail}`);
163
+ }
164
+ return result.map;
165
+ }