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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/bin/openink.js +4 -0
- package/docs/ai-assistants.md +35 -0
- package/docs/architecture.md +50 -0
- package/docs/blocks.md +604 -0
- package/docs/extending.md +86 -0
- package/docs/getting-started.md +97 -0
- package/docs/spec.md +215 -0
- package/package.json +38 -0
- package/schema/spec.schema.json +2641 -0
- package/src/build.js +82 -0
- package/src/cli.js +133 -0
- package/src/dev.js +53 -0
- package/src/export.js +68 -0
- package/src/icons.js +47 -0
- package/src/index.js +14 -0
- package/src/render/blocks/actions.js +68 -0
- package/src/render/blocks/content.js +86 -0
- package/src/render/blocks/data.js +24 -0
- package/src/render/blocks/feedback.js +29 -0
- package/src/render/blocks/forms.js +86 -0
- package/src/render/blocks/index.js +42 -0
- package/src/render/blocks/layout.js +102 -0
- package/src/render/blocks/media.js +65 -0
- package/src/render/blocks/navigation.js +67 -0
- package/src/render/blocks/overlay.js +18 -0
- package/src/render/blocks/shared.js +20 -0
- package/src/render/blocks/text.js +49 -0
- package/src/render/context.js +63 -0
- package/src/render/page.js +76 -0
- package/src/runtime/chart.js +85 -0
- package/src/runtime/dom.js +21 -0
- package/src/runtime/i18n.js +24 -0
- package/src/runtime/icon.js +28 -0
- package/src/runtime/index.js +64 -0
- package/src/runtime/navigation.js +27 -0
- package/src/runtime/placeholder.js +88 -0
- package/src/spec/docs.js +65 -0
- package/src/spec/schema.js +110 -0
- package/src/spec/validate.js +219 -0
- package/src/styles/openink.css +250 -0
- package/src/styles/themes/blueprint.css +31 -0
- package/src/styles/themes/color.css +28 -0
- package/src/styles/themes/dark.css +31 -0
- package/src/styles/themes/pastel.css +26 -0
- package/src/themes.js +10 -0
- package/templates/starter/AGENTS.md +18 -0
- package/templates/starter/README.md +11 -0
- package/templates/starter/gitignore +3 -0
- package/templates/starter/spec.yaml +55 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const label = { type: "text", doc: "Label shown above the control." };
|
|
2
|
+
const placeholder = { type: "text", doc: "Placeholder text." };
|
|
3
|
+
|
|
4
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
5
|
+
export default [
|
|
6
|
+
{
|
|
7
|
+
name: "input",
|
|
8
|
+
group: "Forms",
|
|
9
|
+
summary: "Single-line text field.",
|
|
10
|
+
props: {
|
|
11
|
+
label,
|
|
12
|
+
placeholder,
|
|
13
|
+
value: { type: "text", doc: "Pre-filled value." },
|
|
14
|
+
inputType: { type: "string", doc: "HTML input type: `text`, `email`, `password`, `number`…" },
|
|
15
|
+
disabled: { type: "boolean", doc: "Grey out the field." },
|
|
16
|
+
},
|
|
17
|
+
render: (b, c) =>
|
|
18
|
+
`<div class="field">${c.label(b)}<wired-input type="${c.esc(b.inputType || "text")}"${c.attr("placeholder", b.placeholder)}${
|
|
19
|
+
b.value != null ? ` value="${c.esc(c.plain(b.value))}"` : ""
|
|
20
|
+
}${b.disabled ? " disabled" : ""}></wired-input></div>`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "search",
|
|
24
|
+
group: "Forms",
|
|
25
|
+
summary: "Search field with a magnifier.",
|
|
26
|
+
props: { placeholder },
|
|
27
|
+
render: (b, c) => `<wired-search-input${c.attr("placeholder", b.placeholder)}></wired-search-input>`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "textarea",
|
|
31
|
+
group: "Forms",
|
|
32
|
+
summary: "Multi-line text field.",
|
|
33
|
+
props: { label, placeholder, rows: { type: "number", doc: "Visible rows (default 3)." } },
|
|
34
|
+
render: (b, c) =>
|
|
35
|
+
`<div class="field">${c.label(b)}<wired-textarea rows="${+b.rows || 3}"${c.attr("placeholder", b.placeholder)}></wired-textarea></div>`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "select",
|
|
39
|
+
group: "Forms",
|
|
40
|
+
summary: "Dropdown.",
|
|
41
|
+
props: {
|
|
42
|
+
label,
|
|
43
|
+
options: { type: "text[]", doc: "Choices.", required: true },
|
|
44
|
+
selected: { type: "number", doc: "Index of the pre-selected option (default 0)." },
|
|
45
|
+
},
|
|
46
|
+
render: (b, c) =>
|
|
47
|
+
`<div class="field">${c.label(b)}<wired-combo selected="${+b.selected || 0}">${(b.options || [])
|
|
48
|
+
.map((o, i) => `<wired-item value="${i}">${c.tx(o)}</wired-item>`)
|
|
49
|
+
.join("")}</wired-combo></div>`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "checkbox",
|
|
53
|
+
group: "Forms",
|
|
54
|
+
summary: "Checkbox with a label.",
|
|
55
|
+
props: { label: { ...label, required: true }, checked: { type: "boolean", doc: "Start ticked." } },
|
|
56
|
+
render: (b, c) => `<wired-checkbox${b.checked ? " checked" : ""}>${c.tx(b.label)}</wired-checkbox>`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "toggle",
|
|
60
|
+
group: "Forms",
|
|
61
|
+
summary: "On/off switch with a label.",
|
|
62
|
+
props: { label: { ...label, required: true }, checked: { type: "boolean", doc: "Start switched on." } },
|
|
63
|
+
render: (b, c) => `<div class="row">${c.tx(b.label)}<wired-toggle${b.checked ? " checked" : ""}></wired-toggle></div>`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "radio",
|
|
67
|
+
group: "Forms",
|
|
68
|
+
summary: "Radio group.",
|
|
69
|
+
props: {
|
|
70
|
+
label,
|
|
71
|
+
options: { type: "text[]", doc: "Choices.", required: true },
|
|
72
|
+
selected: { type: "number", doc: "Index of the pre-selected option (default 0)." },
|
|
73
|
+
},
|
|
74
|
+
render: (b, c) =>
|
|
75
|
+
`<div class="field">${c.label(b)}<wired-radio-group selected="${+b.selected || 0}">${(b.options || [])
|
|
76
|
+
.map((o, i) => `<wired-radio name="${i}">${c.tx(o)}</wired-radio>`)
|
|
77
|
+
.join("")}</wired-radio-group></div>`,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "slider",
|
|
81
|
+
group: "Forms",
|
|
82
|
+
summary: "Range slider.",
|
|
83
|
+
props: { label, value: { type: "number", doc: "Start position, 0 to 100 (default 30)." } },
|
|
84
|
+
render: (b, c) => `<div class="field">${c.label(b)}<wired-slider value="${+b.value || 30}"></wired-slider></div>`,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import layout from "./layout.js";
|
|
2
|
+
import text from "./text.js";
|
|
3
|
+
import media from "./media.js";
|
|
4
|
+
import forms from "./forms.js";
|
|
5
|
+
import actions from "./actions.js";
|
|
6
|
+
import data from "./data.js";
|
|
7
|
+
import content from "./content.js";
|
|
8
|
+
import feedback from "./feedback.js";
|
|
9
|
+
import navigation from "./navigation.js";
|
|
10
|
+
import overlay from "./overlay.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A block is one entry in a screen's `blocks` list. Everything the framework knows about it
|
|
14
|
+
* (validation, docs, JSON Schema, rendering) comes from this one definition.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} PropDef
|
|
17
|
+
* @property {"text"|"string"|"number"|"boolean"|"text[]"|"number[]"|"sections"|"links"|"rows"} type
|
|
18
|
+
* `text` = string, number, or a `{ lang: string }` translation object.
|
|
19
|
+
* `sections` = list of `{ label, children }`; `links` = list of `{ icon, label, go, toast, open }`.
|
|
20
|
+
* @property {string} doc Shown in the generated docs and in editor tooltips.
|
|
21
|
+
* @property {boolean} [required]
|
|
22
|
+
* @property {string[]} [enum] Allowed values (for `type: "string"`).
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} BlockDef
|
|
25
|
+
* @property {string} name The `type:` value used in a spec.
|
|
26
|
+
* @property {string} group Section heading in the generated docs.
|
|
27
|
+
* @property {string} summary One-line description.
|
|
28
|
+
* @property {boolean} [children] Accepts a `children` list of blocks.
|
|
29
|
+
* @property {Record<string, PropDef>} props
|
|
30
|
+
* @property {(b: object) => Array<[string, any]>} [nested] Extra nested blocks as [path suffix, block] pairs.
|
|
31
|
+
* @property {(b: object) => string[]} [targets] Extra screen ids this block links to.
|
|
32
|
+
* @property {(b: object, ctx: import("../context.js").Context) => string} render
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** @type {BlockDef[]} */
|
|
36
|
+
export const blocks = [...layout, ...text, ...content, ...media, ...forms, ...actions, ...navigation, ...feedback, ...overlay, ...data];
|
|
37
|
+
|
|
38
|
+
const byName = new Map(blocks.map((b) => [b.name, b]));
|
|
39
|
+
if (byName.size !== blocks.length) throw new Error("Duplicate block name in src/render/blocks");
|
|
40
|
+
|
|
41
|
+
export const getBlock = (name) => byName.get(name);
|
|
42
|
+
export const blockNames = () => [...byName.keys()];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ACTION } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
4
|
+
export default [
|
|
5
|
+
{
|
|
6
|
+
name: "stack",
|
|
7
|
+
group: "Layout",
|
|
8
|
+
summary: "Vertical stack of blocks.",
|
|
9
|
+
children: true,
|
|
10
|
+
props: { center: { type: "boolean", doc: "Center the children horizontally." } },
|
|
11
|
+
render: (b, c) => `<div class="stack${b.center ? " center-items" : ""}">${c.kids(b)}</div>`,
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "row",
|
|
15
|
+
group: "Layout",
|
|
16
|
+
summary: "Horizontal row that wraps on small screens.",
|
|
17
|
+
children: true,
|
|
18
|
+
props: {
|
|
19
|
+
between: { type: "boolean", doc: "Push the first and last child to opposite ends." },
|
|
20
|
+
center: { type: "boolean", doc: "Center the children." },
|
|
21
|
+
},
|
|
22
|
+
render: (b, c) => `<div class="row${b.between ? " between" : ""}${b.center ? " center" : ""}">${c.kids(b)}</div>`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "grid",
|
|
26
|
+
group: "Layout",
|
|
27
|
+
summary: "Equal-width columns. Collapses to one column on phones.",
|
|
28
|
+
children: true,
|
|
29
|
+
props: { cols: { type: "number", doc: "Number of columns (default 2)." } },
|
|
30
|
+
render: (b, c) => `<div class="grid" style="--cols:${+b.cols || 2}">${c.kids(b)}</div>`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "card",
|
|
34
|
+
group: "Layout",
|
|
35
|
+
summary: "Hand-drawn box around related blocks. Clickable when `go` is set.",
|
|
36
|
+
children: true,
|
|
37
|
+
props: {
|
|
38
|
+
title: { type: "text", doc: "Small heading at the top of the card." },
|
|
39
|
+
dash: { type: "boolean", doc: "No fill (a lighter, secondary card)." },
|
|
40
|
+
center: { type: "boolean", doc: "Center the content horizontally." },
|
|
41
|
+
elevation: { type: "number", doc: "Shadow layers, 1 to 5 (default 1)." },
|
|
42
|
+
...ACTION,
|
|
43
|
+
},
|
|
44
|
+
render: (b, c) =>
|
|
45
|
+
`<wired-card elevation="${+b.elevation || 1}"${c.act(b)} class="card${b.go ? " click" : ""}${b.dash ? " dash" : ""}">${
|
|
46
|
+
b.title ? `<h3>${c.tx(b.title)}</h3>` : ""
|
|
47
|
+
}<div class="stack${b.center ? " center-items" : ""}">${c.kids(b)}</div></wired-card>`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "accordion",
|
|
51
|
+
group: "Layout",
|
|
52
|
+
summary: "Collapsible sections (FAQ, filters, settings groups).",
|
|
53
|
+
props: {
|
|
54
|
+
items: { type: "sections", doc: "List of `{ label, children }`.", required: true },
|
|
55
|
+
open: { type: "number", doc: "Index of the section open at first (default: all closed)." },
|
|
56
|
+
},
|
|
57
|
+
nested: (b) => (b.items || []).flatMap((t, i) => (t?.children || []).map((x, j) => [`.items[${i}].children[${j}]`, x])),
|
|
58
|
+
render: (b, c) =>
|
|
59
|
+
`<div class="accordion">${(b.items || [])
|
|
60
|
+
.map(
|
|
61
|
+
(s, i) =>
|
|
62
|
+
`<details${b.open === i ? " open" : ""}><summary>${c.tx(s.label)}<oi-icon name="chevron-down" size="18"></oi-icon></summary><div class="stack">${(s.children || [])
|
|
63
|
+
.map((x) => c.block(x))
|
|
64
|
+
.join("")}</div></details>`
|
|
65
|
+
)
|
|
66
|
+
.join("")}</div>`,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "device",
|
|
70
|
+
group: "Layout",
|
|
71
|
+
summary: "Wrap screens in a phone, tablet or browser frame. Ideal for mobile-app and responsive sketches.",
|
|
72
|
+
children: true,
|
|
73
|
+
props: {
|
|
74
|
+
kind: { type: "string", enum: ["phone", "tablet", "browser"], doc: "Frame type (default `phone`)." },
|
|
75
|
+
title: { type: "text", doc: "Address shown in the browser bar." },
|
|
76
|
+
},
|
|
77
|
+
render: (b, c) => {
|
|
78
|
+
const kind = b.kind || "phone";
|
|
79
|
+
const bar =
|
|
80
|
+
kind === "browser"
|
|
81
|
+
? `<div class="device-bar"><i></i><i></i><i></i><span>${c.tx(b.title || "example.com")}</span></div>`
|
|
82
|
+
: kind === "phone"
|
|
83
|
+
? `<div class="device-notch"></div>`
|
|
84
|
+
: "";
|
|
85
|
+
return `<div class="device device-${kind}"><wired-card elevation="2" class="device-frame">${bar}<div class="stack device-body">${c.kids(b)}</div></wired-card></div>`;
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "divider",
|
|
90
|
+
group: "Layout",
|
|
91
|
+
summary: "Hand-drawn horizontal line.",
|
|
92
|
+
props: {},
|
|
93
|
+
render: () => `<wired-divider></wired-divider>`,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "spacer",
|
|
97
|
+
group: "Layout",
|
|
98
|
+
summary: "Empty vertical space.",
|
|
99
|
+
props: { h: { type: "number", doc: "Height in px (default 16)." } },
|
|
100
|
+
render: (b) => `<div style="height:${+b.h || 16}px"></div>`,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const size = { type: "number", doc: "Height in px." };
|
|
2
|
+
const label = { type: "text", doc: "Caption centered in the box." };
|
|
3
|
+
|
|
4
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
5
|
+
export default [
|
|
6
|
+
{
|
|
7
|
+
name: "image",
|
|
8
|
+
group: "Media",
|
|
9
|
+
summary: "Image placeholder: hand-drawn box with a cross. Use `round` for avatars.",
|
|
10
|
+
props: { h: size, label, round: { type: "boolean", doc: "Draw a circle instead of a box." } },
|
|
11
|
+
render: (b, c) =>
|
|
12
|
+
`<oi-placeholder h="${+b.h || 140}" cross${b.label ? ` label="${c.esc(c.plain(b.label))}"` : ""}${b.round ? " round" : ""}></oi-placeholder>`,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "map",
|
|
16
|
+
group: "Media",
|
|
17
|
+
summary: "Map placeholder with a pin.",
|
|
18
|
+
props: { h: size, label },
|
|
19
|
+
render: (b, c) => `<oi-placeholder h="${+b.h || 160}" label="${c.esc(c.plain(b.label) || "Map")}" pin></oi-placeholder>`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "box",
|
|
23
|
+
group: "Media",
|
|
24
|
+
summary: "Generic hand-drawn box, for ads, embeds, anything else.",
|
|
25
|
+
props: { h: size, label },
|
|
26
|
+
render: (b, c) => `<oi-placeholder h="${+b.h || 80}" label="${c.esc(c.plain(b.label))}"></oi-placeholder>`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "video",
|
|
30
|
+
group: "Media",
|
|
31
|
+
summary: "Video placeholder with a play button.",
|
|
32
|
+
props: { h: size, label },
|
|
33
|
+
render: (b, c) => `<oi-placeholder h="${+b.h || 220}" play${b.label ? ` label="${c.esc(c.plain(b.label))}"` : ""}></oi-placeholder>`,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "carousel",
|
|
37
|
+
group: "Media",
|
|
38
|
+
summary: "Swipeable gallery: image placeholder with arrows and page dots.",
|
|
39
|
+
props: { h: size, label, count: { type: "number", doc: "Number of slides shown as dots (default 4)." } },
|
|
40
|
+
render: (b, c) =>
|
|
41
|
+
`<oi-placeholder h="${+b.h || 260}" cross dots="${+b.count || 4}"${b.label ? ` label="${c.esc(c.plain(b.label))}"` : ""}></oi-placeholder>`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "dropzone",
|
|
45
|
+
group: "Media",
|
|
46
|
+
summary: "Dashed upload area with an arrow.",
|
|
47
|
+
props: { h: size, label },
|
|
48
|
+
render: (b, c) => `<oi-placeholder h="${+b.h || 140}" upload label="${c.esc(c.plain(b.label) || "Drop files here")}"></oi-placeholder>`,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "chart",
|
|
52
|
+
group: "Media",
|
|
53
|
+
summary: "Hand-drawn chart with sample data. It shows where a chart goes and what kind it is.",
|
|
54
|
+
props: {
|
|
55
|
+
kind: { type: "string", enum: ["line", "area", "bar", "pie", "donut"], doc: "Chart type (default `line`)." },
|
|
56
|
+
h: size,
|
|
57
|
+
values: { type: "number[]", doc: "Your own data points. Leave out for sample data." },
|
|
58
|
+
label: { type: "text", doc: "Caption in the corner." },
|
|
59
|
+
},
|
|
60
|
+
render: (b, c) =>
|
|
61
|
+
`<oi-chart kind="${c.esc(b.kind || "line")}" h="${+b.h || 200}"${b.values ? ` values="${c.esc(b.values.join(","))}"` : ""}${
|
|
62
|
+
b.label ? ` label="${c.esc(c.plain(b.label))}"` : ""
|
|
63
|
+
}></oi-chart>`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
2
|
+
export default [
|
|
3
|
+
{
|
|
4
|
+
name: "breadcrumb",
|
|
5
|
+
group: "Navigation",
|
|
6
|
+
summary: "Path to the current page. The last item is the current one.",
|
|
7
|
+
props: { items: { type: "text[]", doc: "Path segments.", required: true } },
|
|
8
|
+
render: (b, c) => {
|
|
9
|
+
const items = b.items || [];
|
|
10
|
+
return `<nav class="crumbs">${items
|
|
11
|
+
.map((x, i) => `<span${i === items.length - 1 ? ' class="current"' : ""}>${c.tx(x)}</span>`)
|
|
12
|
+
.join('<span class="sep">/</span>')}</nav>`;
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "pagination",
|
|
17
|
+
group: "Navigation",
|
|
18
|
+
summary: "Previous / page numbers / next.",
|
|
19
|
+
props: {
|
|
20
|
+
pages: { type: "number", doc: "Number of pages (default 5)." },
|
|
21
|
+
active: { type: "number", doc: "Current page, starting at 1 (default 1)." },
|
|
22
|
+
},
|
|
23
|
+
render: (b, c) => {
|
|
24
|
+
const pages = +b.pages || 5;
|
|
25
|
+
const active = +b.active || 1;
|
|
26
|
+
return `<div class="row pagination"><wired-button data-toast="Previous page">${c.icon("arrow-left")}</wired-button><div class="row" data-chips>${Array.from(
|
|
27
|
+
{ length: pages },
|
|
28
|
+
(_, i) => `<wired-button class="chip${i + 1 === active ? " on" : ""}" elevation="${i + 1 === active ? 3 : 1}">${i + 1}</wired-button>`
|
|
29
|
+
).join("")}</div><wired-button data-toast="Next page">${c.icon("arrow-right")}</wired-button></div>`;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "steps",
|
|
34
|
+
group: "Navigation",
|
|
35
|
+
summary: "Numbered progress through a multi-step flow (checkout, onboarding).",
|
|
36
|
+
props: {
|
|
37
|
+
items: { type: "text[]", doc: "Step names.", required: true },
|
|
38
|
+
active: { type: "number", doc: "Index of the current step, starting at 0 (default 0)." },
|
|
39
|
+
},
|
|
40
|
+
render: (b, c) => {
|
|
41
|
+
const active = +b.active || 0;
|
|
42
|
+
return `<ol class="steps">${(b.items || [])
|
|
43
|
+
.map((x, i) => `<li class="${i < active ? "done" : i === active ? "on" : ""}"><span class="dot">${i < active ? c.icon("check", 14) : i + 1}</span>${c.tx(x)}</li>`)
|
|
44
|
+
.join("")}</ol>`;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "tabbar",
|
|
49
|
+
group: "Navigation",
|
|
50
|
+
summary: "Bottom tab bar of a mobile app: icons with small labels.",
|
|
51
|
+
props: {
|
|
52
|
+
items: { type: "links", doc: "List of `{ icon, label, go, toast, open }`.", required: true },
|
|
53
|
+
active: { type: "number", doc: "Index of the highlighted item (default 0)." },
|
|
54
|
+
},
|
|
55
|
+
render: (b, c) => {
|
|
56
|
+
const active = +b.active || 0;
|
|
57
|
+
return `<div class="tabbar">${(b.items || [])
|
|
58
|
+
.map(
|
|
59
|
+
(i, n) =>
|
|
60
|
+
`<wired-button class="tab-item${n === active ? " on" : ""}" elevation="${n === active ? 3 : 1}"${c.act(i)}>${
|
|
61
|
+
i.icon ? c.icon(i.icon, 22, n === active) : ""
|
|
62
|
+
}${i.label != null ? `<span class="tab-label">${c.tx(i.label)}</span>` : ""}</wired-button>`
|
|
63
|
+
)
|
|
64
|
+
.join("")}</div>`;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
2
|
+
export default [
|
|
3
|
+
{
|
|
4
|
+
name: "modal",
|
|
5
|
+
group: "Overlays",
|
|
6
|
+
summary: "Dialog that opens over the screen when a button, card or link has `open: <id>`. Close with a `close: true` button, the X, or a click outside.",
|
|
7
|
+
children: true,
|
|
8
|
+
props: {
|
|
9
|
+
id: { type: "string", doc: "Unique id; other blocks use it in `open:`.", required: true },
|
|
10
|
+
title: { type: "text", doc: "Heading of the dialog." },
|
|
11
|
+
width: { type: "string", enum: ["narrow", "medium", "wide"], doc: "Dialog width (default `medium`)." },
|
|
12
|
+
},
|
|
13
|
+
render: (b, c) =>
|
|
14
|
+
`<div class="modal" data-modal="${c.esc(b.id)}" data-width="${c.esc(b.width || "medium")}"><div class="modal-backdrop" data-close></div><wired-card elevation="3" class="modal-card"><div class="row between"><h3>${
|
|
15
|
+
b.title ? c.tx(b.title) : ""
|
|
16
|
+
}</h3><wired-button data-close>${c.icon("close")}</wired-button></div><div class="stack">${c.kids(b)}</div></wired-card></div>`,
|
|
17
|
+
},
|
|
18
|
+
];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ICON_NAMES } from "../../icons.js";
|
|
2
|
+
|
|
3
|
+
/** Named colours a block or screen can be tinted with (`tone:`). Defined as CSS variables in openink.css. */
|
|
4
|
+
export const TONES = ["blue", "green", "yellow", "red", "purple", "pink", "orange", "teal", "gray"];
|
|
5
|
+
|
|
6
|
+
/** Props every block accepts, whatever its type. Handled by the renderer, not by individual blocks. */
|
|
7
|
+
export const COMMON = {
|
|
8
|
+
tone: { type: "string", enum: TONES, doc: "Colour this block and everything inside it: outlines, text and drawn shapes." },
|
|
9
|
+
fill: { type: "boolean", doc: "Give the block a tinted background (uses `tone`, or a neutral tint)." },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Props shared by every clickable block. */
|
|
13
|
+
export const ACTION = {
|
|
14
|
+
go: { type: "string", doc: "Id of the screen to open when clicked." },
|
|
15
|
+
toast: { type: "text", doc: "Message shown in a toast when clicked." },
|
|
16
|
+
open: { type: "string", doc: "Id of a `modal` block to open when clicked." },
|
|
17
|
+
close: { type: "boolean", doc: "Close the modal this block is inside when clicked." },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const ICON = { type: "string", enum: ICON_NAMES, doc: "Hand-drawn icon name." };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const heading = (level) => ({
|
|
2
|
+
name: `h${level}`,
|
|
3
|
+
group: "Text",
|
|
4
|
+
summary: `Level ${level} heading.`,
|
|
5
|
+
props: { text: { type: "text", doc: "Heading text.", required: true } },
|
|
6
|
+
render: (b, c) => `<h${level}>${c.tx(b.text)}</h${level}>`,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
10
|
+
export default [
|
|
11
|
+
heading(1),
|
|
12
|
+
heading(2),
|
|
13
|
+
heading(3),
|
|
14
|
+
{
|
|
15
|
+
name: "text",
|
|
16
|
+
group: "Text",
|
|
17
|
+
summary: "Paragraph. A bare string in a `children` list is shorthand for this block.",
|
|
18
|
+
props: {
|
|
19
|
+
text: { type: "text", doc: "Paragraph text.", required: true },
|
|
20
|
+
muted: { type: "boolean", doc: "Grey, smaller text." },
|
|
21
|
+
bold: { type: "boolean", doc: "Bold text." },
|
|
22
|
+
},
|
|
23
|
+
render: (b, c) => `<p class="${b.muted ? "muted" : ""}${b.bold ? " bold" : ""}">${c.tx(b.text)}</p>`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "list",
|
|
27
|
+
group: "Text",
|
|
28
|
+
summary: "Bulleted list.",
|
|
29
|
+
props: { items: { type: "text[]", doc: "List items.", required: true } },
|
|
30
|
+
render: (b, c) => `<ul class="oi-list">${(b.items || []).map((i) => `<li>${c.tx(i)}</li>`).join("")}</ul>`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "note",
|
|
34
|
+
group: "Text",
|
|
35
|
+
summary: "Yellow sticky-note annotation. Use it for behaviour a sketch cannot show.",
|
|
36
|
+
props: { text: { type: "text", doc: "Note text.", required: true } },
|
|
37
|
+
render: (b, c) => `<div class="note">${c.tx(b.text)}</div>`,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "badge",
|
|
41
|
+
group: "Text",
|
|
42
|
+
summary: "Small status pill.",
|
|
43
|
+
props: {
|
|
44
|
+
text: { type: "text", doc: "Badge text.", required: true },
|
|
45
|
+
status: { type: "string", enum: ["on", "wait", "off"], doc: "Colour: `on` green, `wait` orange, `off` grey." },
|
|
46
|
+
},
|
|
47
|
+
render: (b, c) => `<span class="badge ${c.esc(b.status || "")}">${c.tx(b.text)}</span>`,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getBlock, blockNames } from "./blocks/index.js";
|
|
2
|
+
|
|
3
|
+
export const esc = (s) =>
|
|
4
|
+
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helpers handed to every block's `render(b, ctx)`.
|
|
8
|
+
* @typedef {ReturnType<typeof createContext>} Context
|
|
9
|
+
*/
|
|
10
|
+
export function createContext(spec) {
|
|
11
|
+
const langs = spec.languages ?? [];
|
|
12
|
+
const def = langs[0];
|
|
13
|
+
const isTranslation = (v) => v && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
|
|
15
|
+
/** Plain string for a text value: first language, or the only value. */
|
|
16
|
+
const plain = (v) => (isTranslation(v) ? v[def] ?? Object.values(v)[0] ?? "" : v ?? "");
|
|
17
|
+
|
|
18
|
+
const ctx = {
|
|
19
|
+
langs,
|
|
20
|
+
esc,
|
|
21
|
+
plain,
|
|
22
|
+
|
|
23
|
+
/** Text node content. Translations become one <span data-l="xx"> per language. */
|
|
24
|
+
tx(v) {
|
|
25
|
+
if (v == null) return "";
|
|
26
|
+
if (isTranslation(v) && langs.length) return langs.map((l) => `<span data-l="${l}">${esc(v[l] ?? v[def] ?? "")}</span>`).join("");
|
|
27
|
+
return esc(plain(v));
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/** Attribute. Translations add data-<name>-<lang> attributes that the runtime swaps in. */
|
|
31
|
+
attr(name, v) {
|
|
32
|
+
if (v == null) return "";
|
|
33
|
+
let s = ` ${name}="${esc(plain(v))}"`;
|
|
34
|
+
if (isTranslation(v) && langs.length) for (const l of langs) if (v[l] != null) s += ` data-${name}-${l}="${esc(v[l])}"`;
|
|
35
|
+
return s;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/** data-go / data-toast / data-open / data-close attributes for clickable blocks. */
|
|
39
|
+
act: (b) =>
|
|
40
|
+
`${b.go ? ` data-go="${esc(b.go)}"` : ""}${b.toast ? ctx.attr("data-toast", b.toast) : ""}${b.open ? ` data-open="${esc(b.open)}"` : ""}${b.close ? " data-close" : ""}`,
|
|
41
|
+
|
|
42
|
+
/** A hand-drawn icon. */
|
|
43
|
+
icon: (name, size = 18, filled = false) => `<oi-icon name="${esc(name)}" size="${size}"${filled ? " filled" : ""}></oi-icon>`,
|
|
44
|
+
|
|
45
|
+
/** Icon + label, for buttons and nav items. Either may be missing. */
|
|
46
|
+
inner: (b) => `${b.icon ? ctx.icon(b.icon) : ""}${b.label != null ? `<span class="btn-label">${ctx.tx(b.label)}</span>` : ""}`,
|
|
47
|
+
|
|
48
|
+
label: (b) => (b.label ? `<label class="fl">${ctx.tx(b.label)}</label>` : ""),
|
|
49
|
+
|
|
50
|
+
block(b) {
|
|
51
|
+
if (typeof b === "string") return getBlock("text").render({ text: b }, ctx);
|
|
52
|
+
const def = getBlock(b?.type);
|
|
53
|
+
if (!def) throw new Error(`Unknown block type "${b?.type}". Known types: ${blockNames().join(", ")}`);
|
|
54
|
+
const html = def.render(b, ctx);
|
|
55
|
+
// `tone` / `fill` work on any block: wrap it in an element that sets the colour variables
|
|
56
|
+
if (!b.tone && !b.fill) return html;
|
|
57
|
+
return `<div class="tone"${b.tone ? ` data-tone="${esc(b.tone)}"` : ""}${b.fill ? " data-fill" : ""}>${html}</div>`;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
kids: (b) => (b.children || []).map((x) => ctx.block(x)).join(""),
|
|
61
|
+
};
|
|
62
|
+
return ctx;
|
|
63
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createContext, esc } from "./context.js";
|
|
2
|
+
import { colorVar, isPreset } from "../themes.js";
|
|
3
|
+
|
|
4
|
+
const DEV_RELOAD = `<script>(function(){var v;setInterval(function(){fetch("/__version").then(function(r){return r.text()}).then(function(t){if(v&&t!==v)location.reload();v=t}).catch(function(){})},600)})()</script>`;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Render a validated spec to a complete HTML document.
|
|
8
|
+
* @param {object} spec
|
|
9
|
+
* @param {{ dev?: boolean }} [opts] dev = inject the live-reload poller
|
|
10
|
+
*/
|
|
11
|
+
export function renderPage(spec, { dev = false } = {}) {
|
|
12
|
+
const c = createContext(spec);
|
|
13
|
+
const langs = c.langs;
|
|
14
|
+
const first = spec.screens[0].id;
|
|
15
|
+
|
|
16
|
+
const navSets = Array.isArray(spec.nav) ? { default: spec.nav } : spec.nav || { default: [] };
|
|
17
|
+
const nav = Object.entries(navSets)
|
|
18
|
+
.map(([key, items]) => `<nav data-nav="${esc(key)}" hidden>${items.map((n) => `<wired-button${c.act(n)}>${c.inner(n)}</wired-button>`).join("")}</nav>`)
|
|
19
|
+
.join("");
|
|
20
|
+
|
|
21
|
+
const screens = spec.screens
|
|
22
|
+
.map(
|
|
23
|
+
(s) => `<section class="screen" id="${esc(s.id)}" data-title="${esc(c.plain(s.title) || s.id)}" data-nav="${esc(s.nav || "default")}"${s.width ? ` data-width="${esc(s.width)}"` : ""}${s.tone ? ` data-tone="${esc(s.tone)}"` : ""}>
|
|
24
|
+
<p class="screen-title">${c.tx(s.title || s.id)}</p>
|
|
25
|
+
${s.note ? `<div class="note">${c.tx(s.note)}</div>` : ""}
|
|
26
|
+
${(s.blocks || []).map((b) => c.block(b)).join("\n")}
|
|
27
|
+
</section>`
|
|
28
|
+
)
|
|
29
|
+
.join("\n");
|
|
30
|
+
|
|
31
|
+
// global modals live outside the screens, so any screen can open them
|
|
32
|
+
const modals = (spec.modals || []).map((m) => c.block({ ...m, type: "modal" })).join("\n");
|
|
33
|
+
|
|
34
|
+
const langBar = langs.length ? `<div class="lang">${langs.map((l) => `<wired-button data-lang="${esc(l)}">${esc(l.toUpperCase())}</wired-button>`).join("")}</div>` : "";
|
|
35
|
+
const langCss = langs.length
|
|
36
|
+
? `<style>[data-l]{display:none}${langs.map((l) => `html[lang="${esc(l)}"] [data-l="${esc(l)}"]{display:inline}`).join("")}</style>`
|
|
37
|
+
: "";
|
|
38
|
+
|
|
39
|
+
// theme: a built-in preset (theme-<name>.css is copied by the build) or the project's own .css file
|
|
40
|
+
const themeLink = spec.theme && spec.theme !== "sketch" ? `<link rel="stylesheet" href="${esc(isPreset(spec.theme) ? `theme-${spec.theme}.css` : spec.theme)}" />` : "";
|
|
41
|
+
const colors = Object.entries(spec.colors || {});
|
|
42
|
+
const colorCss = colors.length ? `<style>:root{${colors.map(([k, v]) => `${colorVar(k)}:${v}`).join(";")}}</style>` : "";
|
|
43
|
+
|
|
44
|
+
return `<!DOCTYPE html>
|
|
45
|
+
<html lang="${esc(langs[0] || "en")}">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="UTF-8" />
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
49
|
+
<title>${esc(spec.name)} · wireframe</title>
|
|
50
|
+
<link rel="stylesheet" href="openink.css" />
|
|
51
|
+
${themeLink}
|
|
52
|
+
${colorCss}
|
|
53
|
+
${langCss}
|
|
54
|
+
<script>window.OPENINK=${JSON.stringify({ languages: langs, first }).replace(/</g, "\\u003c")};</script>
|
|
55
|
+
<script src="openink.js" defer></script>
|
|
56
|
+
${dev ? DEV_RELOAD : ""}
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<div class="shell">
|
|
60
|
+
<header class="oi-bar">
|
|
61
|
+
<a class="brand" data-go="${esc(first)}">${esc(spec.name)}</a>
|
|
62
|
+
${nav}
|
|
63
|
+
${langBar}
|
|
64
|
+
<wired-button class="print-btn" onclick="window.print()">PDF</wired-button>
|
|
65
|
+
</header>
|
|
66
|
+
<main>
|
|
67
|
+
${screens}
|
|
68
|
+
</main>
|
|
69
|
+
${modals}
|
|
70
|
+
<footer class="muted center">${esc(spec.footer || "Wireframe · not the final design")}</footer>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="toast" id="toast"></div>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
75
|
+
`;
|
|
76
|
+
}
|