@christianirack/brainllm-runtime 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +547 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +260 -0
- package/brain-guard.js +242 -0
- package/brain-hydrate.js +72 -0
- package/brain-i18n.js +111 -0
- package/brain-inspect.js +745 -0
- package/brain-island.js +106 -0
- package/brain-lazy.js +88 -0
- package/brain-mcp.js +58 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +353 -0
- package/brain-rpc.js +17 -0
- package/brain-schema.js +69 -0
- package/brain-wc.js +69 -0
- package/brain-why.js +198 -0
- package/brain-worker.js +66 -0
- package/brain.js +421 -0
- package/package.json +20 -4
- package/server/server.mjs +882 -0
- package/shim/dom.js +448 -0
- package/shim/probe.js +111 -0
- package/sw/brain-sw.js +103 -0
- package/themes/contrast.cssbrain +39 -0
- package/themes/dark.cssbrain +39 -0
- package/themes/glass.cssbrain +39 -0
- package/themes/paper.cssbrain +39 -0
- package/ui/button.brain +31 -0
- package/ui/center.brain +11 -0
- package/ui/checkbox.brain +13 -0
- package/ui/container.brain +11 -0
- package/ui/dialog.brain +29 -0
- package/ui/error-state.brain +53 -0
- package/ui/field.brain +34 -0
- package/ui/grid.brain +11 -0
- package/ui/input.brain +23 -0
- package/ui/lang-switch.brain +38 -0
- package/ui/menu.brain +64 -0
- package/ui/row.brain +12 -0
- package/ui/select.brain +29 -0
- package/ui/sidebar.brain +17 -0
- package/ui/spacer.brain +12 -0
- package/ui/stack.brain +10 -0
- package/ui/table.brain +73 -0
- package/ui/tabs.brain +33 -0
- package/ui/toast.brain +45 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* BRAINLLM theme — high contrast (AAA text). Written by `brain theme contrast`; tokens are the only source of color and spacing. */
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: light;
|
|
4
|
+
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px; --space-6: 32px;
|
|
5
|
+
--radius: 14px; --radius-sm: 10px; --radius-lg: 20px;
|
|
6
|
+
/* AA text pairs (>= 4.5:1, verified by runtime/test/themes.test.js): fg|muted|accent|ok|bad on bg or surface;
|
|
7
|
+
on-accent on accent|ok|bad. --line is for borders only, never for text. --scrim is the backdrop of modals. */
|
|
8
|
+
--fg: #000000; --muted: #333333; --bg: #ffffff; --surface: #ffffff; --line: #000000;
|
|
9
|
+
--scrim: rgb(0 0 0 / .7);
|
|
10
|
+
--accent: #0033a0; --ok: #00552b; --bad: #8b0000; --on-accent: #ffffff;
|
|
11
|
+
--font: "Geist", "SF Pro Text", ui-sans-serif, sans-serif;
|
|
12
|
+
--glass: #ffffff; --glass-edge: #000000;
|
|
13
|
+
--shadow: none;
|
|
14
|
+
}
|
|
15
|
+
@layer reset {
|
|
16
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
17
|
+
body {
|
|
18
|
+
margin: 0; min-height: 100vh; font-family: var(--font); color: var(--fg); line-height: 1.5;
|
|
19
|
+
-webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums;
|
|
20
|
+
background:
|
|
21
|
+
radial-gradient(1100px 600px at 12% -10%, #ffffff, transparent 60%),
|
|
22
|
+
radial-gradient(900px 600px at 110% 10%, rgba(0, 0, 0, 0), transparent 60%),
|
|
23
|
+
var(--bg);
|
|
24
|
+
background-attachment: fixed;
|
|
25
|
+
}
|
|
26
|
+
h1, h2, h3 { line-height: 1.1; letter-spacing: -.02em; margin: 0; }
|
|
27
|
+
a { color: var(--accent); }
|
|
28
|
+
button, input, textarea, select { font: inherit; color: inherit; }
|
|
29
|
+
button {
|
|
30
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
31
|
+
padding: var(--space-2) var(--space-4); cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
input, textarea, select {
|
|
34
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
35
|
+
padding: var(--space-2) var(--space-3);
|
|
36
|
+
}
|
|
37
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
38
|
+
@media (prefers-reduced-motion: no-preference) { button { transition: transform .15s ease; } button:active { transform: scale(.98); } }
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* BRAINLLM theme — dark glass. Written by `brain theme dark`; tokens are the only source of color and spacing. */
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: dark;
|
|
4
|
+
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px; --space-6: 32px;
|
|
5
|
+
--radius: 14px; --radius-sm: 10px; --radius-lg: 20px;
|
|
6
|
+
/* AA text pairs (>= 4.5:1, verified by runtime/test/themes.test.js): fg|muted|accent|ok|bad on bg or surface;
|
|
7
|
+
on-accent on accent|ok|bad. --line is for borders only, never for text. --scrim is the backdrop of modals. */
|
|
8
|
+
--fg: #f2f2f7; --muted: #a1a1aa; --bg: #0f1115; --surface: #1b1e24; --line: #3a3f48;
|
|
9
|
+
--scrim: rgb(0 0 0 / .6);
|
|
10
|
+
--accent: #5aa9ff; --ok: #4cc38a; --bad: #ff7a6b; --on-accent: #0b0d10;
|
|
11
|
+
--font: "Geist", "SF Pro Text", ui-sans-serif, sans-serif;
|
|
12
|
+
--glass: rgba(30, 33, 40, .72); --glass-edge: rgba(255, 255, 255, .08);
|
|
13
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 32px -12px rgba(0, 0, 0, .6);
|
|
14
|
+
}
|
|
15
|
+
@layer reset {
|
|
16
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
17
|
+
body {
|
|
18
|
+
margin: 0; min-height: 100vh; font-family: var(--font); color: var(--fg); line-height: 1.5;
|
|
19
|
+
-webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums;
|
|
20
|
+
background:
|
|
21
|
+
radial-gradient(1100px 600px at 12% -10%, #1d2230, transparent 60%),
|
|
22
|
+
radial-gradient(900px 600px at 110% 10%, rgba(90, 169, 255, .06), transparent 60%),
|
|
23
|
+
var(--bg);
|
|
24
|
+
background-attachment: fixed;
|
|
25
|
+
}
|
|
26
|
+
h1, h2, h3 { line-height: 1.1; letter-spacing: -.02em; margin: 0; }
|
|
27
|
+
a { color: var(--accent); }
|
|
28
|
+
button, input, textarea, select { font: inherit; color: inherit; }
|
|
29
|
+
button {
|
|
30
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
31
|
+
padding: var(--space-2) var(--space-4); cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
input, textarea, select {
|
|
34
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
35
|
+
padding: var(--space-2) var(--space-3);
|
|
36
|
+
}
|
|
37
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
38
|
+
@media (prefers-reduced-motion: no-preference) { button { transition: transform .15s ease; } button:active { transform: scale(.98); } }
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* BRAINLLM theme — white glass (the starter theme). Written by `brain theme glass`; tokens are the only source of color and spacing. */
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: light;
|
|
4
|
+
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px; --space-6: 32px;
|
|
5
|
+
--radius: 14px; --radius-sm: 10px; --radius-lg: 20px;
|
|
6
|
+
/* AA text pairs (>= 4.5:1, verified by runtime/test/themes.test.js): fg|muted|accent|ok|bad on bg or surface;
|
|
7
|
+
on-accent on accent|ok|bad. --line is for borders only, never for text. --scrim is the backdrop of modals. */
|
|
8
|
+
--fg: #1d1d1f; --muted: #55595e; --bg: #f5f5f7; --surface: #ffffff; --line: #d2d2d7;
|
|
9
|
+
--scrim: rgb(15 23 42 / .45);
|
|
10
|
+
--accent: #0062c4; --ok: #16794a; --bad: #c42b1c; --on-accent: #ffffff;
|
|
11
|
+
--font: "Geist", "SF Pro Text", ui-sans-serif, sans-serif;
|
|
12
|
+
--glass: rgba(255, 255, 255, .72); --glass-edge: rgba(255, 255, 255, .9);
|
|
13
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, .04), 0 12px 32px -12px rgba(0, 0, 0, .14);
|
|
14
|
+
}
|
|
15
|
+
@layer reset {
|
|
16
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
17
|
+
body {
|
|
18
|
+
margin: 0; min-height: 100vh; font-family: var(--font); color: var(--fg); line-height: 1.5;
|
|
19
|
+
-webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums;
|
|
20
|
+
background:
|
|
21
|
+
radial-gradient(1100px 600px at 12% -10%, #ffffff, transparent 60%),
|
|
22
|
+
radial-gradient(900px 600px at 110% 10%, rgba(0, 0, 0, .035), transparent 60%),
|
|
23
|
+
var(--bg);
|
|
24
|
+
background-attachment: fixed;
|
|
25
|
+
}
|
|
26
|
+
h1, h2, h3 { line-height: 1.1; letter-spacing: -.02em; margin: 0; }
|
|
27
|
+
a { color: var(--accent); }
|
|
28
|
+
button, input, textarea, select { font: inherit; color: inherit; }
|
|
29
|
+
button {
|
|
30
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
31
|
+
padding: var(--space-2) var(--space-4); cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
input, textarea, select {
|
|
34
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
35
|
+
padding: var(--space-2) var(--space-3);
|
|
36
|
+
}
|
|
37
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
38
|
+
@media (prefers-reduced-motion: no-preference) { button { transition: transform .15s ease; } button:active { transform: scale(.98); } }
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* BRAINLLM theme — warm paper. Written by `brain theme paper`; tokens are the only source of color and spacing. */
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: light;
|
|
4
|
+
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px; --space-6: 32px;
|
|
5
|
+
--radius: 14px; --radius-sm: 10px; --radius-lg: 20px;
|
|
6
|
+
/* AA text pairs (>= 4.5:1, verified by runtime/test/themes.test.js): fg|muted|accent|ok|bad on bg or surface;
|
|
7
|
+
on-accent on accent|ok|bad. --line is for borders only, never for text. --scrim is the backdrop of modals. */
|
|
8
|
+
--fg: #24211c; --muted: #5c564c; --bg: #f6f3ee; --surface: #fffdf9; --line: #ddd6ca;
|
|
9
|
+
--scrim: rgb(36 33 28 / .45);
|
|
10
|
+
--accent: #8a4b0f; --ok: #2f6b2f; --bad: #a3271b; --on-accent: #ffffff;
|
|
11
|
+
--font: "Geist", "SF Pro Text", ui-sans-serif, sans-serif;
|
|
12
|
+
--glass: rgba(255, 253, 249, .78); --glass-edge: rgba(255, 255, 255, .9);
|
|
13
|
+
--shadow: 0 1px 2px rgba(36, 33, 28, .05), 0 12px 32px -12px rgba(36, 33, 28, .16);
|
|
14
|
+
}
|
|
15
|
+
@layer reset {
|
|
16
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
17
|
+
body {
|
|
18
|
+
margin: 0; min-height: 100vh; font-family: var(--font); color: var(--fg); line-height: 1.5;
|
|
19
|
+
-webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums;
|
|
20
|
+
background:
|
|
21
|
+
radial-gradient(1100px 600px at 12% -10%, #fffdf9, transparent 60%),
|
|
22
|
+
radial-gradient(900px 600px at 110% 10%, rgba(138, 75, 15, .04), transparent 60%),
|
|
23
|
+
var(--bg);
|
|
24
|
+
background-attachment: fixed;
|
|
25
|
+
}
|
|
26
|
+
h1, h2, h3 { line-height: 1.1; letter-spacing: -.02em; margin: 0; }
|
|
27
|
+
a { color: var(--accent); }
|
|
28
|
+
button, input, textarea, select { font: inherit; color: inherit; }
|
|
29
|
+
button {
|
|
30
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
31
|
+
padding: var(--space-2) var(--space-4); cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
input, textarea, select {
|
|
34
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius-sm);
|
|
35
|
+
padding: var(--space-2) var(--space-3);
|
|
36
|
+
}
|
|
37
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
38
|
+
@media (prefers-reduced-motion: no-preference) { button { transition: transform .15s ease; } button:active { transform: scale(.98); } }
|
|
39
|
+
}
|
package/ui/button.brain
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<meta props="variant:str='primary' type:str='button' busy:bool=false disabled:bool=false label:str?" emits="click">
|
|
2
|
+
<button
|
|
3
|
+
type={type}
|
|
4
|
+
class:ghost={variant === 'ghost'}
|
|
5
|
+
class:danger={variant === 'danger'}
|
|
6
|
+
disabled={disabled || busy}
|
|
7
|
+
aria-busy={busy ? 'true' : null}
|
|
8
|
+
aria-label={label}
|
|
9
|
+
on:click={emit('click')}
|
|
10
|
+
>
|
|
11
|
+
{#if busy}<span class="spin" aria-hidden="true"></span>{/if}
|
|
12
|
+
<slot />
|
|
13
|
+
</button>
|
|
14
|
+
<style>
|
|
15
|
+
button {
|
|
16
|
+
display: inline-flex; align-items: center; gap: var(--space-2);
|
|
17
|
+
padding: var(--space-2) var(--space-4); min-height: 40px;
|
|
18
|
+
font: 600 15px/1.2 var(--font); border-radius: var(--radius-sm); cursor: pointer;
|
|
19
|
+
color: var(--on-accent); background: var(--accent); border: 1px solid var(--accent);
|
|
20
|
+
box-shadow: var(--shadow);
|
|
21
|
+
}
|
|
22
|
+
button.ghost { color: var(--fg); background: var(--glass); border-color: var(--glass-edge); }
|
|
23
|
+
button.danger { color: var(--on-accent); background: var(--bad); border-color: var(--bad); }
|
|
24
|
+
button:disabled { cursor: not-allowed; filter: grayscale(.4); }
|
|
25
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
26
|
+
.spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; }
|
|
27
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
28
|
+
.spin { animation: spin .7s linear infinite; }
|
|
29
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
30
|
+
}
|
|
31
|
+
</style>
|
package/ui/center.brain
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<meta props="max:str='60ch' gutter:str='4' text:bool=false">
|
|
2
|
+
<script>
|
|
3
|
+
// a centered column with a maximum width and side gutters (a token step); text: also center the text
|
|
4
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
5
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
6
|
+
</script>
|
|
7
|
+
<div class="center" class:text={text} style:max-width={max} style:padding-inline={sp(gutter)}><slot /></div>
|
|
8
|
+
<style>
|
|
9
|
+
.center { box-sizing: content-box; margin-inline: auto; }
|
|
10
|
+
.center.text { text-align: center; }
|
|
11
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<meta props="label:str checked:bool~ name:str? disabled:bool=false error:str?">
|
|
2
|
+
<div class="check">
|
|
3
|
+
<label>
|
|
4
|
+
<input type="checkbox" name={name} disabled={disabled} aria-invalid={error ? 'true' : null} bind:checked={checked}>
|
|
5
|
+
<span>{label}</span>
|
|
6
|
+
</label>
|
|
7
|
+
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
8
|
+
</div>
|
|
9
|
+
<style>
|
|
10
|
+
label { display: inline-flex; align-items: center; gap: var(--space-2); font: 15px/1.4 var(--font); color: var(--fg); cursor: pointer; }
|
|
11
|
+
input { width: 18px; height: 18px; accent-color: var(--accent); }
|
|
12
|
+
.error { font-size: 13px; color: var(--bad); margin: var(--space-1) 0 0; }
|
|
13
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<meta props="size:str='lg' gutter:str='4'">
|
|
2
|
+
<script>
|
|
3
|
+
// the page's content width: sm 40rem · md 56rem · lg 72rem · xl 90rem · full; gutters are a token step
|
|
4
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
5
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
6
|
+
const W = { sm: '40rem', md: '56rem', lg: '72rem', xl: '90rem', full: 'none' }
|
|
7
|
+
</script>
|
|
8
|
+
<div class="container" style:max-width={W[size] || W.lg} style:padding-inline={sp(gutter)}><slot /></div>
|
|
9
|
+
<style>
|
|
10
|
+
.container { width: 100%; box-sizing: border-box; margin-inline: auto; }
|
|
11
|
+
</style>
|
package/ui/dialog.brain
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<meta props="title:str closeLabel:str='Close'" emits="close" expose="open close">
|
|
2
|
+
<script>
|
|
3
|
+
let dlg = ref()
|
|
4
|
+
// native modal <dialog>: focus moves inside, the rest of the page is inert, Escape closes
|
|
5
|
+
const open = () => { if (!dlg.open) dlg.showModal() }
|
|
6
|
+
const close = () => { if (dlg.open) dlg.close() }
|
|
7
|
+
</script>
|
|
8
|
+
<dialog bind:ref={dlg} aria-labelledby="dlg-title" on:close={emit('close')}>
|
|
9
|
+
<header>
|
|
10
|
+
<h2 id="dlg-title">{title}</h2>
|
|
11
|
+
<button type="button" class="x" aria-label={closeLabel} on:click={close}>×</button>
|
|
12
|
+
</header>
|
|
13
|
+
<div class="body"><slot /></div>
|
|
14
|
+
<footer><slot name="footer" /></footer>
|
|
15
|
+
</dialog>
|
|
16
|
+
<style>
|
|
17
|
+
dialog {
|
|
18
|
+
width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--glass-edge);
|
|
19
|
+
border-radius: var(--radius-lg); color: var(--fg); background: var(--glass); box-shadow: var(--shadow);
|
|
20
|
+
backdrop-filter: blur(24px) saturate(1.4);
|
|
21
|
+
}
|
|
22
|
+
dialog::backdrop { background: var(--scrim); backdrop-filter: blur(4px); }
|
|
23
|
+
header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-4) 0; }
|
|
24
|
+
h2 { font: 650 18px/1.3 var(--font); margin: 0; }
|
|
25
|
+
.x { font-size: 22px; line-height: 1; width: 36px; height: 36px; border: 0; border-radius: var(--radius-sm); color: var(--muted); background: var(--surface); cursor: pointer; }
|
|
26
|
+
.body { padding: var(--space-4); }
|
|
27
|
+
footer { display: flex; justify-content: flex-end; gap: var(--space-2); padding: 0 var(--space-4) var(--space-4); }
|
|
28
|
+
footer:empty { display: none; }
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<meta props="error:any retry:fn? login:str='/login'">
|
|
2
|
+
<script>
|
|
3
|
+
// One message and one action per error kind (INTERNALS §40.2), for {:catch e} and failed resources. Offline and
|
|
4
|
+
// network errors retry by themselves when the connection comes back; 429 retries after its Retry-After. A cancelled
|
|
5
|
+
// request (kind 'aborted') shows nothing.
|
|
6
|
+
const text = (e) => {
|
|
7
|
+
if (!e) return ''
|
|
8
|
+
if (e.kind === 'offline') return 'You are offline.'
|
|
9
|
+
if (e.kind === 'network') return 'The connection was lost.'
|
|
10
|
+
if (e.kind === 'timeout') return 'The server took too long to answer.'
|
|
11
|
+
if (e.kind === 'schema') return 'The server sent something unexpected.'
|
|
12
|
+
if (e.status === 401) return 'Sign in to continue.'
|
|
13
|
+
if (e.status === 403) return e.message && e.message !== 'Forbidden' ? 'You do not have permission: ' + e.message : 'You do not have permission to see this.'
|
|
14
|
+
if (e.status === 404) return 'Not found.'
|
|
15
|
+
if (e.status === 422) return 'Check the highlighted fields.'
|
|
16
|
+
if (e.status === 429) return 'Too many requests. Trying again in ' + (e.retryAfter || 30) + ' seconds.'
|
|
17
|
+
return 'Something went wrong on our side.'
|
|
18
|
+
}
|
|
19
|
+
const canRetry = (e) => !!retry && !!e && (e.kind !== 'http' || e.status >= 500)
|
|
20
|
+
const signIn = () => login + (globalThis.location ? '?next=' + encodeURIComponent(location.pathname + location.search) : '')
|
|
21
|
+
fx(() => {
|
|
22
|
+
if (!retry || !error || (error.kind !== 'offline' && error.kind !== 'network') || !globalThis.addEventListener) return // no window events while the server renders
|
|
23
|
+
const again = () => retry()
|
|
24
|
+
globalThis.addEventListener('online', again)
|
|
25
|
+
return () => globalThis.removeEventListener('online', again)
|
|
26
|
+
})
|
|
27
|
+
fx(() => {
|
|
28
|
+
if (!retry || !error || error.status !== 429) return
|
|
29
|
+
const t = setTimeout(() => retry(), (error.retryAfter || 30) * 1000)
|
|
30
|
+
return () => clearTimeout(t)
|
|
31
|
+
})
|
|
32
|
+
</script>
|
|
33
|
+
{#if error && error.kind !== 'aborted'}
|
|
34
|
+
<div class="error-state" role="alert">
|
|
35
|
+
<p>{text(error)}</p>
|
|
36
|
+
{#if error && error.status === 401}<a href={signIn()}>Sign in</a>{/if}
|
|
37
|
+
{#if canRetry(error)}<button type="button" on:click={retry()}>Retry</button>{/if}
|
|
38
|
+
{#if error && error.id && error.status >= 500}<small>Reference: {error.id}</small>{/if}
|
|
39
|
+
</div>
|
|
40
|
+
{/if}
|
|
41
|
+
<style>
|
|
42
|
+
.error-state {
|
|
43
|
+
display: grid; justify-items: start; gap: var(--space-2); padding: var(--space-4); font: 14px/1.5 var(--font);
|
|
44
|
+
color: var(--fg); background: var(--glass); border: 1px solid var(--glass-edge); border-radius: var(--radius);
|
|
45
|
+
}
|
|
46
|
+
p { margin: 0; }
|
|
47
|
+
a { color: var(--accent); }
|
|
48
|
+
button {
|
|
49
|
+
font: inherit; padding: var(--space-1) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
50
|
+
color: var(--fg); background: var(--surface); cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
small { color: var(--muted); }
|
|
53
|
+
</style>
|
package/ui/field.brain
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<meta props="label:str error:str? help:str?">
|
|
2
|
+
<script>
|
|
3
|
+
let box = ref()
|
|
4
|
+
let hint = ref()
|
|
5
|
+
let err = ref()
|
|
6
|
+
// links the slotted control to its help and error text (aria-describedby / aria-invalid)
|
|
7
|
+
fx(() => {
|
|
8
|
+
if (!box || !hint || !err) return
|
|
9
|
+
const c = box.querySelector('input, select, textarea')
|
|
10
|
+
if (!c) return
|
|
11
|
+
const ids = []
|
|
12
|
+
if (help) ids.push(hint.id)
|
|
13
|
+
if (error) ids.push(err.id)
|
|
14
|
+
if (ids.length) c.setAttribute('aria-describedby', ids.join(' '))
|
|
15
|
+
else c.removeAttribute('aria-describedby')
|
|
16
|
+
if (error) c.setAttribute('aria-invalid', 'true')
|
|
17
|
+
else c.removeAttribute('aria-invalid')
|
|
18
|
+
})
|
|
19
|
+
</script>
|
|
20
|
+
<div class="field" bind:ref={box}>
|
|
21
|
+
<label>
|
|
22
|
+
<span class="label">{label}</span>
|
|
23
|
+
<slot />
|
|
24
|
+
</label>
|
|
25
|
+
<p id="hint" class="help" bind:ref={hint} hidden={!help}>{help}</p>
|
|
26
|
+
<p id="err" class="error" role="alert" bind:ref={err} hidden={!error}>{error}</p>
|
|
27
|
+
</div>
|
|
28
|
+
<style>
|
|
29
|
+
.field { display: grid; gap: var(--space-1); }
|
|
30
|
+
label { display: grid; gap: var(--space-1); }
|
|
31
|
+
.label { font: 600 14px/1.3 var(--font); color: var(--fg); }
|
|
32
|
+
.help { font-size: 13px; color: var(--muted); margin: 0; }
|
|
33
|
+
.error { font-size: 13px; color: var(--bad); margin: 0; }
|
|
34
|
+
</style>
|
package/ui/grid.brain
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<meta props="gap:str='4' cols:num? min:str='16rem'">
|
|
2
|
+
<script>
|
|
3
|
+
// equal columns: `cols` fixed, else as many as fit with a minimum width (auto-fit, one column on phones)
|
|
4
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
5
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
6
|
+
const tpl = (c, m) => (c ? 'repeat(' + c + ', minmax(0, 1fr))' : 'repeat(auto-fit, minmax(min(' + m + ', 100%), 1fr))')
|
|
7
|
+
</script>
|
|
8
|
+
<div class="grid" style:gap={sp(gap)} style:grid-template-columns={tpl(cols, min)}><slot /></div>
|
|
9
|
+
<style>
|
|
10
|
+
.grid { display: grid; min-width: 0; }
|
|
11
|
+
</style>
|
package/ui/input.brain
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<meta props="label:str value:str~ type:str='text' name:str? placeholder:str? autocomplete:str? required:bool=false disabled:bool=false error:str? help:str?">
|
|
2
|
+
<Field label={label} error={error} help={help}>
|
|
3
|
+
<input
|
|
4
|
+
type={type}
|
|
5
|
+
name={name}
|
|
6
|
+
placeholder={placeholder}
|
|
7
|
+
autocomplete={autocomplete}
|
|
8
|
+
required={required}
|
|
9
|
+
disabled={disabled}
|
|
10
|
+
aria-label={label}
|
|
11
|
+
bind:value={value}
|
|
12
|
+
>
|
|
13
|
+
</Field>
|
|
14
|
+
<style>
|
|
15
|
+
input {
|
|
16
|
+
width: 100%; min-height: 40px; padding: var(--space-2) var(--space-3);
|
|
17
|
+
font: 15px/1.4 var(--font); color: var(--fg); background: var(--surface);
|
|
18
|
+
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
19
|
+
}
|
|
20
|
+
input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
21
|
+
input[aria-invalid='true'] { border-color: var(--bad); }
|
|
22
|
+
input:disabled { color: var(--muted); }
|
|
23
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<meta props="label:str='Language'">
|
|
2
|
+
<script>
|
|
3
|
+
// links to this same page in every language of the site (none with a single language); each name in itself
|
|
4
|
+
const langs = alternates()
|
|
5
|
+
// the browser prefers another language the site has: offered once, never an automatic redirect. A ui/ component has
|
|
6
|
+
// no state (B0116): closing hides the hint's own element and remembers the answer.
|
|
7
|
+
const hint = suggest()
|
|
8
|
+
const close = (e) => {
|
|
9
|
+
dismiss()
|
|
10
|
+
e.target.parentNode.setAttribute('hidden', '')
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
{#if langs.length}
|
|
14
|
+
<nav class="lang" aria-label={label}>
|
|
15
|
+
<ul>
|
|
16
|
+
{#each langs as l (l.locale)}
|
|
17
|
+
<li><a href={l.href} hreflang={l.locale} lang={l.locale} aria-current={l.current ? 'page' : null}>{l.name}</a></li>
|
|
18
|
+
{/each}
|
|
19
|
+
</ul>
|
|
20
|
+
{#if hint}
|
|
21
|
+
<p class="hint" lang={hint.locale}>
|
|
22
|
+
<a href={hint.href} hreflang={hint.locale} on:click={dismiss()}>{hint.name}</a>
|
|
23
|
+
<button type="button" aria-label="Dismiss" on:click={close}>×</button>
|
|
24
|
+
</p>
|
|
25
|
+
{/if}
|
|
26
|
+
</nav>
|
|
27
|
+
{/if}
|
|
28
|
+
<style>
|
|
29
|
+
.lang { display: flex; align-items: center; gap: var(--space-3); font: 14px/1.3 var(--font); }
|
|
30
|
+
ul { display: flex; gap: var(--space-1); list-style: none; margin: 0; padding: 0; }
|
|
31
|
+
a { color: var(--muted); text-decoration: none; padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); }
|
|
32
|
+
a[aria-current='page'] { color: var(--fg); background: var(--surface); }
|
|
33
|
+
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
34
|
+
.hint { display: flex; align-items: center; gap: var(--space-1); margin: 0; padding: var(--space-1) var(--space-2); border: 1px solid var(--glass-edge); border-radius: var(--radius-sm); background: var(--glass); }
|
|
35
|
+
.hint a { color: var(--accent); }
|
|
36
|
+
.hint[hidden] { display: none; } /* closed: display:flex above would otherwise win over [hidden] */
|
|
37
|
+
button { border: 0; background: transparent; color: var(--muted); font-size: 16px; cursor: pointer; }
|
|
38
|
+
</style>
|
package/ui/menu.brain
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<meta props="label:str items:list" emits="select:obj">
|
|
2
|
+
<script>
|
|
3
|
+
// items: [{ id: 'edit', label: 'Edit', disabled?: true }]
|
|
4
|
+
let open = sig(false)
|
|
5
|
+
let pop = ref()
|
|
6
|
+
let btn = ref()
|
|
7
|
+
const enabled = () => [...pop.querySelectorAll('[role=menuitem]:not([disabled])')]
|
|
8
|
+
const choose = (it) => {
|
|
9
|
+
open = false
|
|
10
|
+
btn.focus()
|
|
11
|
+
emit('select', it)
|
|
12
|
+
}
|
|
13
|
+
const keys = (e) => {
|
|
14
|
+
const all = enabled()
|
|
15
|
+
const i = all.indexOf(e.target)
|
|
16
|
+
if (e.key === 'Escape') {
|
|
17
|
+
open = false
|
|
18
|
+
btn.focus()
|
|
19
|
+
} else if (e.key === 'ArrowDown' && all.length) {
|
|
20
|
+
e.preventDefault()
|
|
21
|
+
all[(i + 1) % all.length].focus()
|
|
22
|
+
} else if (e.key === 'ArrowUp' && all.length) {
|
|
23
|
+
e.preventDefault()
|
|
24
|
+
all[(i - 1 + all.length) % all.length].focus()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
fx(() => {
|
|
28
|
+
if (!open || !pop) return
|
|
29
|
+
// next frame: the popup is visible (hidden removed) only after this update is painted
|
|
30
|
+
const h = requestAnimationFrame(() => {
|
|
31
|
+
const all = enabled()
|
|
32
|
+
if (all.length) all[0].focus()
|
|
33
|
+
})
|
|
34
|
+
const out = (e) => {
|
|
35
|
+
if (!pop.contains(e.target) && !btn.contains(e.target)) open = false
|
|
36
|
+
}
|
|
37
|
+
pop.ownerDocument.addEventListener('pointerdown', out)
|
|
38
|
+
return () => {
|
|
39
|
+
cancelAnimationFrame(h)
|
|
40
|
+
pop.ownerDocument.removeEventListener('pointerdown', out)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
</script>
|
|
44
|
+
<div class="menu">
|
|
45
|
+
<button type="button" bind:ref={btn} aria-haspopup="menu" aria-expanded={open ? 'true' : 'false'} on:click={open = !open}>{label}</button>
|
|
46
|
+
<div class="pop" role="menu" aria-label={label} bind:ref={pop} hidden={!open} on:keydown={keys}>
|
|
47
|
+
{#each items as it (it.id)}
|
|
48
|
+
<button type="button" role="menuitem" tabindex="-1" disabled={it.disabled} on:click={choose(it)}>{it.label}</button>
|
|
49
|
+
{/each}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<style>
|
|
53
|
+
.menu { position: relative; display: inline-block; }
|
|
54
|
+
.menu > button { font: 600 14px/1.2 var(--font); padding: var(--space-2) var(--space-3); border: 1px solid var(--glass-edge); border-radius: var(--radius-sm); color: var(--fg); background: var(--glass); cursor: pointer; }
|
|
55
|
+
.pop {
|
|
56
|
+
position: absolute; top: calc(100% + 6px); left: 0; min-width: 180px; display: grid; padding: var(--space-1); z-index: 50;
|
|
57
|
+
border: 1px solid var(--glass-edge); border-radius: var(--radius); background: var(--glass); box-shadow: var(--shadow); backdrop-filter: blur(20px) saturate(1.4);
|
|
58
|
+
}
|
|
59
|
+
.pop[hidden] { display: none; }
|
|
60
|
+
[role=menuitem] { text-align: left; font: 14px/1.3 var(--font); padding: var(--space-2) var(--space-3); border: 0; border-radius: var(--radius-sm); color: var(--fg); background: transparent; cursor: pointer; }
|
|
61
|
+
[role=menuitem]:hover, [role=menuitem]:focus-visible { background: var(--surface); outline: none; }
|
|
62
|
+
[role=menuitem]:disabled { color: var(--muted); cursor: not-allowed; }
|
|
63
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
64
|
+
</style>
|
package/ui/row.brain
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<meta props="gap:str='3' align:str='center' justify:str='start' wrap:bool=true">
|
|
2
|
+
<script>
|
|
3
|
+
// a horizontal flow that wraps on narrow screens; justify: start | center | end | between
|
|
4
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
5
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
6
|
+
const jc = (j) => (j === 'between' ? 'space-between' : j === 'start' || j === 'end' ? 'flex-' + j : j)
|
|
7
|
+
</script>
|
|
8
|
+
<div class="row" class:nowrap={!wrap} style:gap={sp(gap)} style:align-items={align} style:justify-content={jc(justify)}><slot /></div>
|
|
9
|
+
<style>
|
|
10
|
+
.row { display: flex; flex-wrap: wrap; min-width: 0; }
|
|
11
|
+
.row.nowrap { flex-wrap: nowrap; }
|
|
12
|
+
</style>
|
package/ui/select.brain
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<meta props="label:str value:str~ options:list placeholder:str? name:str? required:bool=false disabled:bool=false error:str? help:str?">
|
|
2
|
+
<script>
|
|
3
|
+
// options: ['a', 'b'] or [{ value: 'a', label: 'A' }]
|
|
4
|
+
const items = drv(() => options.map((o) => (typeof o === 'object' ? o : { value: String(o), label: String(o) })))
|
|
5
|
+
let sel = ref()
|
|
6
|
+
// a <select> value only sticks once its options exist: re-apply it after they render
|
|
7
|
+
fx(() => {
|
|
8
|
+
const n = items.length
|
|
9
|
+
if (!sel || !n) return
|
|
10
|
+
sel.value = value || ''
|
|
11
|
+
})
|
|
12
|
+
</script>
|
|
13
|
+
<Field label={label} error={error} help={help}>
|
|
14
|
+
<select bind:ref={sel} name={name} required={required} disabled={disabled} aria-label={label} bind:value={value}>
|
|
15
|
+
{#if placeholder}<option value="" disabled>{placeholder}</option>{/if}
|
|
16
|
+
{#each items as o (o.value)}
|
|
17
|
+
<option value={o.value}>{o.label}</option>
|
|
18
|
+
{/each}
|
|
19
|
+
</select>
|
|
20
|
+
</Field>
|
|
21
|
+
<style>
|
|
22
|
+
select {
|
|
23
|
+
width: 100%; min-height: 40px; padding: var(--space-2) var(--space-3);
|
|
24
|
+
font: 15px/1.4 var(--font); color: var(--fg); background: var(--surface);
|
|
25
|
+
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
26
|
+
}
|
|
27
|
+
select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
28
|
+
select[aria-invalid='true'] { border-color: var(--bad); }
|
|
29
|
+
</style>
|
package/ui/sidebar.brain
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<meta props="side:str='left' width:str='16rem' gap:str='4' min:str='50%'">
|
|
2
|
+
<script>
|
|
3
|
+
// a sidebar next to the main content that stacks above it when the main column would get narrower than `min`
|
|
4
|
+
// (no media query). Content goes in <div slot="side"> and the default slot.
|
|
5
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
6
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
7
|
+
</script>
|
|
8
|
+
<div class="sidebar" class:right={side === 'right'} style:gap={sp(gap)}>
|
|
9
|
+
<div class="side" style:flex-basis={width}><slot name="side" /></div>
|
|
10
|
+
<div class="main" style:min-width={min}><slot /></div>
|
|
11
|
+
</div>
|
|
12
|
+
<style>
|
|
13
|
+
.sidebar { display: flex; flex-wrap: wrap; }
|
|
14
|
+
.sidebar.right { flex-direction: row-reverse; }
|
|
15
|
+
.side { flex-grow: 1; }
|
|
16
|
+
.main { flex-basis: 0; flex-grow: 999; }
|
|
17
|
+
</style>
|
package/ui/spacer.brain
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<meta props="size:str?">
|
|
2
|
+
<script>
|
|
3
|
+
// inside a Stack or Row: with no size it takes the remaining space (pushes the next items to the end); with a
|
|
4
|
+
// token step it is a fixed gap of that size
|
|
5
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
6
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
7
|
+
</script>
|
|
8
|
+
<span class="spacer" class:grow={!size} style:flex-basis={sp(size)} aria-hidden="true"></span>
|
|
9
|
+
<style>
|
|
10
|
+
.spacer { display: block; flex-shrink: 0; }
|
|
11
|
+
.spacer.grow { flex-grow: 1; }
|
|
12
|
+
</style>
|
package/ui/stack.brain
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<meta props="gap:str='3' align:str='stretch'">
|
|
2
|
+
<script>
|
|
3
|
+
// a vertical flow: children one under the other with a token gap; align: start | center | end | stretch
|
|
4
|
+
// spacing props take a token step from tokens.cssbrain (0–6 → --space-N), never raw CSS
|
|
5
|
+
const sp = (n) => (n == null || n === '' ? null : String(n) === '0' ? '0' : 'var(--space-' + n + ')')
|
|
6
|
+
</script>
|
|
7
|
+
<div class="stack" style:gap={sp(gap)} style:align-items={align}><slot /></div>
|
|
8
|
+
<style>
|
|
9
|
+
.stack { display: flex; flex-direction: column; min-width: 0; }
|
|
10
|
+
</style>
|