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,85 @@
1
+ import rough from "roughjs/bundled/rough.esm.js";
2
+
3
+ const SAMPLE = { line: [3, 5, 4, 7, 6, 9, 8], area: [3, 5, 4, 7, 6, 9, 8], bar: [5, 8, 3, 9, 6, 7], pie: [40, 30, 20, 10], donut: [40, 30, 20, 10] };
4
+
5
+ /**
6
+ * <oi-chart kind="line|area|bar|pie|donut" h="200" values="3,5,4" label="Visitors">
7
+ * A hand-drawn chart with made-up data: it shows *where* a chart goes and what kind it is.
8
+ */
9
+ class Chart extends HTMLElement {
10
+ connectedCallback() {
11
+ const h = +this.getAttribute("h") || 200;
12
+ const kind = this.getAttribute("kind") || "line";
13
+ const values = (this.getAttribute("values") || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number).filter(Number.isFinite);
14
+ const data = values.length ? values : SAMPLE[kind] || SAMPLE.line;
15
+ this.style.cssText += `display:block;position:relative;height:${h}px`;
16
+
17
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
18
+ svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
19
+ this.prepend(svg);
20
+
21
+ const draw = () => {
22
+ const w = this.clientWidth;
23
+ if (!w) return;
24
+ svg.replaceChildren();
25
+ svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
26
+ const css = getComputedStyle(this);
27
+ const ink = css.getPropertyValue("--oi-stroke").trim() || "#8a8a85";
28
+ const tone = css.getPropertyValue("--oi-chart").trim() || css.getPropertyValue("--accent").trim() || "#c2410c";
29
+ const rc = rough.svg(svg);
30
+ const line = { roughness: 1.4, stroke: ink, seed: 5 };
31
+ const max = Math.max(...data, 1);
32
+ const pad = { l: 34, r: 12, t: 14, b: 26 };
33
+ const cw = w - pad.l - pad.r;
34
+ const ch = h - pad.t - pad.b;
35
+
36
+ if (kind === "pie" || kind === "donut") {
37
+ const r = Math.min(w, h) / 2 - 8;
38
+ const cx = w / 2, cy = h / 2;
39
+ const total = data.reduce((a, b) => a + b, 0) || 1;
40
+ let a0 = -Math.PI / 2;
41
+ const shades = [tone, ink, "#cfcfc8", "#e7e5dc", "#b8b5a8"];
42
+ data.forEach((v, i) => {
43
+ const span = (Math.max(v, 0) / total) * Math.PI * 2;
44
+ const fill = { ...line, fill: shades[i % shades.length], fillStyle: i === 0 ? "solid" : "hachure", hachureGap: 6, fillWeight: 1 };
45
+ // RoughJS never returns for a zero-length arc, so skip empty slices and draw a whole circle as a circle
46
+ if (span >= Math.PI * 2 - 0.001) svg.appendChild(rc.circle(cx, cy, r * 2, fill));
47
+ else if (span > 0.01) svg.appendChild(rc.arc(cx, cy, r * 2, r * 2, a0, a0 + span, true, fill));
48
+ a0 += span;
49
+ });
50
+ if (kind === "donut") svg.appendChild(rc.circle(cx, cy, r, { ...line, fill: css.getPropertyValue("--paper").trim() || "#fdfcf8", fillStyle: "solid" }));
51
+ return;
52
+ }
53
+
54
+ // axes
55
+ svg.appendChild(rc.line(pad.l, pad.t, pad.l, pad.t + ch, line));
56
+ svg.appendChild(rc.line(pad.l, pad.t + ch, pad.l + cw, pad.t + ch, line));
57
+ for (let i = 1; i <= 3; i++) svg.appendChild(rc.line(pad.l, pad.t + (ch * i) / 4, pad.l + cw, pad.t + (ch * i) / 4, { ...line, stroke: "#d8d6cc", roughness: 0.8 }));
58
+
59
+ if (kind === "bar") {
60
+ const bw = cw / data.length;
61
+ data.forEach((v, i) => {
62
+ const bh = (v / max) * ch;
63
+ svg.appendChild(rc.rectangle(pad.l + i * bw + bw * 0.18, pad.t + ch - bh, bw * 0.64, bh, { ...line, stroke: tone, fill: tone, fillStyle: "hachure", hachureGap: 5, fillWeight: 1.2 }));
64
+ });
65
+ } else {
66
+ const pts = data.map((v, i) => [pad.l + (i / Math.max(data.length - 1, 1)) * cw, pad.t + ch - (v / max) * ch]);
67
+ if (kind === "area") svg.appendChild(rc.polygon([[pts[0][0], pad.t + ch], ...pts, [pts.at(-1)[0], pad.t + ch]], { roughness: 1, stroke: "none", fill: tone, fillStyle: "hachure", hachureGap: 7, fillWeight: 0.8, seed: 2 }));
68
+ svg.appendChild(rc.linearPath(pts, { ...line, stroke: tone, strokeWidth: 2.2 }));
69
+ pts.forEach(([x, y]) => svg.appendChild(rc.circle(x, y, 6, { ...line, stroke: tone, fill: tone, fillStyle: "solid" })));
70
+ }
71
+ };
72
+
73
+ draw();
74
+ new ResizeObserver(draw).observe(this);
75
+ const label = this.getAttribute("label");
76
+ if (label) {
77
+ const span = document.createElement("span");
78
+ span.className = "ph-label ph-corner";
79
+ span.textContent = label;
80
+ this.appendChild(span);
81
+ }
82
+ }
83
+ }
84
+
85
+ if (!customElements.get("oi-chart")) customElements.define("oi-chart", Chart);
@@ -0,0 +1,21 @@
1
+ export const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
2
+
3
+ export const config = window.OPENINK || { languages: [], first: "" };
4
+
5
+ /**
6
+ * wired-elements only draw when their size changes, and they draw at 0x0 while hidden.
7
+ * Call this after anything becomes visible or is resized so they re-render.
8
+ */
9
+ export function redraw() {
10
+ requestAnimationFrame(() =>
11
+ $$("*").forEach((el) => {
12
+ if (typeof el.wiredRender === "function" && el.offsetParent !== null) el.wiredRender();
13
+ })
14
+ );
15
+ }
16
+
17
+ let resizeTimer;
18
+ window.addEventListener("resize", () => {
19
+ clearTimeout(resizeTimer);
20
+ resizeTimer = setTimeout(redraw, 150);
21
+ });
@@ -0,0 +1,24 @@
1
+ import { $$, config, redraw } from "./dom.js";
2
+
3
+ const STORAGE_KEY = "openink_lang";
4
+
5
+ /** Text lives in <span data-l="xx"> (shown by CSS). Attributes live in data-<attr>-<lang> and are swapped here. */
6
+ export function setLang(lang) {
7
+ document.documentElement.lang = lang;
8
+ try { localStorage.setItem(STORAGE_KEY, lang); } catch { /* private mode */ }
9
+ $$("[data-lang]").forEach((b) => b.classList.toggle("on", b.dataset.lang === lang));
10
+ $$("*").forEach((el) => {
11
+ for (const a of el.attributes) {
12
+ const m = a.name.match(/^data-(.+)-([a-z]{2,3})$/i);
13
+ if (m && m[2] === lang && m[1] !== "lang") el.setAttribute(m[1], a.value);
14
+ }
15
+ });
16
+ redraw();
17
+ }
18
+
19
+ export function initI18n() {
20
+ if (!config.languages.length) return;
21
+ let saved = null;
22
+ try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* private mode */ }
23
+ setLang(config.languages.includes(saved) ? saved : config.languages[0]);
24
+ }
@@ -0,0 +1,28 @@
1
+ import rough from "roughjs/bundled/rough.esm.js";
2
+ import { ICONS } from "../icons.js";
3
+
4
+ /**
5
+ * <oi-icon name="heart" size="22" filled>
6
+ * Hand-drawn icon (RoughJS). Inherits the surrounding text colour.
7
+ */
8
+ class Icon extends HTMLElement {
9
+ connectedCallback() {
10
+ const paths = ICONS[this.getAttribute("name")];
11
+ if (!paths) return;
12
+ const size = +this.getAttribute("size") || 22;
13
+ this.style.cssText += `display:inline-block;vertical-align:middle;width:${size}px;height:${size}px;line-height:0`;
14
+
15
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
16
+ svg.setAttribute("viewBox", "0 0 24 24");
17
+ svg.setAttribute("width", size);
18
+ svg.setAttribute("height", size);
19
+ const rc = rough.svg(svg);
20
+ const filled = this.hasAttribute("filled");
21
+ for (const d of paths) {
22
+ svg.appendChild(rc.path(d, { roughness: 0.9, strokeWidth: 1.6, stroke: "currentColor", seed: 11, ...(filled ? { fill: "currentColor", fillStyle: "solid" } : {}) }));
23
+ }
24
+ this.replaceChildren(svg);
25
+ }
26
+ }
27
+
28
+ if (!customElements.get("oi-icon")) customElements.define("oi-icon", Icon);
@@ -0,0 +1,64 @@
1
+ import "wired-elements";
2
+ import "./placeholder.js";
3
+ import "./icon.js";
4
+ import "./chart.js";
5
+ import { $$, redraw } from "./dom.js";
6
+ import { go, toast, initNavigation } from "./navigation.js";
7
+ import { setLang, initI18n } from "./i18n.js";
8
+
9
+ function select(buttons, active) {
10
+ buttons.forEach((b) => {
11
+ b.classList.toggle("on", b === active);
12
+ b.setAttribute("elevation", b === active ? 3 : 1);
13
+ });
14
+ }
15
+
16
+ const closeModals = () => $$(".modal.on").forEach((m) => m.classList.remove("on"));
17
+
18
+ document.addEventListener("click", (e) => {
19
+ const lang = e.target.closest("[data-lang]");
20
+ if (lang) return setLang(lang.dataset.lang);
21
+
22
+ // modals: [data-open="id"] opens <div data-modal="id">; [data-close] (button, X, backdrop) closes
23
+ if (e.target.closest("[data-close]")) closeModals();
24
+ const opener = e.target.closest("[data-open]");
25
+ if (opener) {
26
+ closeModals();
27
+ document.querySelector(`.modal[data-modal="${CSS.escape(opener.dataset.open)}"]`)?.classList.add("on");
28
+ redraw();
29
+ }
30
+
31
+ const chip = e.target.closest("[data-chips] .chip");
32
+ if (chip) {
33
+ select($$(".chip", chip.parentElement), chip);
34
+ toast("Results updated");
35
+ }
36
+
37
+ const tab = e.target.closest("[data-tabs] .tab");
38
+ if (tab) {
39
+ const root = tab.closest("[data-tabs]");
40
+ select($$(".tab", root), tab);
41
+ $$(".tab-panel", root).forEach((p) => p.classList.toggle("on", p.dataset.panel === tab.dataset.tab));
42
+ redraw();
43
+ }
44
+
45
+ const item = e.target.closest(".tabbar .tab-item");
46
+ if (item) select($$(".tab-item", item.parentElement), item);
47
+
48
+ const target = e.target.closest("[data-go]");
49
+ if (target) {
50
+ closeModals();
51
+ go(target.dataset.go);
52
+ }
53
+ const message = e.target.closest("[data-toast]");
54
+ if (message) toast(message.dataset.toast);
55
+ });
56
+
57
+ document.addEventListener("keydown", (e) => e.key === "Escape" && closeModals());
58
+ // <details> content is hidden until opened, so wired-elements inside it have never been drawn
59
+ document.addEventListener("toggle", (e) => e.target.tagName === "DETAILS" && redraw(), true);
60
+
61
+ window.addEventListener("DOMContentLoaded", () => {
62
+ initI18n();
63
+ initNavigation();
64
+ });
@@ -0,0 +1,27 @@
1
+ import { $$, config, redraw } from "./dom.js";
2
+
3
+ /** Show one screen (and the header nav set it asks for). */
4
+ export function go(id) {
5
+ const el = document.getElementById(id);
6
+ if (!el || !el.classList.contains("screen")) return;
7
+ $$(".screen").forEach((s) => s.classList.remove("on"));
8
+ el.classList.add("on");
9
+ $$("header nav").forEach((n) => (n.hidden = n.dataset.nav !== el.dataset.nav));
10
+ document.title = el.dataset.title + " · " + document.title.split(" · ").pop();
11
+ history.replaceState(null, "", "#" + id);
12
+ window.scrollTo({ top: 0 });
13
+ redraw();
14
+ }
15
+
16
+ export function toast(message) {
17
+ const el = document.getElementById("toast");
18
+ el.textContent = message;
19
+ el.classList.add("show");
20
+ clearTimeout(toast.timer);
21
+ toast.timer = setTimeout(() => el.classList.remove("show"), 2200);
22
+ }
23
+
24
+ export function initNavigation() {
25
+ window.addEventListener("hashchange", () => go(location.hash.slice(1)));
26
+ go(location.hash.slice(1) || config.first);
27
+ }
@@ -0,0 +1,88 @@
1
+ import rough from "roughjs/bundled/rough.esm.js";
2
+
3
+ /**
4
+ * <oi-placeholder h="160" label="Photo" cross pin round play dots="4" upload>
5
+ * A hand-drawn box (RoughJS) used for images, maps, video, galleries, uploads.
6
+ * Colours come from CSS variables, so a `tone` on a parent block recolours it:
7
+ * --oi-stroke (outline), --oi-fill (hatch fill), --accent (pin / play button)
8
+ */
9
+ class Placeholder extends HTMLElement {
10
+ connectedCallback() {
11
+ const h = +this.getAttribute("h") || 120;
12
+ // A round placeholder (avatar) is square and must have a width of its own to sit in a flex row.
13
+ const width = this.hasAttribute("round") ? `width:${h}px;flex:none;` : "";
14
+ this.style.cssText += `display:block;position:relative;height:${h}px;${width}`;
15
+
16
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
17
+ svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
18
+ this.prepend(svg);
19
+
20
+ const draw = () => {
21
+ const w = this.clientWidth;
22
+ if (!w) return;
23
+ svg.replaceChildren();
24
+ svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
25
+ const css = getComputedStyle(this);
26
+ const stroke = css.getPropertyValue("--oi-stroke").trim() || "#8a8a85";
27
+ const fill = css.getPropertyValue("--oi-fill").trim() || "#f3f1ea";
28
+ const accent = css.getPropertyValue("--accent").trim() || "#c2410c";
29
+ const paper = css.getPropertyValue("--paper").trim() || "#fdfcf8";
30
+ const rc = rough.svg(svg);
31
+ const ink = { roughness: 1.6, stroke, seed: 7 };
32
+ const soft = { roughness: 1.6, stroke: css.getPropertyValue("--oi-line").trim() || "#cfcfc8", seed: 3 };
33
+ const solid = { ...ink, stroke: accent, fill: accent, fillStyle: "solid" };
34
+
35
+ if (this.hasAttribute("round")) {
36
+ svg.appendChild(rc.circle(w / 2, h / 2, Math.min(w, h) - 6, { ...ink, fill, fillStyle: "solid" }));
37
+ } else {
38
+ const dashed = this.hasAttribute("upload") ? { strokeLineDash: [8, 6] } : {};
39
+ svg.appendChild(rc.rectangle(3, 3, w - 6, h - 6, { ...ink, ...dashed, fill, fillStyle: "hachure", hachureGap: 12, fillWeight: 0.6 }));
40
+ if (this.hasAttribute("cross")) {
41
+ svg.appendChild(rc.line(3, 3, w - 3, h - 3, soft));
42
+ svg.appendChild(rc.line(w - 3, 3, 3, h - 3, soft));
43
+ }
44
+ }
45
+ if (this.hasAttribute("pin")) {
46
+ const y = h * 0.38;
47
+ svg.appendChild(rc.circle(w / 2, y, 22, solid));
48
+ svg.appendChild(rc.line(w / 2, y + 10, w / 2, y + 24, { ...ink, stroke: accent, strokeWidth: 2 }));
49
+ }
50
+ if (this.hasAttribute("play")) {
51
+ const cy = h * 0.42;
52
+ svg.appendChild(rc.circle(w / 2, cy, Math.min(56, h * 0.4), { ...ink, stroke: accent, fill: paper, fillStyle: "solid", strokeWidth: 2 }));
53
+ svg.appendChild(rc.polygon([[w / 2 - 7, cy - 10], [w / 2 - 7, cy + 10], [w / 2 + 11, cy]], solid));
54
+ }
55
+ if (this.hasAttribute("upload")) {
56
+ const cy = h * 0.38;
57
+ svg.appendChild(rc.line(w / 2, cy + 14, w / 2, cy - 14, { ...ink, strokeWidth: 2.2 }));
58
+ svg.appendChild(rc.linearPath([[w / 2 - 11, cy - 3], [w / 2, cy - 15], [w / 2 + 11, cy - 3]], { ...ink, strokeWidth: 2.2 }));
59
+ }
60
+ const dots = +this.getAttribute("dots");
61
+ if (dots > 0) {
62
+ const y = h - 16;
63
+ for (let i = 0; i < dots; i++) {
64
+ const x = w / 2 + (i - (dots - 1) / 2) * 16;
65
+ svg.appendChild(rc.circle(x, y, 7, i === 0 ? { ...ink, stroke: accent, fill: accent, fillStyle: "solid" } : { ...ink, fill: paper, fillStyle: "solid" }));
66
+ }
67
+ for (const dir of [-1, 1]) {
68
+ const x = dir < 0 ? 22 : w - 22;
69
+ svg.appendChild(rc.circle(x, h / 2, 26, { ...ink, fill: paper, fillStyle: "solid" }));
70
+ svg.appendChild(rc.linearPath([[x - dir * 3, h / 2 - 6], [x + dir * 3, h / 2], [x - dir * 3, h / 2 + 6]], { ...ink, strokeWidth: 1.8 }));
71
+ }
72
+ }
73
+ };
74
+
75
+ draw();
76
+ new ResizeObserver(draw).observe(this);
77
+
78
+ const label = this.getAttribute("label");
79
+ if (label) {
80
+ const span = document.createElement("span");
81
+ span.className = "ph-label";
82
+ span.textContent = label;
83
+ this.appendChild(span);
84
+ }
85
+ }
86
+ }
87
+
88
+ if (!customElements.get("oi-placeholder")) customElements.define("oi-placeholder", Placeholder);
@@ -0,0 +1,65 @@
1
+ import { blocks } from "../render/blocks/index.js";
2
+ import { COMMON, ACTION } from "../render/blocks/shared.js";
3
+ import { ICON_NAMES } from "../icons.js";
4
+
5
+ const TYPE_LABEL = {
6
+ text: "text",
7
+ string: "string",
8
+ number: "number",
9
+ boolean: "boolean",
10
+ "text[]": "list of text",
11
+ "number[]": "list of numbers",
12
+ sections: "list of `{ label, children }`",
13
+ links: "list of `{ icon, label, go, toast, open }`",
14
+ rows: "list of rows",
15
+ };
16
+
17
+ /** Markdown reference for every block, generated from the registry (also printed by `openink blocks`). */
18
+ export function blocksMarkdown() {
19
+ const groups = [...new Set(blocks.map((b) => b.group))];
20
+ const out = [
21
+ "# Block reference",
22
+ "",
23
+ "<!-- Generated from src/render/blocks by `npm run generate`. Do not edit by hand. -->",
24
+ "",
25
+ "Every entry in a screen's `blocks:` list is `{ type: <name>, ...props }`. Blocks marked **children** also take a `children:` list of blocks.",
26
+ "A `text` value is a string, a number, or a translation object such as `{ en: \"Hello\", de: \"Hallo\" }` (needs `languages:` in the spec).",
27
+ "",
28
+ "## Props every block accepts",
29
+ "",
30
+ "| Prop | Type | Description |",
31
+ "|---|---|---|",
32
+ ...Object.entries(COMMON).map(([k, p]) => `| \`${k}\` | ${p.enum ? p.enum.map((e) => `\`${e}\``).join(" \\| ") : p.type} | ${p.doc} |`),
33
+ "",
34
+ "## Actions",
35
+ "",
36
+ "Blocks that list `go`, `toast`, `open` or `close` in their props are clickable (`button`, `card`, `avatar`, `link`, `nextbar`, nav and tab-bar items) and accept:",
37
+ "",
38
+ "| Prop | Type | Description |",
39
+ "|---|---|---|",
40
+ ...Object.entries(ACTION).map(([k, p]) => `| \`${k}\` | ${p.type} | ${p.doc} |`),
41
+ "",
42
+ `## Icons`,
43
+ "",
44
+ "Used by `icon`, `button.icon`, nav items and `tabbar`:",
45
+ "",
46
+ ICON_NAMES.map((n) => `\`${n}\``).join(" · "),
47
+ "",
48
+ ];
49
+ for (const g of groups) {
50
+ out.push(`## ${g}`, "");
51
+ for (const b of blocks.filter((x) => x.group === g)) {
52
+ out.push(`### \`${b.name}\`${b.children ? " · children" : ""}`, "", b.summary, "");
53
+ const props = Object.entries(b.props);
54
+ if (props.length) {
55
+ out.push("| Prop | Type | Description |", "|---|---|---|");
56
+ for (const [k, p] of props) {
57
+ const type = p.enum ? p.enum.map((e) => `\`${e}\``).join(" \\| ") : TYPE_LABEL[p.type];
58
+ out.push(`| \`${k}\`${p.required ? " *" : ""} | ${type} | ${p.doc} |`);
59
+ }
60
+ out.push("", "\\* required", "");
61
+ }
62
+ }
63
+ }
64
+ return out.join("\n");
65
+ }
@@ -0,0 +1,110 @@
1
+ import { blocks } 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 TEXT = { anyOf: [{ type: ["string", "number"] }, { type: "object", additionalProperties: { type: ["string", "number"] } }] };
7
+ const ref = (name) => ({ $ref: `#/$defs/${name}` });
8
+
9
+ const propSchema = (p) => {
10
+ const base = {
11
+ text: ref("text"),
12
+ string: p.enum ? { enum: p.enum } : { type: "string" },
13
+ number: { type: "number" },
14
+ boolean: { type: "boolean" },
15
+ "text[]": { type: "array", items: ref("text") },
16
+ "number[]": { type: "array", items: { type: "number" } },
17
+ sections: { type: "array", items: { type: "object", required: ["label"], properties: { label: ref("text"), children: { type: "array", items: ref("block") } }, additionalProperties: false } },
18
+ links: {
19
+ type: "array",
20
+ items: { type: "object", properties: { icon: { enum: ICON_NAMES }, label: ref("text"), go: { type: "string" }, toast: ref("text"), open: { type: "string" } }, additionalProperties: false },
21
+ },
22
+ rows: { type: "array", items: { anyOf: [{ type: "array", items: ref("cell") }, { type: "object", required: ["cells"], properties: { cells: { type: "array", items: ref("cell") }, go: { type: "string" } }, additionalProperties: false }] } },
23
+ }[p.type];
24
+ return { ...base, description: p.doc };
25
+ };
26
+
27
+ /** Properties of a global modal: everything the `modal` block has, minus `type`. */
28
+ function modalProps() {
29
+ const modal = blocks.find((b) => b.name === "modal");
30
+ return {
31
+ ...Object.fromEntries(Object.entries({ ...modal.props, ...COMMON }).map(([k, p]) => [k, propSchema(p)])),
32
+ children: { type: "array", items: ref("block") },
33
+ };
34
+ }
35
+
36
+ /** JSON Schema for spec.yaml, generated from the block registry (gives editors autocomplete + validation). */
37
+ export function buildSchema() {
38
+ return {
39
+ $schema: "https://json-schema.org/draft/2020-12/schema",
40
+ $id: "https://unpkg.com/openink/schema/spec.schema.json",
41
+ title: "openink spec",
42
+ type: "object",
43
+ required: ["name", "screens"],
44
+ additionalProperties: false,
45
+ patternProperties: { "^x-": { description: "Free-form: define YAML anchors here to reuse blocks." } },
46
+ properties: {
47
+ name: { type: "string", description: "Title shown in the header and browser tab." },
48
+ languages: { type: "array", items: { type: "string", pattern: "^[A-Za-z]{2,3}$" }, description: "Language codes. The first is the default." },
49
+ footer: { type: "string" },
50
+ theme: {
51
+ anyOf: [{ enum: THEME_PRESETS }, { type: "string", pattern: "\\.css$" }],
52
+ description: `Colour theme: a preset (${THEME_PRESETS.join(", ")}) or the path of your own .css file. Default: sketch.`,
53
+ },
54
+ colors: {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ properties: Object.fromEntries(COLOR_KEYS.map((k) => [k, { type: "string", description: `CSS colour for the \`${k}\` design token.` }])),
58
+ description: "Override individual design tokens, on top of the theme.",
59
+ },
60
+ nav: {
61
+ description: "Header buttons: a list, or an object of named lists (screens choose one with `nav:`).",
62
+ anyOf: [{ type: "array", items: ref("navItem") }, { type: "object", additionalProperties: { type: "array", items: ref("navItem") } }],
63
+ },
64
+ modals: {
65
+ type: "array",
66
+ description: "Dialogs that any screen can open with `open: <id>`. Same properties as a `modal` block.",
67
+ items: { type: "object", required: ["id"], additionalProperties: false, properties: modalProps() },
68
+ },
69
+ screens: {
70
+ type: "array",
71
+ minItems: 1,
72
+ items: {
73
+ type: "object",
74
+ required: ["id", "blocks"],
75
+ additionalProperties: false,
76
+ properties: {
77
+ id: { type: "string", pattern: "^[A-Za-z][\\w-]*$", description: "Unique id, used by `go:` links." },
78
+ title: ref("text"),
79
+ nav: { type: "string", description: "Name of the nav set to show on this screen." },
80
+ note: { ...ref("text"), description: "Sticky-note annotation shown at the top of the screen." },
81
+ tone: { enum: TONES, description: "Tint the whole screen with a colour." },
82
+ width: { enum: ["narrow", "medium", "wide"], description: "Page width: narrow (~520px, phone-like), medium (~760px) or wide (default, full width)." },
83
+ blocks: { type: "array", items: ref("block") },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ $defs: {
89
+ text: TEXT,
90
+ navItem: { type: "object", properties: { icon: { enum: ICON_NAMES }, label: ref("text"), go: { type: "string" }, toast: ref("text"), open: { type: "string" } }, additionalProperties: false },
91
+ cell: { anyOf: [{ type: ["string", "number"] }, { type: "object", additionalProperties: { type: ["string", "number"] } }, ref("block")] },
92
+ block: {
93
+ anyOf: [
94
+ { type: "string", description: "Shorthand for a `text` block." },
95
+ ...blocks.map((b) => ({
96
+ type: "object",
97
+ description: b.summary,
98
+ required: ["type", ...Object.entries(b.props).filter(([, p]) => p.required).map(([k]) => k)],
99
+ additionalProperties: false,
100
+ properties: {
101
+ type: { const: b.name },
102
+ ...Object.fromEntries(Object.entries({ ...b.props, ...COMMON }).map(([k, p]) => [k, propSchema(p)])),
103
+ ...(b.children ? { children: { type: "array", items: ref("block") } } : {}),
104
+ },
105
+ })),
106
+ ],
107
+ },
108
+ },
109
+ };
110
+ }