openink 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 (52) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/bin/openink.js +4 -0
  5. package/docs/ai-assistants.md +35 -0
  6. package/docs/architecture.md +50 -0
  7. package/docs/blocks.md +604 -0
  8. package/docs/extending.md +86 -0
  9. package/docs/getting-started.md +97 -0
  10. package/docs/spec.md +215 -0
  11. package/package.json +38 -0
  12. package/schema/spec.schema.json +2641 -0
  13. package/src/build.js +82 -0
  14. package/src/cli.js +133 -0
  15. package/src/dev.js +53 -0
  16. package/src/export.js +68 -0
  17. package/src/icons.js +47 -0
  18. package/src/index.js +14 -0
  19. package/src/render/blocks/actions.js +68 -0
  20. package/src/render/blocks/content.js +86 -0
  21. package/src/render/blocks/data.js +24 -0
  22. package/src/render/blocks/feedback.js +29 -0
  23. package/src/render/blocks/forms.js +86 -0
  24. package/src/render/blocks/index.js +42 -0
  25. package/src/render/blocks/layout.js +102 -0
  26. package/src/render/blocks/media.js +65 -0
  27. package/src/render/blocks/navigation.js +67 -0
  28. package/src/render/blocks/overlay.js +18 -0
  29. package/src/render/blocks/shared.js +20 -0
  30. package/src/render/blocks/text.js +49 -0
  31. package/src/render/context.js +63 -0
  32. package/src/render/page.js +76 -0
  33. package/src/runtime/chart.js +85 -0
  34. package/src/runtime/dom.js +21 -0
  35. package/src/runtime/i18n.js +24 -0
  36. package/src/runtime/icon.js +28 -0
  37. package/src/runtime/index.js +64 -0
  38. package/src/runtime/navigation.js +27 -0
  39. package/src/runtime/placeholder.js +88 -0
  40. package/src/spec/docs.js +65 -0
  41. package/src/spec/schema.js +110 -0
  42. package/src/spec/validate.js +219 -0
  43. package/src/styles/openink.css +250 -0
  44. package/src/styles/themes/blueprint.css +31 -0
  45. package/src/styles/themes/color.css +28 -0
  46. package/src/styles/themes/dark.css +31 -0
  47. package/src/styles/themes/pastel.css +26 -0
  48. package/src/themes.js +10 -0
  49. package/templates/starter/AGENTS.md +18 -0
  50. package/templates/starter/README.md +11 -0
  51. package/templates/starter/gitignore +3 -0
  52. package/templates/starter/spec.yaml +55 -0
