ucode-agent 1.26.2 → 1.27.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 +36 -7
- package/package.json +1 -1
- package/skills/build-app/DIGEST.md +94 -0
- package/skills/build-app/SKILL.md +222 -181
- package/skills/ui-ux/DIGEST.md +135 -0
- package/src/core/context.js +164 -151
- package/src/core/loop.js +189 -24
- package/src/core/provider.js +6 -0
- package/src/core/skills.js +189 -165
- package/src/core/window.js +27 -2
- package/src/tools/blocks.js +117 -27
- package/src/tools/index.js +71 -23
- package/src/tools/scaffold.js +104 -14
- package/src/ui/plain.js +4 -2
- package/src/ui/screen.js +14 -5
- package/src/ui/theme.js +100 -24
- package/templates/blocks/plain/filter-bar.js +133 -0
- package/templates/blocks/plain/item-list.js +249 -0
- package/templates/blocks/plain/modal.js +141 -0
- package/templates/blocks/plain/store.js +93 -0
- package/templates/blocks/plain/theme-toggle.js +116 -0
- package/templates/blocks/plain/toast.js +107 -0
- package/templates/plain-html/styles.css +4 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row of filters — All / Active / Done — that works from the keyboard.
|
|
3
|
+
*
|
|
4
|
+
* Written by hand it usually becomes three divs with a click handler and no
|
|
5
|
+
* way to reach it by tab. These are real buttons in a labelled group, with
|
|
6
|
+
* arrow keys moving between them and one tab stop for the whole row.
|
|
7
|
+
*
|
|
8
|
+
* import { FilterBar } from './blocks/filter-bar.js';
|
|
9
|
+
*
|
|
10
|
+
* const filters = FilterBar({
|
|
11
|
+
* options: [
|
|
12
|
+
* { value: 'all', label: 'All' },
|
|
13
|
+
* { value: 'active', label: 'Active' },
|
|
14
|
+
* { value: 'done', label: 'Done' },
|
|
15
|
+
* ],
|
|
16
|
+
* value: 'all',
|
|
17
|
+
* onChange: (value) => { state.filter = value; render(); },
|
|
18
|
+
* });
|
|
19
|
+
* document.querySelector('#app').append(filters.el);
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CSS = `
|
|
23
|
+
.fb {
|
|
24
|
+
display: inline-flex; gap: 2px; padding: 4px;
|
|
25
|
+
background: var(--surface, #14171c);
|
|
26
|
+
border: 1px solid var(--line, #232830); border-radius: 999px;
|
|
27
|
+
}
|
|
28
|
+
.fb button {
|
|
29
|
+
padding: 8px var(--s3, 16px); min-height: 36px;
|
|
30
|
+
font: inherit; color: var(--muted, #8b93a3);
|
|
31
|
+
background: none; border: 0; border-radius: 999px; cursor: pointer;
|
|
32
|
+
transition: color var(--fast, 160ms), background var(--fast, 160ms);
|
|
33
|
+
}
|
|
34
|
+
.fb button:hover { color: var(--text, #e7eaf0); }
|
|
35
|
+
.fb button[aria-pressed="true"] {
|
|
36
|
+
color: var(--accent-ink, #04110f); background: var(--accent, #2dd4bf);
|
|
37
|
+
}
|
|
38
|
+
.fb button:focus-visible { outline: 2px solid var(--accent, #2dd4bf); outline-offset: 2px; }
|
|
39
|
+
.fb-count { margin-left: 6px; opacity: .7; font-variant-numeric: tabular-nums; }
|
|
40
|
+
@media (prefers-reduced-motion: reduce) { .fb button { transition: none; } }
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
let styled = false;
|
|
44
|
+
function styles() {
|
|
45
|
+
if (styled) return;
|
|
46
|
+
styled = true;
|
|
47
|
+
document.head.append(Object.assign(document.createElement('style'), { textContent: CSS }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function FilterBar({
|
|
51
|
+
options = [],
|
|
52
|
+
value = options[0]?.value,
|
|
53
|
+
onChange = () => {},
|
|
54
|
+
label = 'Filter',
|
|
55
|
+
} = {}) {
|
|
56
|
+
styles();
|
|
57
|
+
|
|
58
|
+
let current = value;
|
|
59
|
+
|
|
60
|
+
const el = document.createElement('div');
|
|
61
|
+
el.className = 'fb';
|
|
62
|
+
el.setAttribute('role', 'group');
|
|
63
|
+
el.setAttribute('aria-label', label);
|
|
64
|
+
|
|
65
|
+
const buttons = options.map((option) => {
|
|
66
|
+
const button = document.createElement('button');
|
|
67
|
+
button.type = 'button';
|
|
68
|
+
button.dataset.value = option.value;
|
|
69
|
+
button.textContent = option.label;
|
|
70
|
+
|
|
71
|
+
if (option.count !== undefined) {
|
|
72
|
+
const count = document.createElement('span');
|
|
73
|
+
count.className = 'fb-count';
|
|
74
|
+
count.textContent = String(option.count);
|
|
75
|
+
button.append(count);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
button.addEventListener('click', () => pick(option.value));
|
|
79
|
+
el.append(button);
|
|
80
|
+
return button;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// One tab stop for the row, arrows to move inside it: the pattern a
|
|
84
|
+
// keyboard user expects from a group of related choices.
|
|
85
|
+
el.addEventListener('keydown', (event) => {
|
|
86
|
+
const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
87
|
+
const step = keys[event.key];
|
|
88
|
+
if (!step) return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
const at = buttons.findIndex((b) => b.dataset.value === current);
|
|
91
|
+
const next = buttons[(at + step + buttons.length) % buttons.length];
|
|
92
|
+
pick(next.dataset.value);
|
|
93
|
+
next.focus();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function paint() {
|
|
97
|
+
for (const button of buttons) {
|
|
98
|
+
const on = button.dataset.value === current;
|
|
99
|
+
button.setAttribute('aria-pressed', String(on));
|
|
100
|
+
button.tabIndex = on ? 0 : -1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pick(next) {
|
|
105
|
+
if (next === current) return;
|
|
106
|
+
current = next;
|
|
107
|
+
paint();
|
|
108
|
+
onChange(current);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
paint();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
el,
|
|
115
|
+
get value() { return current; },
|
|
116
|
+
/** Move the selection without calling onChange — for state set elsewhere. */
|
|
117
|
+
setValue(next) { current = next; paint(); },
|
|
118
|
+
/** Update the numbers beside the labels. */
|
|
119
|
+
setCounts(counts = {}) {
|
|
120
|
+
for (const button of buttons) {
|
|
121
|
+
const count = counts[button.dataset.value];
|
|
122
|
+
let span = button.querySelector('.fb-count');
|
|
123
|
+
if (count === undefined) { span?.remove(); continue; }
|
|
124
|
+
if (!span) {
|
|
125
|
+
span = document.createElement('span');
|
|
126
|
+
span.className = 'fb-count';
|
|
127
|
+
button.append(span);
|
|
128
|
+
}
|
|
129
|
+
span.textContent = String(count);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list of things you can add, tick off, rename and remove.
|
|
3
|
+
*
|
|
4
|
+
* The part of a one-page app that is written from scratch most often and
|
|
5
|
+
* finished least often: no empty state, no keyboard, no way to fix a typo,
|
|
6
|
+
* and a delete button that is a bare × with nothing to announce it. This one
|
|
7
|
+
* has all of that, and it is an ordinary file now — edit it to suit the app.
|
|
8
|
+
*
|
|
9
|
+
* import { ItemList } from './blocks/item-list.js';
|
|
10
|
+
*
|
|
11
|
+
* const list = ItemList({
|
|
12
|
+
* items: store.state.items,
|
|
13
|
+
* onChange: (items) => store.set({ items }),
|
|
14
|
+
* placeholder: 'Add a task',
|
|
15
|
+
* empty: { title: 'Nothing here yet', description: 'Add the first one above.' },
|
|
16
|
+
* });
|
|
17
|
+
* document.querySelector('#app').append(list.el);
|
|
18
|
+
*
|
|
19
|
+
* Items are `{ id, text, done }`. `onChange` gets the whole array back after
|
|
20
|
+
* every change, so the state lives in one place and this only draws it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const CSS = `
|
|
24
|
+
.il { display: flex; flex-direction: column; gap: var(--s3, 16px); }
|
|
25
|
+
.il-add { display: flex; gap: var(--s1, 8px); }
|
|
26
|
+
.il-add input {
|
|
27
|
+
flex: 1; min-width: 0;
|
|
28
|
+
padding: 12px var(--s2, 12px);
|
|
29
|
+
font: inherit; color: var(--text, #e7eaf0);
|
|
30
|
+
background: var(--surface, #14171c);
|
|
31
|
+
border: 1px solid var(--line, #232830); border-radius: var(--r, 16px);
|
|
32
|
+
}
|
|
33
|
+
.il-add input::placeholder { color: var(--muted, #8b93a3); }
|
|
34
|
+
.il-add input:focus-visible, .il button:focus-visible, .il input:focus-visible {
|
|
35
|
+
outline: 2px solid var(--accent, #2dd4bf); outline-offset: 2px;
|
|
36
|
+
}
|
|
37
|
+
.il-add button {
|
|
38
|
+
padding: 0 var(--s4, 24px); min-height: 44px;
|
|
39
|
+
font: 500 inherit; color: var(--accent-ink, #04110f); background: var(--accent, #2dd4bf);
|
|
40
|
+
border: 0; border-radius: var(--r, 16px); cursor: pointer;
|
|
41
|
+
transition: filter var(--fast, 160ms);
|
|
42
|
+
}
|
|
43
|
+
.il-add button:hover { filter: brightness(1.08); }
|
|
44
|
+
.il-add button:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
|
45
|
+
|
|
46
|
+
.il-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s1, 8px); }
|
|
47
|
+
.il-row {
|
|
48
|
+
display: flex; align-items: center; gap: var(--s2, 12px);
|
|
49
|
+
padding: var(--s2, 12px);
|
|
50
|
+
background: var(--surface, #14171c);
|
|
51
|
+
border: 1px solid var(--line, #232830); border-radius: var(--r, 16px);
|
|
52
|
+
}
|
|
53
|
+
.il-row:hover { border-color: color-mix(in oklab, var(--line, #232830) 60%, var(--accent, #2dd4bf)); }
|
|
54
|
+
.il-check { width: 20px; height: 20px; flex: none; accent-color: var(--accent, #2dd4bf); cursor: pointer; }
|
|
55
|
+
.il-text {
|
|
56
|
+
flex: 1; min-width: 0; padding: 4px 6px; margin: -4px -6px;
|
|
57
|
+
font: inherit; color: inherit; background: none; border: 1px solid transparent; border-radius: 8px;
|
|
58
|
+
text-align: left; cursor: text; overflow-wrap: anywhere;
|
|
59
|
+
}
|
|
60
|
+
.il-text:hover { border-color: var(--line, #232830); }
|
|
61
|
+
.il-row[data-done="true"] .il-text { color: var(--muted, #8b93a3); text-decoration: line-through; }
|
|
62
|
+
.il-edit {
|
|
63
|
+
flex: 1; min-width: 0; padding: 4px 6px; margin: -4px -6px;
|
|
64
|
+
font: inherit; color: var(--text, #e7eaf0); background: var(--bg, #0b0d10);
|
|
65
|
+
border: 1px solid var(--accent, #2dd4bf); border-radius: 8px;
|
|
66
|
+
}
|
|
67
|
+
.il-remove {
|
|
68
|
+
flex: none; width: 32px; height: 32px; display: grid; place-items: center;
|
|
69
|
+
font-size: 18px; line-height: 1; color: var(--muted, #8b93a3);
|
|
70
|
+
background: none; border: 0; border-radius: 8px; cursor: pointer;
|
|
71
|
+
transition: color var(--fast, 160ms), background var(--fast, 160ms);
|
|
72
|
+
}
|
|
73
|
+
.il-remove:hover { color: var(--bad, #ff8b95); background: color-mix(in oklab, var(--surface, #14171c) 70%, var(--bad, #ff8b95)); }
|
|
74
|
+
|
|
75
|
+
.il-empty { padding: var(--s5, 40px) var(--s3, 16px); text-align: center; }
|
|
76
|
+
.il-empty h2 { margin: 0 0 var(--s1, 8px); font-size: 1.05rem; }
|
|
77
|
+
.il-empty p { margin: 0; color: var(--muted, #8b93a3); }
|
|
78
|
+
.il-count { color: var(--muted, #8b93a3); font-size: .875rem; }
|
|
79
|
+
@media (prefers-reduced-motion: reduce) { .il * { transition: none !important; } }
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
let styled = false;
|
|
83
|
+
function styles() {
|
|
84
|
+
if (styled) return;
|
|
85
|
+
styled = true;
|
|
86
|
+
document.head.append(Object.assign(document.createElement('style'), { textContent: CSS }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const uid = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
90
|
+
|
|
91
|
+
export function ItemList({
|
|
92
|
+
items = [],
|
|
93
|
+
onChange = () => {},
|
|
94
|
+
placeholder = 'Add an item',
|
|
95
|
+
addLabel = 'Add',
|
|
96
|
+
empty = { title: 'Nothing here yet', description: 'Add the first one above.' },
|
|
97
|
+
} = {}) {
|
|
98
|
+
styles();
|
|
99
|
+
|
|
100
|
+
let rows = items.map((i) => ({ ...i }));
|
|
101
|
+
let editing = null; // id of the row being renamed
|
|
102
|
+
|
|
103
|
+
const el = document.createElement('div');
|
|
104
|
+
el.className = 'il';
|
|
105
|
+
|
|
106
|
+
// -- the add row ---------------------------------------------------------
|
|
107
|
+
const form = document.createElement('form');
|
|
108
|
+
form.className = 'il-add';
|
|
109
|
+
const input = document.createElement('input');
|
|
110
|
+
input.type = 'text';
|
|
111
|
+
input.placeholder = placeholder;
|
|
112
|
+
input.setAttribute('aria-label', placeholder);
|
|
113
|
+
input.autocomplete = 'off';
|
|
114
|
+
const add = document.createElement('button');
|
|
115
|
+
add.type = 'submit';
|
|
116
|
+
add.textContent = addLabel;
|
|
117
|
+
add.disabled = true;
|
|
118
|
+
form.append(input, add);
|
|
119
|
+
|
|
120
|
+
input.addEventListener('input', () => { add.disabled = !input.value.trim(); });
|
|
121
|
+
|
|
122
|
+
function submit() {
|
|
123
|
+
const text = input.value.trim();
|
|
124
|
+
if (!text) return;
|
|
125
|
+
rows = [...rows, { id: uid(), text, done: false }];
|
|
126
|
+
input.value = '';
|
|
127
|
+
add.disabled = true;
|
|
128
|
+
commit();
|
|
129
|
+
input.focus();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); submit(); });
|
|
133
|
+
|
|
134
|
+
// Enter is how this control is actually used, and a form's implicit submit
|
|
135
|
+
// is conditional on things outside this file — a disabled default button, a
|
|
136
|
+
// browser quirk, an ancestor that swallows the key. Handled outright.
|
|
137
|
+
input.addEventListener('keydown', (event) => {
|
|
138
|
+
if (event.key !== 'Enter' || event.isComposing) return;
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
submit();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const list = document.createElement('ul');
|
|
144
|
+
list.className = 'il-rows';
|
|
145
|
+
|
|
146
|
+
const count = document.createElement('p');
|
|
147
|
+
count.className = 'il-count';
|
|
148
|
+
count.setAttribute('aria-live', 'polite');
|
|
149
|
+
|
|
150
|
+
el.append(form, list, count);
|
|
151
|
+
|
|
152
|
+
function commit() {
|
|
153
|
+
draw();
|
|
154
|
+
onChange(rows.map((r) => ({ ...r })));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function row(item) {
|
|
158
|
+
const li = document.createElement('li');
|
|
159
|
+
li.className = 'il-row';
|
|
160
|
+
li.dataset.done = String(Boolean(item.done));
|
|
161
|
+
|
|
162
|
+
const check = document.createElement('input');
|
|
163
|
+
check.type = 'checkbox';
|
|
164
|
+
check.className = 'il-check';
|
|
165
|
+
check.checked = Boolean(item.done);
|
|
166
|
+
check.setAttribute('aria-label', `Mark "${item.text}" as ${item.done ? 'not done' : 'done'}`);
|
|
167
|
+
check.addEventListener('change', () => {
|
|
168
|
+
rows = rows.map((r) => (r.id === item.id ? { ...r, done: check.checked } : r));
|
|
169
|
+
commit();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
let body;
|
|
173
|
+
if (editing === item.id) {
|
|
174
|
+
// Renaming in place: Enter keeps it, Escape puts it back, and clicking
|
|
175
|
+
// away keeps it too — losing an edit to a stray click is infuriating.
|
|
176
|
+
body = document.createElement('input');
|
|
177
|
+
body.className = 'il-edit';
|
|
178
|
+
body.value = item.text;
|
|
179
|
+
body.setAttribute('aria-label', `Rename "${item.text}"`);
|
|
180
|
+
const keep = () => {
|
|
181
|
+
const text = body.value.trim();
|
|
182
|
+
editing = null;
|
|
183
|
+
if (!text || text === item.text) return draw();
|
|
184
|
+
rows = rows.map((r) => (r.id === item.id ? { ...r, text } : r));
|
|
185
|
+
commit();
|
|
186
|
+
};
|
|
187
|
+
body.addEventListener('keydown', (event) => {
|
|
188
|
+
if (event.key === 'Enter') { event.preventDefault(); keep(); }
|
|
189
|
+
if (event.key === 'Escape') { event.preventDefault(); editing = null; draw(); }
|
|
190
|
+
});
|
|
191
|
+
body.addEventListener('blur', keep);
|
|
192
|
+
queueMicrotask(() => { body.focus(); body.select(); });
|
|
193
|
+
} else {
|
|
194
|
+
body = document.createElement('button');
|
|
195
|
+
body.type = 'button';
|
|
196
|
+
body.className = 'il-text';
|
|
197
|
+
body.textContent = item.text;
|
|
198
|
+
body.setAttribute('aria-label', `Rename "${item.text}"`);
|
|
199
|
+
body.addEventListener('click', () => { editing = item.id; draw(); });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const remove = document.createElement('button');
|
|
203
|
+
remove.type = 'button';
|
|
204
|
+
remove.className = 'il-remove';
|
|
205
|
+
remove.textContent = '×';
|
|
206
|
+
remove.setAttribute('aria-label', `Remove "${item.text}"`);
|
|
207
|
+
remove.addEventListener('click', () => {
|
|
208
|
+
rows = rows.filter((r) => r.id !== item.id);
|
|
209
|
+
commit();
|
|
210
|
+
input.focus();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
li.append(check, body, remove);
|
|
214
|
+
return li;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function draw() {
|
|
218
|
+
list.replaceChildren();
|
|
219
|
+
if (!rows.length) {
|
|
220
|
+
const blank = document.createElement('li');
|
|
221
|
+
blank.className = 'il-empty';
|
|
222
|
+
const title = document.createElement('h2');
|
|
223
|
+
title.textContent = empty.title;
|
|
224
|
+
const description = document.createElement('p');
|
|
225
|
+
description.textContent = empty.description ?? '';
|
|
226
|
+
blank.append(title, description);
|
|
227
|
+
list.append(blank);
|
|
228
|
+
count.textContent = '';
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const item of rows) list.append(row(item));
|
|
232
|
+
const left = rows.filter((r) => !r.done).length;
|
|
233
|
+
count.textContent = `${left} of ${rows.length} left`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
draw();
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
el,
|
|
240
|
+
/** Redraw from a new array — call this when the state changes elsewhere. */
|
|
241
|
+
setItems(next = []) {
|
|
242
|
+
rows = next.map((i) => ({ ...i }));
|
|
243
|
+
editing = null;
|
|
244
|
+
draw();
|
|
245
|
+
},
|
|
246
|
+
get items() { return rows.map((r) => ({ ...r })); },
|
|
247
|
+
focus() { input.focus(); },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dialog that behaves like one: Escape closes it, focus is trapped inside
|
|
3
|
+
* while it is open and goes back to whatever opened it afterwards, and the
|
|
4
|
+
* page behind it cannot be scrolled or tabbed into.
|
|
5
|
+
*
|
|
6
|
+
* Built on the browser's own <dialog>, which does the trapping and the
|
|
7
|
+
* backdrop already. A hand-rolled div gets the look and none of the rest.
|
|
8
|
+
*
|
|
9
|
+
* import { Modal } from './blocks/modal.js';
|
|
10
|
+
*
|
|
11
|
+
* const confirm = Modal({
|
|
12
|
+
* title: 'Delete this list?',
|
|
13
|
+
* description: 'Everything in it goes too. This cannot be undone.',
|
|
14
|
+
* confirmLabel: 'Delete',
|
|
15
|
+
* danger: true,
|
|
16
|
+
* onConfirm: () => store.reset(),
|
|
17
|
+
* });
|
|
18
|
+
* document.body.append(confirm.el);
|
|
19
|
+
* button.addEventListener('click', () => confirm.open());
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CSS = `
|
|
23
|
+
.md {
|
|
24
|
+
width: min(440px, calc(100vw - 32px)); padding: 0;
|
|
25
|
+
color: var(--text, #e7eaf0); background: var(--surface, #14171c);
|
|
26
|
+
border: 1px solid var(--line, #232830); border-radius: var(--r, 16px);
|
|
27
|
+
box-shadow: 0 24px 60px rgb(0 0 0 / .5);
|
|
28
|
+
}
|
|
29
|
+
.md::backdrop { background: rgb(0 0 0 / .6); }
|
|
30
|
+
.md-body { padding: var(--s4, 24px); }
|
|
31
|
+
.md h2 { margin: 0 0 var(--s1, 8px); font-size: 1.1rem; }
|
|
32
|
+
.md p { margin: 0; color: var(--muted, #8b93a3); }
|
|
33
|
+
.md-actions {
|
|
34
|
+
display: flex; justify-content: flex-end; gap: var(--s1, 8px);
|
|
35
|
+
padding: var(--s2, 12px) var(--s4, 24px) var(--s4, 24px);
|
|
36
|
+
}
|
|
37
|
+
.md button {
|
|
38
|
+
min-height: 44px; padding: 0 var(--s4, 24px);
|
|
39
|
+
font: 500 inherit; border-radius: var(--r, 16px); cursor: pointer;
|
|
40
|
+
transition: filter var(--fast, 160ms), background var(--fast, 160ms);
|
|
41
|
+
}
|
|
42
|
+
.md-cancel {
|
|
43
|
+
color: var(--text, #e7eaf0); background: none;
|
|
44
|
+
border: 1px solid var(--line, #232830);
|
|
45
|
+
}
|
|
46
|
+
.md-cancel:hover { background: var(--bg, #0b0d10); }
|
|
47
|
+
.md-confirm { color: var(--accent-ink, #04110f); background: var(--accent, #2dd4bf); border: 0; }
|
|
48
|
+
.md-confirm:hover { filter: brightness(1.08); }
|
|
49
|
+
.md-confirm[data-danger="true"] { color: var(--bad-ink, #2a0b0e); background: var(--bad, #ff8b95); }
|
|
50
|
+
.md button:focus-visible { outline: 2px solid var(--accent, #2dd4bf); outline-offset: 2px; }
|
|
51
|
+
@media (prefers-reduced-motion: reduce) { .md button { transition: none; } }
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
let styled = false;
|
|
55
|
+
function styles() {
|
|
56
|
+
if (styled) return;
|
|
57
|
+
styled = true;
|
|
58
|
+
document.head.append(Object.assign(document.createElement('style'), { textContent: CSS }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let seq = 0;
|
|
62
|
+
|
|
63
|
+
export function Modal({
|
|
64
|
+
title = '',
|
|
65
|
+
description = '',
|
|
66
|
+
content = null, // an element, if the dialog is more than a question
|
|
67
|
+
confirmLabel = 'Confirm',
|
|
68
|
+
cancelLabel = 'Cancel',
|
|
69
|
+
danger = false,
|
|
70
|
+
onConfirm = () => {},
|
|
71
|
+
onCancel = () => {},
|
|
72
|
+
} = {}) {
|
|
73
|
+
styles();
|
|
74
|
+
|
|
75
|
+
const id = `md-${++seq}`;
|
|
76
|
+
const el = document.createElement('dialog');
|
|
77
|
+
el.className = 'md';
|
|
78
|
+
el.setAttribute('aria-labelledby', id);
|
|
79
|
+
|
|
80
|
+
const body = document.createElement('div');
|
|
81
|
+
body.className = 'md-body';
|
|
82
|
+
|
|
83
|
+
const heading = document.createElement('h2');
|
|
84
|
+
heading.id = id;
|
|
85
|
+
heading.textContent = title;
|
|
86
|
+
body.append(heading);
|
|
87
|
+
|
|
88
|
+
if (description) {
|
|
89
|
+
const p = document.createElement('p');
|
|
90
|
+
p.textContent = description;
|
|
91
|
+
body.append(p);
|
|
92
|
+
}
|
|
93
|
+
if (content) body.append(content);
|
|
94
|
+
|
|
95
|
+
const actions = document.createElement('div');
|
|
96
|
+
actions.className = 'md-actions';
|
|
97
|
+
|
|
98
|
+
const cancel = document.createElement('button');
|
|
99
|
+
cancel.type = 'button';
|
|
100
|
+
cancel.className = 'md-cancel';
|
|
101
|
+
cancel.textContent = cancelLabel;
|
|
102
|
+
cancel.addEventListener('click', () => close('cancel'));
|
|
103
|
+
|
|
104
|
+
const confirm = document.createElement('button');
|
|
105
|
+
confirm.type = 'button';
|
|
106
|
+
confirm.className = 'md-confirm';
|
|
107
|
+
confirm.dataset.danger = String(Boolean(danger));
|
|
108
|
+
confirm.textContent = confirmLabel;
|
|
109
|
+
confirm.addEventListener('click', () => close('confirm'));
|
|
110
|
+
|
|
111
|
+
actions.append(cancel, confirm);
|
|
112
|
+
el.append(body, actions);
|
|
113
|
+
|
|
114
|
+
// Escape fires the dialog's own cancel event; route it through the same
|
|
115
|
+
// path as the button so onCancel runs however it was dismissed.
|
|
116
|
+
el.addEventListener('cancel', (event) => { event.preventDefault(); close('cancel'); });
|
|
117
|
+
|
|
118
|
+
// A click on the backdrop lands on the dialog itself, never on its contents.
|
|
119
|
+
el.addEventListener('click', (event) => { if (event.target === el) close('cancel'); });
|
|
120
|
+
|
|
121
|
+
let opener = null;
|
|
122
|
+
|
|
123
|
+
function close(how) {
|
|
124
|
+
el.close();
|
|
125
|
+
opener?.focus?.();
|
|
126
|
+
opener = null;
|
|
127
|
+
if (how === 'confirm') onConfirm();
|
|
128
|
+
else onCancel();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
el,
|
|
133
|
+
open() {
|
|
134
|
+
opener = document.activeElement;
|
|
135
|
+
el.showModal();
|
|
136
|
+
confirm.focus();
|
|
137
|
+
},
|
|
138
|
+
close: () => close('cancel'),
|
|
139
|
+
get isOpen() { return el.open; },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State in one place, saved to localStorage, and the same in every tab.
|
|
3
|
+
*
|
|
4
|
+
* The three things a hand-written version gets wrong: it throws in private
|
|
5
|
+
* mode, it dies on a corrupt value written by an older version of the app,
|
|
6
|
+
* and it silently disagrees with itself when the app is open twice.
|
|
7
|
+
*
|
|
8
|
+
* import { createStore } from './blocks/store.js';
|
|
9
|
+
*
|
|
10
|
+
* const store = createStore('tide', { items: [], filter: 'all' });
|
|
11
|
+
* store.subscribe(render); // called now and after every change
|
|
12
|
+
* store.update((s) => ({ items: [...s.items, task] }));
|
|
13
|
+
* store.state.items
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function createStore(key, initial = {}) {
|
|
17
|
+
let state = { ...initial, ...read(key, initial) };
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
|
|
20
|
+
function announce() {
|
|
21
|
+
for (const listener of [...listeners]) {
|
|
22
|
+
try {
|
|
23
|
+
listener(state);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// One broken listener must not stop the others, or half the page
|
|
26
|
+
// stops redrawing for a reason nobody can see.
|
|
27
|
+
console.error('store listener failed', err);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The app open in two tabs is one app. Without this the second tab keeps
|
|
33
|
+
// showing what the first one deleted, then overwrites it on the next save.
|
|
34
|
+
if (typeof window !== 'undefined') {
|
|
35
|
+
window.addEventListener('storage', (event) => {
|
|
36
|
+
if (event.key !== key) return;
|
|
37
|
+
state = { ...initial, ...read(key, initial) };
|
|
38
|
+
announce();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
get state() { return state; },
|
|
44
|
+
|
|
45
|
+
/** Replace some of the state. Saves, then tells everyone. */
|
|
46
|
+
set(patch) {
|
|
47
|
+
state = { ...state, ...patch };
|
|
48
|
+
write(key, state);
|
|
49
|
+
announce();
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/** The same, from a function of the current state. */
|
|
53
|
+
update(fn) {
|
|
54
|
+
this.set(fn(state) ?? {});
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/** Called straight away with the current state, and after every change. */
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
listener(state);
|
|
61
|
+
return () => listeners.delete(listener);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** Back to how it started, on disk as well as in memory. */
|
|
65
|
+
reset() {
|
|
66
|
+
state = { ...initial };
|
|
67
|
+
write(key, state);
|
|
68
|
+
announce();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function read(key, fallback) {
|
|
74
|
+
try {
|
|
75
|
+
const raw = localStorage.getItem(key);
|
|
76
|
+
if (!raw) return {};
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
// An array or a string where an object belongs is a store written by an
|
|
79
|
+
// older version of this app. Start again rather than crash on load.
|
|
80
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : { ...fallback };
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function write(key, value) {
|
|
87
|
+
try {
|
|
88
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
89
|
+
} catch {
|
|
90
|
+
// Private mode, or the quota is full. Losing the save is survivable;
|
|
91
|
+
// throwing here would take the whole page down with it.
|
|
92
|
+
}
|
|
93
|
+
}
|