@christianirack/brainllm-runtime 0.3.1 → 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/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-budget.js +142 -0
- package/brain-ch.js +18 -4
- package/brain-data.js +13 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +58 -23
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-live.js +364 -0
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +144 -78
- package/brain-schema.js +69 -18
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +5 -4
- package/package.json +45 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +449 -41
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Private server worker for @google-cloud/storage 8.4.0. ADC only; stdout is a bounded machine result.
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { open, realpath, lstat, unlink } from 'node:fs/promises'
|
|
4
|
+
import { join, sep } from 'node:path'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { pipeline } from 'node:stream/promises'
|
|
7
|
+
import { Transform } from 'node:stream'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
const MAX = 20 * 1024 * 1024
|
|
10
|
+
const fail = () => { throw Error('GCS_OPERATION_FAILED') }
|
|
11
|
+
const safe = key => typeof key === 'string' && key.length <= 512 && key.split('/').every(part => /^[a-zA-Z0-9_.-]+$/.test(part) && part !== '.' && part !== '..')
|
|
12
|
+
export async function validate(input) {
|
|
13
|
+
if (input?.schemaVersion !== 1 || !/^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/.test(input.bucket || '') || !/^[a-zA-Z0-9_-]{1,80}$/.test(input.prefix || '') || !['write', 'read', 'delete'].includes(input.request?.op) || !Array.isArray(input.request.objects) || !input.request.objects.length || input.request.objects.length > 9 || typeof input.root !== 'string') fail()
|
|
14
|
+
const root = await realpath(input.root)
|
|
15
|
+
if ((await lstat(input.root)).isSymbolicLink()) fail()
|
|
16
|
+
const paths = new Set()
|
|
17
|
+
for (const object of input.request.objects) {
|
|
18
|
+
if (!safe(object.key) || !object.key.startsWith(input.bucket + '/' + input.prefix + '/') || paths.has(object.key)) fail()
|
|
19
|
+
paths.add(object.key)
|
|
20
|
+
if (!object.key.split('/').some(part => /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(part))) fail()
|
|
21
|
+
if (input.request.op !== 'delete') {
|
|
22
|
+
if (!Number.isSafeInteger(object.bytes) || object.bytes < 1 || object.bytes > MAX || !/^[a-f0-9]{64}$/.test(object.sha256 || '')) fail()
|
|
23
|
+
if (input.request.op === 'write' && (!safe(object.source) || object.source !== object.key || !['image/png', 'image/jpeg', 'image/webp'].includes(object.contentType) || !object.key.endsWith('-' + object.sha256 + '.' + object.contentType.slice(6)))) fail()
|
|
24
|
+
if (input.request.op === 'read' && (!object.generation || !/^gcs-read\/[a-f0-9]{32}\.bin$/.test(object.target || ''))) fail()
|
|
25
|
+
}
|
|
26
|
+
if (object.generation != null && (typeof object.generation !== 'string' || !/^[0-9]{0,32}$/.test(object.generation))) fail()
|
|
27
|
+
}
|
|
28
|
+
return root
|
|
29
|
+
}
|
|
30
|
+
async function localPath(root, key, existing) {
|
|
31
|
+
if (!safe(key)) fail()
|
|
32
|
+
let current = root
|
|
33
|
+
for (const part of key.split('/')) {
|
|
34
|
+
current = join(current, part)
|
|
35
|
+
let entry
|
|
36
|
+
try { entry = await lstat(current) } catch (error) { if (!existing && error.code === 'ENOENT') continue; fail() }
|
|
37
|
+
if (entry.isSymbolicLink()) fail()
|
|
38
|
+
}
|
|
39
|
+
if (!current.startsWith(root + sep)) fail()
|
|
40
|
+
return current
|
|
41
|
+
}
|
|
42
|
+
function checkedStream(expected) {
|
|
43
|
+
let bytes = 0; const digest = createHash('sha256')
|
|
44
|
+
return new Transform({ transform(chunk, encoding, callback) {
|
|
45
|
+
bytes += chunk.length
|
|
46
|
+
if (bytes > MAX || bytes > expected.bytes) return callback(Error('GCS_OPERATION_FAILED'))
|
|
47
|
+
digest.update(chunk); callback(null, chunk)
|
|
48
|
+
}, flush(callback) { callback(bytes === expected.bytes && digest.digest('hex') === expected.sha256 ? null : Error('GCS_OPERATION_FAILED')) } })
|
|
49
|
+
}
|
|
50
|
+
export async function execute(input, createStorage) {
|
|
51
|
+
const root = await validate(input)
|
|
52
|
+
const storage = await createStorage(), bucket = storage.bucket(input.bucket)
|
|
53
|
+
const [policy] = await bucket.getMetadata()
|
|
54
|
+
if (policy.iamConfiguration?.publicAccessPrevention !== 'enforced' || policy.iamConfiguration?.uniformBucketLevelAccess?.enabled !== true) fail()
|
|
55
|
+
const output = []
|
|
56
|
+
for (const object of input.request.objects) {
|
|
57
|
+
const remoteKey = object.key.slice(input.bucket.length + 1), file = bucket.file(remoteKey)
|
|
58
|
+
if (input.request.op === 'write') {
|
|
59
|
+
const source = await localPath(root, object.source, true), sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
|
|
60
|
+
try {
|
|
61
|
+
const stat = await sourceHandle.stat(); if (!stat.isFile() || stat.size !== object.bytes) fail()
|
|
62
|
+
try {
|
|
63
|
+
await pipeline(sourceHandle.createReadStream({ autoClose: true }), checkedStream(object), file.createWriteStream({ resumable: false, validation: 'crc32c', preconditionOpts: { ifGenerationMatch: 0 }, metadata: { contentType: object.contentType, cacheControl: 'private, no-store', metadata: { sha256: object.sha256 } } }))
|
|
64
|
+
} catch (error) { if (![412, '412'].includes(error.code)) fail() }
|
|
65
|
+
} finally { await sourceHandle.close().catch(() => {}) }
|
|
66
|
+
const [metadata] = await file.getMetadata()
|
|
67
|
+
if (String(metadata.size) !== String(object.bytes) || metadata.metadata?.sha256 !== object.sha256 || metadata.contentType !== object.contentType || !/^[0-9]{1,32}$/.test(metadata.generation || '')) fail()
|
|
68
|
+
output.push({ key: object.key, generation: metadata.generation })
|
|
69
|
+
} else if (input.request.op === 'read') {
|
|
70
|
+
if (!object.generation) fail()
|
|
71
|
+
const target = await localPath(root, object.target, false), handle = await open(target, constants.O_WRONLY | (constants.O_NOFOLLOW || 0))
|
|
72
|
+
const stat = await handle.stat(); if (!stat.isFile() || stat.size !== 0) { await handle.close(); fail() }
|
|
73
|
+
let completed = false
|
|
74
|
+
try { await pipeline(bucket.file(remoteKey, { generation: object.generation }).createReadStream({ validation: 'crc32c' }), checkedStream(object), handle.createWriteStream({ autoClose: true, flush: true })); completed = true } finally { await handle.close().catch(() => {}); if (!completed) await unlink(target).catch(() => {}) }
|
|
75
|
+
output.push({ key: object.key, generation: object.generation })
|
|
76
|
+
} else {
|
|
77
|
+
let generation = object.generation
|
|
78
|
+
if (!generation) {
|
|
79
|
+
let metadata
|
|
80
|
+
try { [metadata] = await file.getMetadata() } catch (error) { if ([404, '404'].includes(error.code)) { output.push({ key: object.key, deleted: true }); continue } fail() }
|
|
81
|
+
const expected = /-([a-f0-9]{64})\.(?:png|jpeg|webp)$/.exec(object.key)?.[1]
|
|
82
|
+
if (!expected || metadata.metadata?.sha256 !== expected || !/^[0-9]{1,32}$/.test(metadata.generation || '')) fail()
|
|
83
|
+
generation = metadata.generation
|
|
84
|
+
}
|
|
85
|
+
try { await file.delete({ ifGenerationMatch: generation, ignoreNotFound: true }) } catch (error) { if (![404, '404'].includes(error.code)) fail() }
|
|
86
|
+
output.push({ key: object.key, deleted: true })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, objects: output }
|
|
90
|
+
}
|
|
91
|
+
async function main() {
|
|
92
|
+
const chunks = []; let bytes = 0
|
|
93
|
+
for await (const chunk of process.stdin) { bytes += chunk.length; if (bytes > 32768) fail();chunks.push(chunk) }
|
|
94
|
+
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
95
|
+
if (process.argv.includes('--validate')) { await validate(input); return { ok: true } }
|
|
96
|
+
if (process.env.STORAGE_EMULATOR_HOST || process.env.GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) fail()
|
|
97
|
+
return execute(input, async () => { const { Storage } = await import('@google-cloud/storage'); return new Storage({ retryOptions: { autoRetry: false, maxRetries: 0 } }) })
|
|
98
|
+
}
|
|
99
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) main().then(result => process.stdout.write(JSON.stringify(result)), () => { process.stdout.write('{"ok":false,"error":"GCS_OPERATION_FAILED"}'); process.exitCode = 1 })
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Application-owned GIS adapter. No credentials, tokens, private errors or session data are logged or stored.
|
|
2
|
+
const SDK = 'https://accounts.google.com/gsi/client'
|
|
3
|
+
let loading = null, active = null
|
|
4
|
+
const sdk = () => {
|
|
5
|
+
if (globalThis.google?.accounts?.id) return Promise.resolve(globalThis.google.accounts.id)
|
|
6
|
+
if (loading) return loading
|
|
7
|
+
loading = new Promise((resolve, reject) => {
|
|
8
|
+
const doc = globalThis.document, existing = doc.querySelector(`script[src="${SDK}"]`)
|
|
9
|
+
const script = existing || doc.createElement('script')
|
|
10
|
+
const done = error => {
|
|
11
|
+
clearTimeout(timer); script.removeEventListener('load', loaded); script.removeEventListener('error', failed)
|
|
12
|
+
if (error) { if (!existing) script.remove(); reject(Error('Google sign-in is unavailable')) }
|
|
13
|
+
else resolve(globalThis.google.accounts.id)
|
|
14
|
+
}
|
|
15
|
+
const loaded = () => globalThis.google?.accounts?.id ? done(false) : done(true), failed = () => done(true)
|
|
16
|
+
const timer = setTimeout(failed, 6000)
|
|
17
|
+
script.addEventListener('load', loaded); script.addEventListener('error', failed)
|
|
18
|
+
if (!existing) { script.src = SDK; script.async = true; script.defer = true; doc.head.appendChild(script) }
|
|
19
|
+
}).catch(error => { loading = null; throw error })
|
|
20
|
+
return loading
|
|
21
|
+
}
|
|
22
|
+
export default {
|
|
23
|
+
mount(element, initial = {}) {
|
|
24
|
+
if (active) throw Error('Only one Google sign-in widget may be active')
|
|
25
|
+
let props = initial, alive = true, busy = false, pending = null
|
|
26
|
+
const owner = {}; active = owner
|
|
27
|
+
const notify = (message, waiting, signedIn = false) => {
|
|
28
|
+
if (!alive) return
|
|
29
|
+
busy = waiting; element.setAttribute('aria-busy', String(waiting))
|
|
30
|
+
if ('inert' in element) element.inert = waiting
|
|
31
|
+
if (typeof props.onState === 'function') props.onState({ message, busy: waiting, signedIn })
|
|
32
|
+
}
|
|
33
|
+
const request = async (path, body, method = 'POST') => {
|
|
34
|
+
const controller = new AbortController(); pending = controller
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), 8000)
|
|
36
|
+
try {
|
|
37
|
+
const base = globalThis.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || ''
|
|
38
|
+
if (base && (!base.startsWith('/') || base.startsWith('//') || /[\\?#\x00-\x20]/.test(base))) throw Error('Invalid application base')
|
|
39
|
+
const response = await fetch(base.replace(/\/$/, '') + path, { method, credentials: 'same-origin', headers: body == null ? {} : { 'content-type': 'application/json' }, ...(body == null ? {} : { body: JSON.stringify(body) }), signal: controller.signal })
|
|
40
|
+
if (!response.ok) return { $status: response.status }
|
|
41
|
+
return await response.json()
|
|
42
|
+
} finally { clearTimeout(timer); if (pending === controller) pending = null }
|
|
43
|
+
}
|
|
44
|
+
const failed = () => notify('Sign-in could not be completed. Retry or use email sign-in.', false)
|
|
45
|
+
const start = async () => {
|
|
46
|
+
if (!alive || busy) return
|
|
47
|
+
notify('Preparing secure sign-in.', true)
|
|
48
|
+
try {
|
|
49
|
+
const current = await request('/_brain/auth/me', null, 'GET')
|
|
50
|
+
if (!alive) return
|
|
51
|
+
if (current?.user) { element.replaceChildren(); return notify('You are signed in.', false, true) }
|
|
52
|
+
if (current?.$status !== 401) return failed()
|
|
53
|
+
const renewed = await request('/_brain/auth/refresh')
|
|
54
|
+
if (!alive) return
|
|
55
|
+
if (renewed?.user) { notify('You are signed in.', false, true); globalThis.location?.reload(); return }
|
|
56
|
+
if (renewed?.$status !== 401) return failed()
|
|
57
|
+
const challenge = await request('/_brain/auth/google/challenge')
|
|
58
|
+
if (!alive) return
|
|
59
|
+
if (!challenge || !/^[a-zA-Z0-9._-]{1,255}\.apps\.googleusercontent\.com$/.test(challenge.client_id) || !/^[a-f0-9]{64}$/.test(challenge.nonce)) return failed()
|
|
60
|
+
const api = await sdk()
|
|
61
|
+
if (!alive) return
|
|
62
|
+
api.initialize({ client_id: challenge.client_id, nonce: challenge.nonce, callback: async result => {
|
|
63
|
+
if (!alive || busy) return
|
|
64
|
+
notify('Completing secure sign-in.', true)
|
|
65
|
+
try {
|
|
66
|
+
if (typeof result?.credential !== 'string' || result.credential.length > 16384) return failed()
|
|
67
|
+
const session = await request('/_brain/auth/google', { credential: result.credential })
|
|
68
|
+
if (!alive) return
|
|
69
|
+
if (!session?.user) return failed()
|
|
70
|
+
notify('You are signed in.', false, true)
|
|
71
|
+
// A full auth-boundary reload forgets all prior user-specific in-memory application state.
|
|
72
|
+
globalThis.location?.reload()
|
|
73
|
+
} catch { if (alive) failed() }
|
|
74
|
+
} })
|
|
75
|
+
element.replaceChildren()
|
|
76
|
+
api.renderButton(element, { type: 'standard', theme: 'outline', size: 'large', text: 'signin_with', width: 280 })
|
|
77
|
+
notify('Choose Google sign-in or use email sign-in.', false)
|
|
78
|
+
} catch { if (alive) failed() }
|
|
79
|
+
}
|
|
80
|
+
void start()
|
|
81
|
+
return {
|
|
82
|
+
update(next) {
|
|
83
|
+
props = next || {}
|
|
84
|
+
if (!alive || busy) return
|
|
85
|
+
if (props.action === 'logout') {
|
|
86
|
+
notify('Signing out.', true)
|
|
87
|
+
void request('/_brain/auth/logout').then(result => {
|
|
88
|
+
if (!alive) return
|
|
89
|
+
if (!result?.ok) return failed()
|
|
90
|
+
notify('You are signed out.', false); globalThis.location?.reload()
|
|
91
|
+
}, () => { if (alive) failed() })
|
|
92
|
+
} else void start()
|
|
93
|
+
},
|
|
94
|
+
destroy() {
|
|
95
|
+
if (!alive) return
|
|
96
|
+
alive = false; pending?.abort(); element.replaceChildren(); element.removeAttribute('aria-busy')
|
|
97
|
+
if ('inert' in element) element.inert = false
|
|
98
|
+
if (active === owner) active = null
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}
|
package/ui/button.brain
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<style>
|
|
15
15
|
button {
|
|
16
16
|
display: inline-flex; align-items: center; gap: var(--space-2);
|
|
17
|
-
padding: var(--space-2) var(--space-4); min-height:
|
|
17
|
+
padding: var(--space-2) var(--space-4); min-height: 44px;
|
|
18
18
|
font: 600 15px/1.2 var(--font); border-radius: var(--radius-sm); cursor: pointer;
|
|
19
19
|
color: var(--on-accent); background: var(--accent); border: 1px solid var(--accent);
|
|
20
20
|
box-shadow: var(--shadow);
|
package/ui/checkbox.brain
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
</div>
|
|
9
9
|
<style>
|
|
10
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;
|
|
11
|
+
input { width: 18px; height: 18px; }
|
|
12
12
|
.error { font-size: 13px; color: var(--bad); margin: var(--space-1) 0 0; }
|
|
13
|
+
@supports (accent-color: auto) { input { accent-color: var(--accent); } }
|
|
13
14
|
</style>
|
package/ui/dialog.brain
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
<meta props="title:str closeLabel:str='Close'" emits="close" expose="open close">
|
|
1
|
+
<meta props="title:str closeLabel:str='Close' urlKey:str=''" emits="close" expose="open close">
|
|
2
2
|
<script>
|
|
3
3
|
let dlg = ref()
|
|
4
4
|
// native modal <dialog>: focus moves inside, the rest of the page is inert, Escape closes
|
|
5
5
|
const open = () => { if (!dlg.open) dlg.showModal() }
|
|
6
6
|
const close = () => { if (dlg.open) dlg.close() }
|
|
7
|
+
fx(() => { if (dlg) return urlDialog(dlg, urlKey) })
|
|
7
8
|
</script>
|
|
8
9
|
<dialog bind:ref={dlg} aria-labelledby="dlg-title" on:close={emit('close')}>
|
|
9
10
|
<header>
|
|
@@ -17,13 +18,14 @@
|
|
|
17
18
|
dialog {
|
|
18
19
|
width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--glass-edge);
|
|
19
20
|
border-radius: var(--radius-lg); color: var(--fg); background: var(--glass); box-shadow: var(--shadow);
|
|
20
|
-
|
|
21
|
+
|
|
21
22
|
}
|
|
22
|
-
dialog::backdrop { background: var(--scrim);
|
|
23
|
+
dialog::backdrop { background: var(--scrim); }
|
|
23
24
|
header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-4) 0; }
|
|
24
25
|
h2 { font: 650 18px/1.3 var(--font); margin: 0; }
|
|
25
26
|
.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
27
|
.body { padding: var(--space-4); }
|
|
27
28
|
footer { display: flex; justify-content: flex-end; gap: var(--space-2); padding: 0 var(--space-4) var(--space-4); }
|
|
28
29
|
footer:empty { display: none; }
|
|
30
|
+
@supports (backdrop-filter: blur(1px)) { dialog { backdrop-filter: blur(24px) saturate(1.4); } dialog::backdrop { backdrop-filter: blur(4px); } }
|
|
29
31
|
</style>
|
package/ui/error-state.brain
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
if (e.kind === 'timeout') return 'The server took too long to answer.'
|
|
11
11
|
if (e.kind === 'schema') return 'The server sent something unexpected.'
|
|
12
12
|
if (e.status === 401) return 'Sign in to continue.'
|
|
13
|
-
if (e.status === 403) return
|
|
13
|
+
if (e.status === 403) return 'You do not have permission to see this.'
|
|
14
14
|
if (e.status === 404) return 'Not found.'
|
|
15
15
|
if (e.status === 422) return 'Check the highlighted fields.'
|
|
16
16
|
if (e.status === 429) return 'Too many requests. Trying again in ' + (e.retryAfter || 30) + ' seconds.'
|
package/ui/input.brain
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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?">
|
|
1
|
+
<meta props="label:str value:str~ type:str='text' step:str='any' name:str? placeholder:str? autocomplete:str? required:bool=false disabled:bool=false error:str? help:str?">
|
|
2
2
|
<Field label={label} error={error} help={help}>
|
|
3
3
|
<input
|
|
4
4
|
type={type}
|
|
5
|
+
step={type === 'number' ? step : null}
|
|
5
6
|
name={name}
|
|
6
7
|
placeholder={placeholder}
|
|
7
8
|
autocomplete={autocomplete}
|
|
@@ -13,8 +14,8 @@
|
|
|
13
14
|
</Field>
|
|
14
15
|
<style>
|
|
15
16
|
input {
|
|
16
|
-
width: 100%; min-height:
|
|
17
|
-
font:
|
|
17
|
+
width: 100%; min-height: 44px; padding: var(--space-2) var(--space-3);
|
|
18
|
+
font: 16px/1.4 var(--font); color: var(--fg); background: var(--surface);
|
|
18
19
|
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
19
20
|
}
|
|
20
21
|
input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
package/ui/menu.brain
CHANGED
|
@@ -54,7 +54,10 @@
|
|
|
54
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
55
|
.pop {
|
|
56
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);
|
|
57
|
+
border: 1px solid var(--glass-edge); border-radius: var(--radius); background: var(--glass); box-shadow: var(--shadow);
|
|
58
|
+
}
|
|
59
|
+
@supports (backdrop-filter: blur(20px)) {
|
|
60
|
+
.pop { backdrop-filter: blur(20px) saturate(1.4); }
|
|
58
61
|
}
|
|
59
62
|
.pop[hidden] { display: none; }
|
|
60
63
|
[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; }
|
package/ui/select.brain
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
</Field>
|
|
21
21
|
<style>
|
|
22
22
|
select {
|
|
23
|
-
width: 100%; min-height:
|
|
24
|
-
font:
|
|
23
|
+
width: 100%; min-height: 44px; padding: var(--space-2) var(--space-3);
|
|
24
|
+
font: 16px/1.4 var(--font); color: var(--fg); background: var(--surface);
|
|
25
25
|
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
26
26
|
}
|
|
27
27
|
select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
package/ui/toast.brain
CHANGED
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
.toast {
|
|
37
37
|
display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); min-width: 240px;
|
|
38
38
|
font: 14px/1.4 var(--font); color: var(--fg); background: var(--glass); border: 1px solid var(--glass-edge);
|
|
39
|
-
border-radius: var(--radius); box-shadow: var(--shadow);
|
|
39
|
+
border-radius: var(--radius); box-shadow: var(--shadow);
|
|
40
|
+
}
|
|
41
|
+
@supports (backdrop-filter: blur(20px)) {
|
|
42
|
+
.toast { backdrop-filter: blur(20px) saturate(1.4); }
|
|
40
43
|
}
|
|
41
44
|
.toast.ok { border-color: var(--ok); }
|
|
42
45
|
.toast.bad { border-color: var(--bad); }
|