htmlboard 0.3.0 → 0.5.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/README.md +170 -5
- package/bin/htmlboard.js +152 -0
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +52 -7
- package/src/catalog.js +449 -0
- package/src/check.js +173 -0
- package/src/client/check.js +349 -0
- package/src/client/explorer.js +68 -2
- package/src/client/probe.js +41 -0
- package/src/components.js +95 -0
- package/src/designs.js +63 -15
- package/src/index.js +18 -0
- package/src/server.js +167 -11
- package/src/templates/01-screens/01-dashboard.html +112 -0
- package/src/templates/01-screens/02-customers.html +77 -0
- package/src/templates/01-screens/{02-mobile.html → 03-mobile.html} +35 -182
- package/src/templates/02-ui-kit/01-overview.html +89 -0
- package/src/templates/components/app-sidebar.html +67 -0
- package/src/templates/components/app-topbar.html +19 -0
- package/src/templates/components/stat-card.html +14 -0
- package/src/templates/components/tx-item.html +33 -0
- package/src/templates/components.js +180 -0
- package/src/templates/styles.css +155 -0
- package/src/templates/01-screens/01-desktop.html +0 -399
- package/src/templates/02-components/01-ui-kit.html +0 -201
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// components.js: every file in components/ is a custom element named after it, so
|
|
2
|
+
// components/tx-item.html is <tx-item>. Designs load this file once, beside styles.css:
|
|
3
|
+
//
|
|
4
|
+
// <script type="module" src="../components.js"></script>
|
|
5
|
+
//
|
|
6
|
+
// A component file is a piece of HTML (no <html>, <head> or <body>):
|
|
7
|
+
//
|
|
8
|
+
// <!-- <tx-item name="Coffee" amount="-$6.75"></tx-item> --> first: how to use it
|
|
9
|
+
// <b>{{name}}</b> <span class="amount">{{amount}}</span> the element's attributes, as text
|
|
10
|
+
// <slot></slot> <slot name="icon"></slot> its content; its children with slot="icon"
|
|
11
|
+
// <style> & { display: flex } .amount { … } </style> only for this element (& is the element)
|
|
12
|
+
// <script> this.… </script> runs for each element, this = the element
|
|
13
|
+
//
|
|
14
|
+
// Relative links in it start from its file. Components can use other components.
|
|
15
|
+
// Added by htmlboard (npx htmlboard components); yours to edit.
|
|
16
|
+
|
|
17
|
+
const FOLDER = new URL('components/', import.meta.url);
|
|
18
|
+
const AsyncFunction = (async () => {}).constructor;
|
|
19
|
+
// At-rules that can't go inside a style rule: they stay at the top of the component's CSS
|
|
20
|
+
const TOP_LEVEL_RULES = ['CSSKeyframesRule', 'CSSFontFaceRule', 'CSSPropertyRule', 'CSSCounterStyleRule',
|
|
21
|
+
'CSSFontFeatureValuesRule', 'CSSFontPaletteValuesRule'].map((name) => globalThis[name]).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
const loading = new Map(); // tag → its component loading
|
|
24
|
+
const scripts = new Map(); // URL of a <script src> a component needs → it loading
|
|
25
|
+
const built = new WeakSet();
|
|
26
|
+
|
|
27
|
+
const isContent = (node) => node.nodeType === Node.ELEMENT_NODE || node.textContent.trim() !== '';
|
|
28
|
+
const escape = (text) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
29
|
+
// Not "#…", "?…", "/…", "{{…}}" or with a scheme ("https:", "data:", "mailto:")
|
|
30
|
+
const isRelative = (url) => url.trim() !== '' && !/^\s*([a-z][a-z\d+.-]*:|[/#?]|\{\{)/i.test(url);
|
|
31
|
+
const absolute = (el, attribute, base) => new URL(el.getAttribute(attribute), base).href;
|
|
32
|
+
|
|
33
|
+
// The elements of tags nobody defined yet, in root and under it, load their components. A custom
|
|
34
|
+
// element's name has a hyphen: other elements can be undefined too (<tr is="…">), and aren't ours.
|
|
35
|
+
function discover(root) {
|
|
36
|
+
for (const el of [root, ...root.querySelectorAll(':not(:defined)')]) {
|
|
37
|
+
if (el.localName.includes('-') && el.matches(':not(:defined)')) load(el.localName);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function load(tag) {
|
|
42
|
+
if (!loading.has(tag)) loading.set(tag, define(tag).catch((error) => console.error(`<${tag}>`, error)));
|
|
43
|
+
return loading.get(tag);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function define(tag) {
|
|
47
|
+
const url = new URL(`${tag}.html`, FOLDER);
|
|
48
|
+
const response = await fetch(url);
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
if (!customElements.get(tag)) console.warn(`<${tag}>: there is no ${decodeURI(url.pathname)}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const component = parse(await response.text(), url, tag);
|
|
54
|
+
await Promise.all(component.needs.map(addScript));
|
|
55
|
+
if (customElements.get(tag)) return; // defined by another script meanwhile
|
|
56
|
+
customElements.define(tag, class extends HTMLElement {
|
|
57
|
+
connectedCallback() {
|
|
58
|
+
if (built.has(this)) return;
|
|
59
|
+
built.add(this);
|
|
60
|
+
build(this, component);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A component file: its markup, without its <style>, <script>, comments and what the canvas reads.
|
|
66
|
+
function parse(source, url, tag) {
|
|
67
|
+
const template = document.createElement('template');
|
|
68
|
+
template.innerHTML = source;
|
|
69
|
+
const { content } = template;
|
|
70
|
+
let css = '';
|
|
71
|
+
let code = '';
|
|
72
|
+
const needs = [];
|
|
73
|
+
for (const node of [...content.children]) {
|
|
74
|
+
if (!['style', 'script', 'link', 'meta', 'title'].includes(node.localName)) continue;
|
|
75
|
+
node.remove();
|
|
76
|
+
if (node.localName === 'style') css += `${node.textContent}\n`;
|
|
77
|
+
else if (node.localName === 'script' && node.hasAttribute('src')) needs.push(absolute(node, 'src', url));
|
|
78
|
+
else if (node.localName === 'script') code += `${node.textContent}\n`;
|
|
79
|
+
else if (node.localName === 'link' && /\bstylesheet\b/i.test(node.getAttribute('rel'))) addLink(absolute(node, 'href', url));
|
|
80
|
+
}
|
|
81
|
+
const walker = document.createTreeWalker(content, NodeFilter.SHOW_COMMENT);
|
|
82
|
+
const comments = [];
|
|
83
|
+
while (walker.nextNode()) comments.push(walker.currentNode);
|
|
84
|
+
for (const comment of comments) comment.remove();
|
|
85
|
+
for (const el of content.querySelectorAll('[src], [href], [poster]')) {
|
|
86
|
+
for (const attribute of ['src', 'href', 'poster']) {
|
|
87
|
+
const value = el.getAttribute(attribute);
|
|
88
|
+
if (value && isRelative(value)) el.setAttribute(attribute, new URL(value, url).href);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
addStyle(tag, css, url);
|
|
92
|
+
let run = null;
|
|
93
|
+
if (code.trim()) {
|
|
94
|
+
try {
|
|
95
|
+
run = new AsyncFunction(code);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`<${tag}> <script>`, error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { markup: template.innerHTML.trim(), run, needs };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// An element of the component: the markup with the element's attributes, and its content in the slots.
|
|
104
|
+
function build(el, { markup, run }) {
|
|
105
|
+
let depth = 0;
|
|
106
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
107
|
+
if (node.localName === el.localName && ++depth > 25) return console.error(`<${el.localName}> holds itself`);
|
|
108
|
+
}
|
|
109
|
+
const template = document.createElement('template');
|
|
110
|
+
template.innerHTML = markup.replace(/\{\{\s*([^{}\s]+)\s*\}\}/g, (_, name) => escape(el.getAttribute(name) ?? ''));
|
|
111
|
+
const { content } = template;
|
|
112
|
+
const given = [...el.childNodes];
|
|
113
|
+
const slots = content.querySelectorAll('slot');
|
|
114
|
+
if (slots.length) {
|
|
115
|
+
const placed = new Set();
|
|
116
|
+
for (const slot of slots) {
|
|
117
|
+
const name = slot.getAttribute('name') || '';
|
|
118
|
+
const nodes = given.filter((node) => !placed.has(node) && (node.getAttribute?.('slot') || '') === name);
|
|
119
|
+
nodes.forEach((node) => placed.add(node));
|
|
120
|
+
// A slot nobody fills shows what it holds
|
|
121
|
+
slot.replaceWith(...(nodes.some(isContent) ? nodes : slot.childNodes));
|
|
122
|
+
}
|
|
123
|
+
} else if (given.some(isContent)) {
|
|
124
|
+
// <tx-item /> isn't self-closing in HTML: what came after it was parsed into it, so it goes back
|
|
125
|
+
el.after(...given);
|
|
126
|
+
}
|
|
127
|
+
el.replaceChildren(content);
|
|
128
|
+
run?.call(el).catch((error) => console.error(`<${el.localName}> <script>`, error));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The component's box and CSS, once per page. Its CSS goes inside :where(tag) { … }, so it only
|
|
132
|
+
// reaches the element (& is the element itself) without adding weight to its selectors. It comes
|
|
133
|
+
// after the shared stylesheets and before the page's own <style>, so a design can still restyle it.
|
|
134
|
+
function addStyle(tag, css, url) {
|
|
135
|
+
css = css.replace(/url\(\s*(['"]?)(.*?)\1\s*\)/g, (whole, quote, value) =>
|
|
136
|
+
isRelative(value) ? `url("${new URL(value, url)}")` : whole);
|
|
137
|
+
const top = [];
|
|
138
|
+
try {
|
|
139
|
+
const sheet = new CSSStyleSheet();
|
|
140
|
+
sheet.replaceSync(css);
|
|
141
|
+
for (const rule of sheet.cssRules) if (TOP_LEVEL_RULES.some((type) => rule instanceof type)) top.push(rule.cssText);
|
|
142
|
+
} catch {
|
|
143
|
+
// Nothing to take to the top
|
|
144
|
+
}
|
|
145
|
+
const style = document.createElement('style');
|
|
146
|
+
style.dataset.component = tag;
|
|
147
|
+
style.textContent = `:where(${tag}:not([hidden])) { display: block; }\n${top.join('\n')}\n:where(${tag}) {\n${css}\n}\n`;
|
|
148
|
+
toHead(style);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addLink(href) {
|
|
152
|
+
if ([...document.querySelectorAll('link[rel~="stylesheet"]')].some((link) => link.href === href)) return;
|
|
153
|
+
toHead(Object.assign(document.createElement('link'), { rel: 'stylesheet', href }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A script a component needs (<script src="…">), loaded once, before its elements are built
|
|
157
|
+
function addScript(src) {
|
|
158
|
+
if (!scripts.has(src)) {
|
|
159
|
+
scripts.set(src, [...document.scripts].some((script) => script.src === src) ? Promise.resolve() : new Promise((resolve) => {
|
|
160
|
+
const script = Object.assign(document.createElement('script'), { src });
|
|
161
|
+
script.onload = script.onerror = resolve;
|
|
162
|
+
document.head.append(script);
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
return scripts.get(src);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Ahead of the page's own <style>, after everything else in the <head>
|
|
169
|
+
function toHead(node) {
|
|
170
|
+
document.head.insertBefore(node, document.head.querySelector('style:not([data-component])'));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Every element of the page gets built, and so do the ones added later (by the page's scripts, or
|
|
174
|
+
// inside other components).
|
|
175
|
+
discover(document.documentElement);
|
|
176
|
+
new MutationObserver((records) => {
|
|
177
|
+
for (const record of records) {
|
|
178
|
+
for (const node of record.addedNodes) if (node.nodeType === Node.ELEMENT_NODE) discover(node);
|
|
179
|
+
}
|
|
180
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared styles for every design in this folder: tokens, base styles, and the
|
|
3
|
+
* classes designs and components are built with (cards, buttons, badges…).
|
|
4
|
+
* Designs link it (<link rel="stylesheet" href="../styles.css">) and keep only
|
|
5
|
+
* what is specific to them in their own <style>. The components in components/
|
|
6
|
+
* keep their own CSS in their files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* Tokens */
|
|
10
|
+
:root {
|
|
11
|
+
--bg: #0b0d13;
|
|
12
|
+
--surface: #141724;
|
|
13
|
+
--surface-2: #1c2030;
|
|
14
|
+
--border: #23283c;
|
|
15
|
+
--text: #f1f5f9;
|
|
16
|
+
--text-muted: #94a3b8;
|
|
17
|
+
--text-subtle: #64748b;
|
|
18
|
+
--accent: #6366f1;
|
|
19
|
+
--accent-hover: #4f46e5;
|
|
20
|
+
--accent-light: #818cf8;
|
|
21
|
+
--accent-glow: rgba(99, 102, 241, 0.35);
|
|
22
|
+
--emerald: #10b981;
|
|
23
|
+
--amber: #f59e0b;
|
|
24
|
+
--rose: #f43f5e;
|
|
25
|
+
--purple: #a855f7;
|
|
26
|
+
--radius: 8px;
|
|
27
|
+
--radius-lg: 14px;
|
|
28
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Base */
|
|
32
|
+
* {
|
|
33
|
+
box-sizing: border-box;
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: 0;
|
|
36
|
+
-webkit-font-smoothing: antialiased;
|
|
37
|
+
}
|
|
38
|
+
body {
|
|
39
|
+
font-family: var(--font);
|
|
40
|
+
background: var(--bg);
|
|
41
|
+
color: var(--text);
|
|
42
|
+
}
|
|
43
|
+
a { color: inherit; text-decoration: none; }
|
|
44
|
+
|
|
45
|
+
/* Text */
|
|
46
|
+
.muted { color: var(--text-muted); }
|
|
47
|
+
.label {
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
color: var(--text-subtle);
|
|
51
|
+
text-transform: uppercase;
|
|
52
|
+
letter-spacing: 0.06em;
|
|
53
|
+
}
|
|
54
|
+
.row {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 12px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.page-title { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; }
|
|
61
|
+
.page-sub { margin-top: 4px; font-size: 14px; color: var(--text-muted); }
|
|
62
|
+
|
|
63
|
+
/* Desktop app: <app-sidebar> beside a column that scrolls */
|
|
64
|
+
.app { display: flex; height: 100vh; overflow: hidden; }
|
|
65
|
+
.app-main { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
|
|
66
|
+
.app-content { display: flex; flex-direction: column; gap: 24px; padding: 28px 32px; }
|
|
67
|
+
|
|
68
|
+
/* Card */
|
|
69
|
+
.card {
|
|
70
|
+
background: var(--surface);
|
|
71
|
+
border: 1px solid var(--border);
|
|
72
|
+
border-radius: var(--radius-lg);
|
|
73
|
+
padding: 20px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Buttons */
|
|
77
|
+
.btn {
|
|
78
|
+
display: inline-flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 8px;
|
|
81
|
+
padding: 9px 16px;
|
|
82
|
+
border: 1px solid transparent;
|
|
83
|
+
border-radius: var(--radius);
|
|
84
|
+
font: 600 13px var(--font);
|
|
85
|
+
color: var(--text);
|
|
86
|
+
background: var(--surface-2);
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
transition: background 0.15s ease;
|
|
89
|
+
}
|
|
90
|
+
.btn-primary { background: var(--accent); color: #fff; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); }
|
|
91
|
+
.btn-primary:hover { background: var(--accent-hover); }
|
|
92
|
+
.btn-secondary { border-color: var(--border); }
|
|
93
|
+
.btn-secondary:hover { background: #262c44; }
|
|
94
|
+
.btn-outline { background: transparent; border-color: #3b4260; }
|
|
95
|
+
.btn-outline:hover { background: rgba(255, 255, 255, 0.05); }
|
|
96
|
+
.btn-danger { background: rgba(244, 63, 94, 0.15); border-color: rgba(244, 63, 94, 0.3); color: var(--rose); }
|
|
97
|
+
.btn-danger:hover { background: rgba(244, 63, 94, 0.25); }
|
|
98
|
+
|
|
99
|
+
/* Badges */
|
|
100
|
+
.badge {
|
|
101
|
+
display: inline-flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 6px;
|
|
104
|
+
padding: 4px 10px;
|
|
105
|
+
border-radius: 20px;
|
|
106
|
+
font-size: 12px;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
background: rgba(99, 102, 241, 0.15);
|
|
109
|
+
color: var(--accent-light);
|
|
110
|
+
}
|
|
111
|
+
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
112
|
+
.badge-emerald { background: rgba(16, 185, 129, 0.15); color: var(--emerald); }
|
|
113
|
+
.badge-amber { background: rgba(245, 158, 11, 0.15); color: var(--amber); }
|
|
114
|
+
.badge-rose { background: rgba(244, 63, 94, 0.15); color: var(--rose); }
|
|
115
|
+
|
|
116
|
+
/* Inputs */
|
|
117
|
+
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
118
|
+
.field > span { font-size: 12px; font-weight: 500; color: var(--text-muted); }
|
|
119
|
+
.input {
|
|
120
|
+
background: var(--bg);
|
|
121
|
+
border: 1px solid var(--border);
|
|
122
|
+
border-radius: var(--radius);
|
|
123
|
+
padding: 9px 12px;
|
|
124
|
+
font: 13px var(--font);
|
|
125
|
+
color: var(--text);
|
|
126
|
+
outline: none;
|
|
127
|
+
transition: border-color 0.15s ease;
|
|
128
|
+
}
|
|
129
|
+
.input:focus { border-color: var(--accent); }
|
|
130
|
+
|
|
131
|
+
/* Avatar */
|
|
132
|
+
.avatar {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
width: 36px;
|
|
138
|
+
height: 36px;
|
|
139
|
+
border-radius: 50%;
|
|
140
|
+
background: linear-gradient(135deg, var(--accent), var(--purple));
|
|
141
|
+
font-size: 14px;
|
|
142
|
+
font-weight: 700;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Table */
|
|
146
|
+
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
147
|
+
.table th {
|
|
148
|
+
padding: 0 16px 12px 0;
|
|
149
|
+
border-bottom: 1px solid var(--border);
|
|
150
|
+
text-align: left;
|
|
151
|
+
font-weight: 500;
|
|
152
|
+
color: var(--text-subtle);
|
|
153
|
+
}
|
|
154
|
+
.table td { padding: 12px 16px 12px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.04); }
|
|
155
|
+
.table .badge { padding: 3px 8px; font-size: 11px; }
|