@@ -0,0 +1,219 @@
1
+ import { getBlock, blockNames } from "../render/blocks/index.js";
2
+ import { COMMON, TONES } from "../render/blocks/shared.js";
3
+ import { ICON_NAMES } from "../icons.js";
4
+ import { THEME_PRESETS, COLOR_KEYS } from "../themes.js";
5
+
6
+ const ID = /^[A-Za-z][\w-]*$/;
7
+ const WIDTHS = ["narrow", "medium", "wide"];
8
+ const isObj = (v) => v && typeof v === "object" && !Array.isArray(v);
9
+
10
+ /** Levenshtein distance, for "did you mean" hints. */
11
+ function distance(a, b) {
12
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
13
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
14
+ for (let i = 1; i <= a.length; i++)
15
+ for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
16
+ return d[a.length][b.length];
17
+ }
18
+ const suggest = (word, options) => {
19
+ const best = options.map((o) => [o, distance(word, o)]).sort((x, y) => x[1] - y[1])[0];
20
+ return best && best[1] <= Math.max(2, Math.floor(word.length / 3)) ? ` Did you mean "${best[0]}"?` : "";
21
+ };
22
+
23
+ /**
24
+ * Check a parsed spec. Never throws.
25
+ * @returns {{ errors: {path:string,message:string}[], warnings: {path:string,message:string}[] }}
26
+ */
27
+ export function validate(spec) {
28
+ const errors = [];
29
+ const warnings = [];
30
+ const err = (path, message) => errors.push({ path, message });
31
+ const warn = (path, message) => warnings.push({ path, message });
32
+
33
+ if (!isObj(spec)) return { errors: [{ path: "", message: "Spec must be a YAML/JSON object." }], warnings };
34
+
35
+ if (typeof spec.name !== "string" || !spec.name.trim()) err("name", "`name` is required (a non-empty string).");
36
+ const languages = spec.languages ?? [];
37
+ if (!Array.isArray(languages) || languages.some((l) => typeof l !== "string" || !/^[a-z]{2,3}$/i.test(l))) {
38
+ err("languages", "`languages` must be a list of 2-3 letter codes, e.g. [en, de].");
39
+ }
40
+ const langs = Array.isArray(languages) ? languages.filter((l) => typeof l === "string") : [];
41
+
42
+ const topKnown = ["name", "languages", "footer", "theme", "colors", "nav", "modals", "screens"];
43
+ // `x-*` keys are free-form: a place to define YAML anchors (&name) that screens reuse (*name)
44
+ for (const k of Object.keys(spec)) if (!topKnown.includes(k) && !k.startsWith("x-")) warn(k, `Unknown top-level field "${k}".${suggest(k, topKnown)}`);
45
+
46
+ // ---- theme + colors
47
+ if (spec.theme !== undefined) {
48
+ if (typeof spec.theme !== "string") err("theme", "`theme` must be a preset name or the path of a .css file.");
49
+ else if (!THEME_PRESETS.includes(spec.theme) && !spec.theme.endsWith(".css")) {
50
+ err("theme", `Unknown theme "${spec.theme}". Use a preset (${THEME_PRESETS.join(", ")}) or a path ending in .css.${suggest(spec.theme, THEME_PRESETS)}`);
51
+ }
52
+ }
53
+ if (spec.colors !== undefined) {
54
+ if (!isObj(spec.colors)) err("colors", "`colors` must be an object such as { accent: \"#e11d48\" }.");
55
+ else for (const [k, v] of Object.entries(spec.colors)) {
56
+ if (!COLOR_KEYS.includes(k)) err(`colors.${k}`, `Unknown colour "${k}". Available: ${COLOR_KEYS.join(", ")}.${suggest(k, COLOR_KEYS)}`);
57
+ else if (typeof v !== "string" || /[;{}<>]/.test(v)) err(`colors.${k}`, `\`${k}\` must be a CSS colour such as "#e11d48" or "tomato".`);
58
+ }
59
+ }
60
+
61
+ // ---- screens
62
+ const screens = Array.isArray(spec.screens) ? spec.screens : [];
63
+ if (!screens.length) {
64
+ err("screens", "`screens` must be a non-empty list.");
65
+ return { errors, warnings };
66
+ }
67
+ const ids = new Set();
68
+ screens.forEach((s, i) => {
69
+ const p = `screens[${i}]`;
70
+ if (!isObj(s)) return err(p, "A screen must be an object with `id` and `blocks`.");
71
+ if (typeof s.id !== "string" || !ID.test(s.id)) err(`${p}.id`, `Screen \`id\` must start with a letter and use letters, digits, "_" or "-" (got ${JSON.stringify(s.id)}).`);
72
+ else if (ids.has(s.id)) err(`${p}.id`, `Duplicate screen id "${s.id}".`);
73
+ else ids.add(s.id);
74
+ const screenKeys = ["id", "title", "nav", "note", "width", "tone", "blocks"];
75
+ for (const k of Object.keys(s)) if (!screenKeys.includes(k)) warn(`${p}.${k}`, `Unknown screen field "${k}".${suggest(k, screenKeys)}`);
76
+ if (!Array.isArray(s.blocks)) err(`${p}.blocks`, "`blocks` must be a list.");
77
+ if (s.tone !== undefined && !TONES.includes(s.tone)) err(`${p}.tone`, `\`tone\` must be one of: ${TONES.join(", ")} (got ${JSON.stringify(s.tone)}).`);
78
+ if (s.width !== undefined && !WIDTHS.includes(s.width)) err(`${p}.width`, `\`width\` must be one of: ${WIDTHS.join(", ")} (got ${JSON.stringify(s.width)}).`);
79
+ if (s.title !== undefined) checkText(s.title, `${p}.title`);
80
+ if (s.note !== undefined) checkText(s.note, `${p}.note`);
81
+ });
82
+
83
+ // ---- text values
84
+ function checkText(v, path) {
85
+ if (typeof v === "string" || typeof v === "number") return true;
86
+ if (isObj(v)) {
87
+ if (!langs.length) warn(path, "Translation object used but the spec has no `languages`; only the first value is shown.");
88
+ else {
89
+ for (const k of Object.keys(v)) if (!langs.includes(k)) warn(path, `Language "${k}" is not listed in \`languages\`.`);
90
+ const missing = langs.filter((l) => v[l] == null);
91
+ if (missing.length) warn(path, `Missing translation: ${missing.join(", ")} (falls back to ${langs[0]}).`);
92
+ }
93
+ return Object.values(v).every((x) => typeof x === "string" || typeof x === "number") || (err(path, "Translation values must be strings."), false);
94
+ }
95
+ err(path, `Expected text (string, number or { lang: text }), got ${Array.isArray(v) ? "a list" : typeof v}.`);
96
+ return false;
97
+ }
98
+
99
+ const links = []; // [path, screenId]
100
+ const modalRefs = []; // [path, modalId] from `open:`
101
+ const modalIds = new Set();
102
+ const checkProp = (name, def, v, path) => {
103
+ switch (def.type) {
104
+ case "text": checkText(v, path); break;
105
+ case "string":
106
+ if (typeof v !== "string") err(path, `\`${name}\` must be a string.`);
107
+ else if (def.enum && !def.enum.includes(v)) {
108
+ err(path, def.enum.length > 12 ? `Unknown ${name} "${v}".${suggest(v, def.enum)} See docs/blocks.md for the list.` : `\`${name}\` must be one of: ${def.enum.join(", ")} (got "${v}").`);
109
+ }
110
+ break;
111
+ case "number": if (typeof v !== "number") err(path, `\`${name}\` must be a number.`); break;
112
+ case "boolean": if (typeof v !== "boolean") err(path, `\`${name}\` must be true or false.`); break;
113
+ case "text[]":
114
+ if (!Array.isArray(v)) err(path, `\`${name}\` must be a list.`);
115
+ else v.forEach((x, i) => checkText(x, `${path}[${i}]`));
116
+ break;
117
+ case "number[]":
118
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "number")) err(path, `\`${name}\` must be a list of numbers.`);
119
+ break;
120
+ case "sections":
121
+ if (!Array.isArray(v)) err(path, `\`${name}\` must be a list of { label, children }.`);
122
+ else v.forEach((t, i) => {
123
+ if (!isObj(t) || t.label == null) err(`${path}[${i}]`, "Each entry needs a `label`.");
124
+ else { checkText(t.label, `${path}[${i}].label`); if (t.children !== undefined && !Array.isArray(t.children)) err(`${path}[${i}].children`, "`children` must be a list."); }
125
+ });
126
+ break;
127
+ case "links":
128
+ if (!Array.isArray(v)) err(path, `\`${name}\` must be a list of { icon, label, go }.`);
129
+ else v.forEach((n, i) => {
130
+ if (!isObj(n) || (n.label == null && n.icon == null)) return err(`${path}[${i}]`, "Each item needs an `icon` or a `label`.");
131
+ if (n.label != null) checkText(n.label, `${path}[${i}].label`);
132
+ if (n.icon != null && !ICON_NAMES.includes(n.icon)) err(`${path}[${i}].icon`, `Unknown icon "${n.icon}".${suggest(String(n.icon), ICON_NAMES)}`);
133
+ if (n.go) links.push([`${path}[${i}].go`, n.go]);
134
+ if (n.open) modalRefs.push([`${path}[${i}].open`, n.open]);
135
+ });
136
+ break;
137
+ case "rows":
138
+ if (!Array.isArray(v)) err(path, "`rows` must be a list.");
139
+ else v.forEach((r, i) => {
140
+ if (!Array.isArray(r) && !(isObj(r) && Array.isArray(r.cells))) err(`${path}[${i}]`, "A row must be a list of cells, or { cells: [...], go: screenId }.");
141
+ });
142
+ break;
143
+ }
144
+ };
145
+
146
+ function visit(b, path) {
147
+ if (typeof b === "string") return;
148
+ if (!isObj(b)) return err(path, "A block must be an object with a `type` (or a plain string).");
149
+ if (typeof b.type !== "string") return err(path, `Block is missing \`type\`. Known types: ${blockNames().join(", ")}.`);
150
+ const def = getBlock(b.type);
151
+ if (!def) return err(`${path}.type`, `Unknown block type "${b.type}".${suggest(b.type, blockNames())}`);
152
+
153
+ const known = new Set(["type", ...Object.keys(def.props), ...Object.keys(COMMON), ...(def.children ? ["children"] : [])]);
154
+ for (const k of Object.keys(b)) {
155
+ if (known.has(k)) continue;
156
+ // "Search a, b" as a key means an unquoted comma inside { } ended the previous value early
157
+ if (/[^\w-]/.test(k)) warn(`${path}.${k}`, `Unexpected "${k}". A comma inside { ... } ends the value, so wrap text that contains commas in quotes.`);
158
+ else warn(`${path}.${k}`, `"${b.type}" has no property "${k}".${suggest(k, [...known])}`);
159
+ }
160
+ for (const [name, pd] of Object.entries({ ...def.props, ...COMMON })) {
161
+ if (b[name] === undefined) { if (pd.required) err(`${path}.${name}`, `"${b.type}" requires \`${name}\`.`); continue; }
162
+ checkProp(name, pd, b[name], `${path}.${name}`);
163
+ }
164
+ if (b.go !== undefined && typeof b.go === "string") links.push([`${path}.go`, b.go]);
165
+ if (typeof b.open === "string") modalRefs.push([`${path}.open`, b.open]);
166
+ if (b.type === "modal" && typeof b.id === "string") {
167
+ if (modalIds.has(b.id)) err(`${path}.id`, `Duplicate modal id "${b.id}".`);
168
+ modalIds.add(b.id);
169
+ }
170
+ for (const t of def.targets?.(b) ?? []) links.push([path, t]);
171
+
172
+ if (b.children !== undefined) {
173
+ if (!def.children) err(`${path}.children`, `"${b.type}" cannot have children.`);
174
+ else if (!Array.isArray(b.children)) err(`${path}.children`, "`children` must be a list.");
175
+ else b.children.forEach((x, i) => visit(x, `${path}.children[${i}]`));
176
+ }
177
+ if (def.nested) for (const [suffix, x] of def.nested(b)) visit(x, path + suffix);
178
+ }
179
+ screens.forEach((s, i) => Array.isArray(s?.blocks) && s.blocks.forEach((b, j) => visit(b, `screens[${i}].blocks[${j}]`)));
180
+
181
+ // global modals: same as a `modal` block, listed once and openable from any screen
182
+ if (spec.modals !== undefined) {
183
+ if (!Array.isArray(spec.modals)) err("modals", "`modals` must be a list of modal definitions ({ id, title, children }).");
184
+ else spec.modals.forEach((m, i) => (isObj(m) ? visit({ ...m, type: "modal" }, `modals[${i}]`) : err(`modals[${i}]`, "A modal must be an object with an `id`.")));
185
+ }
186
+
187
+ // ---- nav
188
+ const navSets = Array.isArray(spec.nav) ? { default: spec.nav } : isObj(spec.nav) ? spec.nav : {};
189
+ if (spec.nav !== undefined && !Array.isArray(spec.nav) && !isObj(spec.nav)) err("nav", "`nav` must be a list, or an object of named lists.");
190
+ for (const [key, items] of Object.entries(navSets)) {
191
+ if (!Array.isArray(items)) { err(`nav.${key}`, "A nav set must be a list of { label, go }."); continue; }
192
+ items.forEach((n, i) => {
193
+ const p = Array.isArray(spec.nav) ? `nav[${i}]` : `nav.${key}[${i}]`;
194
+ if (!isObj(n) || (n.label == null && n.icon == null)) return err(p, "A nav item needs a `label` or an `icon`.");
195
+ if (n.label != null) checkText(n.label, `${p}.label`);
196
+ if (n.icon != null && !ICON_NAMES.includes(n.icon)) err(`${p}.icon`, `Unknown icon "${n.icon}".${suggest(String(n.icon), ICON_NAMES)}`);
197
+ if (n.go) links.push([`${p}.go`, n.go]);
198
+ if (n.open) modalRefs.push([`${p}.open`, n.open]);
199
+ });
200
+ }
201
+ screens.forEach((s, i) => {
202
+ if (isObj(s) && s.nav && !navSets[s.nav]) err(`screens[${i}].nav`, `Screen uses nav set "${s.nav}" which is not defined under \`nav\`.${suggest(s.nav, Object.keys(navSets))}`);
203
+ });
204
+
205
+ // ---- links
206
+ for (const [path, target] of links) {
207
+ if (!ids.has(target)) err(path, `Links to unknown screen "${target}".${suggest(target, [...ids])}`);
208
+ }
209
+
210
+ for (const [path, id] of modalRefs) {
211
+ if (!modalIds.has(id)) err(path, `Opens unknown modal "${id}".${suggest(String(id), [...modalIds])} Define it with { type: modal, id: ${id} }.`);
212
+ }
213
+
214
+ // ---- reachability (warning only)
215
+ const linked = new Set([screens[0].id, ...links.map(([, target]) => target)]);
216
+ for (const id of ids) if (!linked.has(id)) warn("screens", `Screen "${id}" is not linked from anywhere (no \`go: ${id}\`).`);
217
+
218
+ return { errors, warnings };
219
+ }
@@ -0,0 +1,250 @@
1
+ @import url("https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Patrick+Hand&display=swap");
2
+
3
+ /* ------------------------------------------------------------------ design tokens
4
+ Override any of these with `colors:` in the spec, a theme preset, or your own CSS file. */
5
+ :root {
6
+ --ink: #2b2b2b; /* text and lines */
7
+ --paper: #fdfcf8; /* page background */
8
+ --muted: #8a8a85; /* secondary text, outlines of placeholders */
9
+ --line: #cfcfc8; /* dashed dividers */
10
+ --accent: #c2410c; /* pins, sliders, active states */
11
+ --note: #fff3b0; /* sticky notes */
12
+ --card-bg: #ffffff; /* cards and dialogs */
13
+ --ok: #15803d; /* "on" badges, success */
14
+ --gap: 1rem;
15
+
16
+ /* used by hand-drawn shapes (placeholders, charts) */
17
+ --oi-stroke: var(--muted);
18
+ --oi-fill: #f3f1ea;
19
+ --oi-line: var(--line);
20
+ --oi-chart: var(--accent);
21
+ --tone-bg: #f1efe8; /* background used by `fill: true` without a tone */
22
+
23
+ --wired-item-selected-bg: #eee;
24
+ --wired-toggle-on-color: var(--ink);
25
+ --wired-toggle-off-color: var(--muted);
26
+ --wired-slider-knob-color: var(--accent);
27
+ --wired-slider-bar-color: var(--ink);
28
+ --wired-radio-icon-color: var(--ink);
29
+ --wired-checkbox-icon-color: var(--ink);
30
+ --wired-progress-color: var(--accent);
31
+ --wired-progress-label-color: var(--ink);
32
+ --wired-progress-label-background: var(--card-bg);
33
+
34
+ /* tone palette: strong colour + light tint */
35
+ --blue: #1d4ed8; --blue-bg: #dbeafe;
36
+ --green: #15803d; --green-bg: #dcfce7;
37
+ --yellow: #a16207; --yellow-bg: #fef3c7;
38
+ --red: #b91c1c; --red-bg: #fee2e2;
39
+ --purple: #7e22ce; --purple-bg: #f3e8ff;
40
+ --pink: #be185d; --pink-bg: #fce7f3;
41
+ --orange: #c2410c; --orange-bg: #ffedd5;
42
+ --teal: #0f766e; --teal-bg: #ccfbf1;
43
+ --gray: #475569; --gray-bg: #e2e8f0;
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ tones
47
+ `tone: blue` on a block (or screen) recolours it and everything inside it. */
48
+ [data-tone="blue"] { --tone: var(--blue); --tone-bg: var(--blue-bg); }
49
+ [data-tone="green"] { --tone: var(--green); --tone-bg: var(--green-bg); }
50
+ [data-tone="yellow"] { --tone: var(--yellow); --tone-bg: var(--yellow-bg); }
51
+ [data-tone="red"] { --tone: var(--red); --tone-bg: var(--red-bg); }
52
+ [data-tone="purple"] { --tone: var(--purple); --tone-bg: var(--purple-bg); }
53
+ [data-tone="pink"] { --tone: var(--pink); --tone-bg: var(--pink-bg); }
54
+ [data-tone="orange"] { --tone: var(--orange); --tone-bg: var(--orange-bg); }
55
+ [data-tone="teal"] { --tone: var(--teal); --tone-bg: var(--teal-bg); }
56
+ [data-tone="gray"] { --tone: var(--gray); --tone-bg: var(--gray-bg); }
57
+ [data-tone] {
58
+ color: var(--tone);
59
+ --ink: var(--tone);
60
+ --muted: color-mix(in srgb, var(--tone) 65%, white);
61
+ --line: color-mix(in srgb, var(--tone) 35%, white);
62
+ --accent: var(--tone);
63
+ --ok: var(--tone);
64
+ --oi-stroke: var(--tone);
65
+ --oi-fill: var(--tone-bg);
66
+ --oi-line: color-mix(in srgb, var(--tone) 30%, white);
67
+ --oi-chart: var(--tone);
68
+ --wired-toggle-on-color: var(--tone);
69
+ --wired-slider-knob-color: var(--tone);
70
+ --wired-slider-bar-color: var(--tone);
71
+ --wired-radio-icon-color: var(--tone);
72
+ --wired-checkbox-icon-color: var(--tone);
73
+ --wired-progress-color: var(--tone);
74
+ --wired-progress-label-background: color-mix(in srgb, var(--tone-bg) 55%, white);
75
+ }
76
+ [data-tone] .card, [data-tone].card { background: color-mix(in srgb, var(--tone-bg) 55%, white); }
77
+ [data-tone] wired-button { background: color-mix(in srgb, var(--tone-bg) 45%, white); }
78
+ [data-tone] wired-button[elevation="3"] { background: var(--tone); color: #fff; }
79
+ .screen[data-tone] { background: color-mix(in srgb, var(--tone-bg) 40%, var(--paper)); padding: 1rem; border-radius: 12px; }
80
+ /* the wrapper the renderer puts around any block with `tone` / `fill` */
81
+ .tone { display: contents; }
82
+ .tone[data-fill] { display: block; background: var(--tone-bg); padding: .75rem .9rem; border-radius: 6px 14px 8px 12px; }
83
+
84
+ /* ------------------------------------------------------------------ base */
85
+ * { box-sizing: border-box; }
86
+ body { margin: 0; font-family: "Patrick Hand", "Caveat", cursive; font-size: 1.1rem; color: var(--ink); background: var(--paper); }
87
+ h1, h2, h3 { font-family: "Caveat", cursive; margin: 0 0 .4rem; line-height: 1.1; }
88
+ h1 { font-size: 2.4rem; } h2 { font-size: 1.9rem; } h3 { font-size: 1.4rem; }
89
+ p { margin: 0; }
90
+ .bold { font-weight: 700; }
91
+ .lead { font-size: 1.15rem; }
92
+ .muted { color: var(--muted); font-size: .95rem; }
93
+ .center { text-align: center; }
94
+ [data-go], [data-open], .click { cursor: pointer; }
95
+
96
+ .shell { max-width: 1000px; margin: 0 auto; padding: var(--gap); }
97
+
98
+ /* layout blocks */
99
+ .stack { display: grid; gap: var(--gap); align-content: start; }
100
+ .stack.tight { gap: .2rem; }
101
+ .row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
102
+ .row.between { justify-content: space-between; }
103
+ .row.center { justify-content: center; }
104
+ .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: var(--gap); align-items: start; }
105
+ @media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
106
+ .card { display: block; padding: 1rem; background: var(--card-bg); }
107
+ .card.dash { background: transparent; }
108
+ .center-items { justify-items: center; text-align: center; }
109
+ .center-items > * { justify-self: center; }
110
+ .field { display: grid; gap: .2rem; }
111
+ .fl { font-size: .9rem; color: var(--muted); }
112
+ wired-input, wired-textarea, wired-combo, wired-search-input, wired-progress { width: 100%; }
113
+ wired-progress { display: block; }
114
+ wired-textarea { display: block; min-height: 6rem; }
115
+ wired-combo { display: block; border: 2px solid var(--muted); border-radius: 255px 8px 225px 8px/8px 225px 8px 255px; padding: .1rem .3rem; }
116
+ wired-button oi-icon + .btn-label { margin-left: .4rem; }
117
+ /* a button is as wide as its label, not as wide as the column (matters once themes give buttons a background) */
118
+ .stack > wired-button, .stack > .tone > wired-button { justify-self: start; }
119
+ .oi-list { margin: 0; padding-left: 1.2rem; display: grid; gap: .25rem; }
120
+ .oi-link { color: var(--accent); text-decoration: underline wavy; text-underline-offset: 4px; }
121
+
122
+ /* header */
123
+ .oi-bar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; padding: .5rem 0 1rem; border-bottom: 2px dashed var(--line); margin-bottom: var(--gap); }
124
+ .oi-bar .brand { font-family: "Caveat", cursive; font-size: 1.9rem; font-weight: 700; margin-right: auto; }
125
+ .oi-bar nav { display: flex; gap: .4rem; flex-wrap: wrap; }
126
+ .oi-bar nav[hidden] { display: none; }
127
+ .lang { display: flex; gap: .2rem; }
128
+ [data-lang].on { --wired-item-selected-bg: #eee; font-weight: 700; }
129
+
130
+ /* screens */
131
+ .screen { display: none; }
132
+ .screen.on { display: block; }
133
+ .screen > * + * { margin-top: var(--gap); }
134
+ .screen-title { display: none; }
135
+ .screen[data-width="narrow"] { max-width: 520px; margin-inline: auto; }
136
+ .screen[data-width="medium"] { max-width: 760px; margin-inline: auto; }
137
+ main { min-height: 40vh; }
138
+ footer { margin-top: 2.5rem; }
139
+
140
+ /* sticky-note annotations */
141
+ .note { background: var(--note); color: #2b2b2b; padding: .55rem .8rem; transform: rotate(-.4deg); box-shadow: 2px 3px 0 rgba(0,0,0,.08); font-size: .98rem; max-width: 60ch; }
142
+
143
+ /* next bar */
144
+ .nextbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; border-top: 2px dashed var(--line); padding-top: var(--gap); margin-top: 1.5rem; }
145
+
146
+ /* table */
147
+ .oi-table { width: 100%; border-collapse: collapse; }
148
+ .oi-table th, .oi-table td { text-align: left; padding: .45rem .5rem; border-bottom: 1px dashed var(--line); }
149
+ .oi-table th { font-family: "Caveat", cursive; font-size: 1.25rem; }
150
+ .oi-table tr.click:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
151
+ /* a column that only holds an avatar shrinks to the avatar */
152
+ .oi-table td:has(> oi-placeholder[round]) { width: 1%; white-space: nowrap; }
153
+
154
+ /* badge */
155
+ .badge { display: inline-block; padding: .05rem .55rem; border: 2px solid var(--muted); border-radius: 255px 15px 225px 15px/15px 225px 15px 255px; font-size: .9rem; }
156
+ .badge.on { border-color: var(--ok); color: var(--ok); }
157
+ .badge.wait { border-color: var(--accent); color: var(--accent); }
158
+ .badge.off { border-color: var(--muted); color: var(--muted); }
159
+ .stack > .badge { justify-self: start; }
160
+
161
+ /* placeholder / chart labels */
162
+ .ph-label { position: absolute; inset: 0; margin: auto; width: max-content; height: max-content; padding: 0 .5rem; color: var(--muted); background: color-mix(in srgb, var(--oi-fill) 60%, var(--paper)); }
163
+ oi-placeholder[pin] .ph-label, oi-placeholder[play] .ph-label { inset: auto 0 10px 0; }
164
+ oi-placeholder[dots] .ph-label { inset: 12px 0 auto 0; }
165
+ oi-placeholder[upload] .ph-label { inset: auto 0 22px 0; }
166
+ .ph-corner { inset: 4px auto auto 40px; margin: 0; background: transparent; }
167
+
168
+ /* tabs */
169
+ .tab-panel { display: none; margin-top: var(--gap); }
170
+ .tab-panel.on { display: block; }
171
+
172
+ /* ------------------------------------------------------------------ content blocks */
173
+ .avatar { display: flex; align-items: center; gap: .6rem; }
174
+ .avatar-text { display: grid; line-height: 1.15; }
175
+ .hero { text-align: center; padding: 2.5rem 1rem; display: grid; gap: .8rem; justify-items: center; }
176
+ .hero.left { text-align: left; justify-items: start; padding-inline: 0; }
177
+ .hero h1 { font-size: 3.2rem; margin: 0; }
178
+ .hero .lead { max-width: 48ch; color: var(--muted); font-size: 1.25rem; }
179
+ .hero-body { justify-items: inherit; width: 100%; }
180
+ .stat-value { font-family: "Caveat", cursive; font-size: 2.6rem; line-height: 1; }
181
+ .rating { gap: .1rem; }
182
+ .rating oi-icon { color: var(--accent); }
183
+ .rating .muted { margin-left: .4rem; }
184
+
185
+ /* feedback */
186
+ .alert { --k: #1d4ed8; display: flex; gap: .7rem; align-items: flex-start; padding: .7rem .9rem; border: 2px solid var(--k);
187
+ border-radius: 255px 15px 225px 15px/15px 225px 15px 255px; background: color-mix(in srgb, var(--k) 10%, var(--paper)); }
188
+ .alert oi-icon { color: var(--k); flex: none; }
189
+ .alert-success { --k: #15803d; } .alert-warning { --k: #b45309; } .alert-error { --k: #b91c1c; }
190
+
191
+ /* navigation */
192
+ .crumbs { display: flex; gap: .4rem; flex-wrap: wrap; color: var(--muted); }
193
+ .crumbs .current { color: var(--ink); font-weight: 700; }
194
+ .crumbs .sep { opacity: .6; }
195
+ .steps { list-style: none; display: flex; padding: 0; margin: 0; }
196
+ .steps li { flex: 1; display: flex; align-items: center; gap: .5rem; color: var(--muted); }
197
+ .steps li:not(:last-child)::after { content: ""; flex: 1; border-top: 2px dashed var(--line); margin: 0 .6rem; }
198
+ .steps li:last-child { flex: none; }
199
+ .steps .dot { width: 28px; height: 28px; border: 2px solid currentColor; border-radius: 50%; display: grid; place-items: center; font-size: .85rem; flex: none; }
200
+ .steps .on { color: var(--ink); font-weight: 700; }
201
+ .steps .on .dot { background: var(--accent); border-color: var(--accent); color: #fff; }
202
+ .steps .done { color: var(--ink); }
203
+ .steps .done .dot { background: var(--ink); color: var(--paper); }
204
+ .tabbar { display: flex; justify-content: space-around; gap: .25rem; border-top: 2px dashed var(--line); padding-top: .6rem; }
205
+ .tabbar wired-button { display: inline-flex; }
206
+ .tab-label { display: block; font-size: .72rem; line-height: 1; margin-top: .15rem; }
207
+
208
+ /* accordion */
209
+ .accordion details { border: 2px dashed var(--line); border-radius: 10px; padding: .1rem .9rem; margin-bottom: .5rem; }
210
+ .accordion summary { cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: .5rem 0; font-family: "Caveat", cursive; font-size: 1.4rem; list-style: none; }
211
+ .accordion summary::-webkit-details-marker { display: none; }
212
+ .accordion details[open] summary oi-icon { transform: rotate(180deg); }
213
+ .accordion details > .stack { padding: .3rem 0 .9rem; }
214
+
215
+ /* device frames */
216
+ .device { margin-inline: auto; width: 100%; }
217
+ .device-phone { max-width: 390px; }
218
+ .device-tablet { max-width: 720px; }
219
+ .device-frame { display: block; background: var(--card-bg); padding: 0; }
220
+ .device-body { padding: .9rem; }
221
+ .device-phone .device-body { min-height: 520px; }
222
+ .device-tablet .device-body { min-height: 420px; }
223
+ .device-notch { width: 90px; height: 8px; border-radius: 8px; background: var(--line); margin: 12px auto 0; }
224
+ .device-bar { display: flex; align-items: center; gap: 6px; padding: .55rem .9rem; border-bottom: 2px dashed var(--line); }
225
+ .device-bar i { width: 10px; height: 10px; border: 2px solid var(--muted); border-radius: 50%; display: block; }
226
+ .device-bar span { margin-left: .6rem; flex: 1; border: 2px solid var(--line); border-radius: 12px; padding: 0 .8rem; font-size: .9rem; color: var(--muted); }
227
+
228
+ /* modal */
229
+ .modal { position: fixed; inset: 0; display: none; place-items: center; z-index: 50; padding: 1rem; margin: 0 !important; }
230
+ .modal.on { display: grid; }
231
+ .modal-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, .4); }
232
+ .modal-card { position: relative; display: block; background: var(--card-bg); width: min(100%, 560px); max-height: 88vh; overflow: auto; padding: 1rem; }
233
+ .modal[data-width="narrow"] .modal-card { width: min(100%, 380px); }
234
+ .modal[data-width="wide"] .modal-card { width: min(100%, 820px); }
235
+
236
+ /* language switching rules ([data-l]) are generated per spec into the page <style> */
237
+
238
+ .toast { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--paper); padding: .5rem 1rem; border-radius: 6px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 100; }
239
+ .toast.show { opacity: 1; }
240
+
241
+ /* print / PDF: every screen on its own page */
242
+ @page { size: A4 landscape; margin: 10mm; }
243
+ @media print {
244
+ body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
245
+ .oi-bar nav, .lang, .print-btn, .toast, .nextbar, .modal { display: none !important; }
246
+ .screen, .screen.on { display: block !important; break-after: page; }
247
+ .screen:last-of-type { break-after: auto; }
248
+ .screen-title { display: block; font-family: "Caveat", cursive; font-size: 1.3rem; color: var(--muted); border-bottom: 2px dashed var(--line); }
249
+ .tab-panel { display: block; }
250
+ }
@@ -0,0 +1,31 @@
1
+ /* blueprint: white lines on blue paper. Looks like an architect's drawing. */
2
+ :root {
3
+ --ink: #f1f6ff;
4
+ --paper: #0f3b8c;
5
+ --muted: #a9c4f5;
6
+ --line: #4b78d6;
7
+ --accent: #ffd166;
8
+ --note: #ffe08a;
9
+ --card-bg: rgba(255, 255, 255, 0.07);
10
+ --ok: #7ee2a8;
11
+ --oi-stroke: #cfe0ff;
12
+ --oi-fill: rgba(255, 255, 255, 0.08);
13
+ --oi-line: #6c93e0;
14
+ --oi-chart: #ffd166;
15
+ --wired-toggle-on-color: #ffd166;
16
+ --wired-slider-knob-color: #ffd166;
17
+ --wired-slider-bar-color: #f1f6ff;
18
+ --wired-progress-color: #ffd166;
19
+ --wired-progress-label-color: #f1f6ff;
20
+ --wired-progress-label-background: transparent;
21
+ --wired-radio-icon-color: #f1f6ff;
22
+ --wired-checkbox-icon-color: #f1f6ff;
23
+ --wired-item-selected-bg: #1f56b8;
24
+ --wired-item-selected-color: #fff;
25
+ --tone-bg: rgba(255, 255, 255, 0.12);
26
+ }
27
+ body { background-image: linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px); background-size: 28px 28px; }
28
+ wired-input, wired-textarea, wired-search-input { color: var(--ink); }
29
+ wired-combo { border-color: var(--muted); }
30
+ .toast { background: #f1f6ff; color: #0f3b8c; }
31
+ .alert { background: rgba(255,255,255,.08); }
@@ -0,0 +1,28 @@
1
+ /* color: a confident, colourful look. Indigo and rose on warm white. */
2
+ :root {
3
+ --ink: #1f2340;
4
+ --paper: #fffdf8;
5
+ --muted: #6b7099;
6
+ --line: #d9dcf0;
7
+ --accent: #e11d48;
8
+ --note: #fde68a;
9
+ --card-bg: #ffffff;
10
+ --ok: #059669;
11
+ --oi-fill: #eef0ff;
12
+ --oi-chart: #4f46e5;
13
+ --wired-toggle-on-color: #4f46e5;
14
+ --wired-slider-knob-color: #e11d48;
15
+ --wired-slider-bar-color: #4f46e5;
16
+ --wired-progress-color: #4f46e5;
17
+ --wired-radio-icon-color: #4f46e5;
18
+ --wired-checkbox-icon-color: #4f46e5;
19
+ --tone-bg: #eef0ff;
20
+ }
21
+ h1, h2 { color: #312e81; }
22
+ .oi-bar .brand { color: #4f46e5; }
23
+ wired-button { background: #eef0ff; color: #312e81; }
24
+ wired-button[elevation="3"] { background: #4f46e5; color: #fff; }
25
+ .badge.wait { background: #ffe4e6; }
26
+ .badge.on { background: #d1fae5; }
27
+ .card { border-radius: 8px; }
28
+ .steps .on .dot { background: #4f46e5; border-color: #4f46e5; }
@@ -0,0 +1,31 @@
1
+ /* dark: for dashboards, dev tools, media apps. */
2
+ :root {
3
+ --ink: #e8eaf0;
4
+ --paper: #14161c;
5
+ --muted: #8b90a0;
6
+ --line: #2f3340;
7
+ --accent: #ff7a59;
8
+ --note: #4a4320;
9
+ --card-bg: #1c1f28;
10
+ --ok: #4ade80;
11
+ --oi-stroke: #6b7080;
12
+ --oi-fill: #1f232d;
13
+ --oi-line: #3a3f4e;
14
+ --oi-chart: #ff7a59;
15
+ --wired-toggle-on-color: #ff7a59;
16
+ --wired-toggle-off-color: #6b7080;
17
+ --wired-slider-knob-color: #ff7a59;
18
+ --wired-slider-bar-color: #8b90a0;
19
+ --wired-progress-color: #ff7a59;
20
+ --wired-radio-icon-color: #e8eaf0;
21
+ --wired-checkbox-icon-color: #e8eaf0;
22
+ --wired-item-selected-bg: #2f3340;
23
+ --wired-item-selected-color: #fff;
24
+ --tone-bg: #262a36;
25
+ }
26
+ .note { color: #f5e9a8; }
27
+ wired-button[elevation="3"] { background: #ff7a59; color: #14161c; }
28
+ wired-input, wired-textarea, wired-search-input { color: var(--ink); }
29
+ wired-combo { border-color: #4a4f60; }
30
+ .modal-backdrop { background: rgba(0, 0, 0, .65); }
31
+ .alert { background: color-mix(in srgb, var(--k) 16%, var(--paper)); }
@@ -0,0 +1,26 @@
1
+ /* pastel: soft and friendly. Good for consumer apps and kids' products. */
2
+ :root {
3
+ --ink: #4a3f55;
4
+ --paper: #fff9f5;
5
+ --muted: #9a8fa5;
6
+ --line: #eadcf0;
7
+ --accent: #ec4899;
8
+ --note: #fef3c7;
9
+ --card-bg: #ffffff;
10
+ --ok: #10b981;
11
+ --oi-fill: #fdf2f8;
12
+ --oi-chart: #c084fc;
13
+ --wired-toggle-on-color: #c084fc;
14
+ --wired-slider-knob-color: #ec4899;
15
+ --wired-slider-bar-color: #c084fc;
16
+ --wired-progress-color: #f9a8d4;
17
+ --wired-radio-icon-color: #c084fc;
18
+ --wired-checkbox-icon-color: #c084fc;
19
+ --tone-bg: #fdf2f8;
20
+ }
21
+ h1, h2 { color: #7e3aa1; }
22
+ wired-button { background: #fde2f3; color: #6b2a7a; }
23
+ wired-button[elevation="3"] { background: #f9a8d4; color: #4a1d5c; }
24
+ .badge.wait { background: #fde2f3; }
25
+ .badge.on { background: #d1fae5; }
26
+ .steps .on .dot { background: #ec4899; border-color: #ec4899; }
package/src/themes.js ADDED
@@ -0,0 +1,10 @@
1
+ /** Built-in colour themes. `sketch` is the default (pencil grey with one orange accent). */
2
+ export const THEME_PRESETS = ["sketch", "color", "pastel", "blueprint", "dark"];
3
+
4
+ /** Design tokens a spec can override with `colors:`. */
5
+ export const COLOR_KEYS = ["ink", "paper", "muted", "line", "accent", "note", "card"];
6
+
7
+ /** Map a `colors:` key to the CSS variable it sets. */
8
+ export const colorVar = (key) => (key === "card" ? "--card-bg" : `--${key}`);
9
+
10
+ export const isPreset = (theme) => THEME_PRESETS.includes(theme);
@@ -0,0 +1,18 @@
1
+ # Wireframe project: instructions for AI assistants
2
+
3
+ This folder is a [openink](https://github.com/mediuswareltd/openink) project. The whole prototype is described in `spec.yaml`. Never write HTML by hand.
4
+
5
+ When the user describes a product or a change:
6
+
7
+ 1. Run `npx openink blocks` to see every block type and its props (or read the JSON Schema at `node_modules/openink/schema/spec.schema.json`).
8
+ 2. Edit `spec.yaml`. One entry in `screens:` per real page or state. Give each a `title`, and use `note:` for behaviour a sketch cannot show.
9
+ 3. Run `npx openink validate`. Fix every error; read the warnings (they catch typos and unreachable screens).
10
+ 4. Run `npx openink png` and look at `dist/png/*.png` to check the layout. `npx openink dev` gives a live preview.
11
+ 5. Tell the user where the result is: `dist/index.html` (and `npx openink pdf` for a PDF).
12
+
13
+ Rules:
14
+ - It is a wireframe: hand-drawn look, image and map placeholders, no real content, no colour beyond the theme.
15
+ - Every screen must be reachable through `go:` links; end a flow with a `nextbar`.
16
+ - Use `icon:` on buttons, `avatar`, `stat`, `chart`, `modal` (open with `open: <id>`) and `device` for mobile screens where they fit. `theme: color` colours the whole project; `tone: blue` colours a single block.
17
+ - For several languages set `languages: [en, de]` and give every visible string as `{ en: …, de: … }`.
18
+ - Do not edit `dist/`; it is regenerated.
@@ -0,0 +1,11 @@
1
+ # {{name}}
2
+
3
+ Wireframes built with [openink](https://github.com/mediuswareltd/openink).
4
+
5
+ ```bash
6
+ npx openink dev # live preview at http://localhost:3000
7
+ npx openink build # static site in dist/
8
+ npx openink pdf # dist/{{name}}.pdf
9
+ ```
10
+
11
+ Edit `spec.yaml`; `npx openink blocks` lists every block you can use.
@@ -0,0 +1,3 @@
1
+ dist/
2
+ .openink-dev/
3
+ node_modules/