Make the settings card behave like the other plugin cards

It rendered as a flat, always-open block with no header. The framework's card
chrome is a disclosure: the tab renders every card into a <ul>, each card's root
is an <li>, its header is its own <button> that discloses the controls in place,
it starts collapsed, the header marks it when it holds unsaved edits, and a save
that lands folds it back up.

Three things were wrong and only one of them was cosmetic:

  * the root was a <div> inside the tab's <ul>, so the list and card styling
    never reached it
  * there was no header button, so no disclosure and no aria-expanded
  * nothing marked unsaved edits, and nothing collapsed after a save

`PluginCard` is not importable — it lives in that package's client bundle, and
the client purity gate forbids cross-plugin value imports — so the behaviour is
reproduced rather than borrowed, with inline styles for the same reason.

The collapse-on-save originally lived in an effect watching a `saving` flag, which
is how the shipped card does it because its form state comes from a controller.
This card awaits its own writes, so it already knows the save landed; collapsing
directly in the success path is simpler and is what the tests can actually reach.
A failed save still keeps the card open with its drafts.

Tests cover the disclosure itself: the root element, collapsed-by-default, the
header's aria-expanded and label, that staged edits survive collapsing, and that
a landed save folds the card up.
This commit is contained in:
dsh-slopo agent committed 2026-09-22 13:26:29 +01:00
1 parent 6b9d91b436
commit 2d7a7d1e73
3 files changed
+186 -65

No files matched your search

+5
View File
@@ -46,6 +46,11 @@ keyed on the `dsh-slopo` settings namespace the Host registers. Every setting in
the table below is editable there, overridden fields are marked, and Reset the table below is editable there, overridden fields are marked, and Reset
returns a field to the deployment default rather than to empty. returns a field to the deployment default rather than to empty.
The card behaves the way the other plugin cards do: it starts collapsed, its
header is a button that discloses the controls in place, the header marks it when
it holds unsaved edits — visible even while collapsed, since staged edits outlive
being folded away — and a save that lands collapses it again.
The card is the bundle's second half — the browser half. It needs no build step The card is the bundle's second half — the browser half. It needs no build step
and ships as plain ESM in the module-loader wrapper, but the platform snapshots and ships as plain ESM in the module-loader wrapper, but the platform snapshots
client bundles at activation, so **a client half only appears after the profile client bundles at activation, so **a client half only appears after the profile
+83 -25
View File
@@ -7,6 +7,14 @@
* claims, so a served namespace with no card draws nothing at all, and a card * claims, so a served namespace with no card draws nothing at all, and a card
* whose namespace is not served is never dispatched. * whose namespace is not served is never dispatched.
* *
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the
* tab renders every card into a `<ul>`), the header is its own `<button>` that
* discloses the controls in place, the card starts collapsed, it marks itself
* when it holds unsaved edits, and it collapses again after a successful save.
* Nothing about that is imported — `PluginCard` lives in that package's client
* bundle and the client purity gate forbids cross-plugin value imports — so the
* behaviour is reproduced here rather than borrowed.
*
* Hand-written plain JavaScript inside the module-loader wrapper, with no build * Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build * step and no bundler. `dsh plugin add git+…` fetches sources rather than build
* output, so a `prepare` script would make every user allowlist a build before * output, so a `prepare` script would make every user allowlist a build before
@@ -14,8 +22,8 @@
* platform seed table; anything outside that table would have to be bundled * platform seed table; anything outside that table would have to be bundled
* into this file or declared in `dsh.client.external`. * into this file or declared in `dsh.client.external`.
* *
* Styling is inline on purpose. The client bundle purity gate forbids * Styling is inline on purpose, for the same reason: there is no stylesheet this
* cross-plugin value imports, so there is no stylesheet this file may reach. * file may reach.
*/ */
window.__ModuleLoader__.load({ window.__ModuleLoader__.load({
id: 'dsh-slopo', id: 'dsh-slopo',
@@ -28,6 +36,9 @@ window.__ModuleLoader__.load({
const NS = 'dsh-slopo' const NS = 'dsh-slopo'
const h = react.createElement const h = react.createElement
const TITLE = 'Slopo'
const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.'
/** /**
* The settings this card edits. * The settings this card edits.
* *
@@ -79,11 +90,43 @@ window.__ModuleLoader__.load({
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' }, { key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
] ]
const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
const S = { const S = {
card: { border: '1px solid var(--dsh-border, #3a3a3a)', borderRadius: '8px', padding: '16px', margin: '8px 0' }, // The tab renders into a <ul>, so the root must be an <li> or the list
head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px', marginBottom: '4px' }, // styling never reaches it.
title: { fontSize: '14px', fontWeight: 600 }, card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
badge: { fontSize: '11px', opacity: 0.7 }, cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
header: {
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'transparent',
color: 'inherit',
textAlign: 'left',
cursor: 'pointer',
font: 'inherit',
},
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
name: { fontSize: '14px', fontWeight: 600 },
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
pending: {
flex: '0 0 auto',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: '4px',
border: BORDER,
opacity: 0.8,
},
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
chevronOpen: { transform: 'rotate(180deg)' },
body: { padding: '0 16px 16px 16px' },
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 }, hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
row: { marginTop: '12px' }, row: { marginTop: '12px' },
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' }, label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
@@ -93,7 +136,7 @@ window.__ModuleLoader__.load({
padding: '6px 8px', padding: '6px 8px',
fontSize: '12px', fontSize: '12px',
borderRadius: '6px', borderRadius: '6px',
border: '1px solid var(--dsh-border, #3a3a3a)', border: BORDER,
background: 'transparent', background: 'transparent',
color: 'inherit', color: 'inherit',
}, },
@@ -102,14 +145,13 @@ window.__ModuleLoader__.load({
padding: '6px 12px', padding: '6px 12px',
fontSize: '12px', fontSize: '12px',
borderRadius: '6px', borderRadius: '6px',
border: '1px solid var(--dsh-border, #3a3a3a)', border: BORDER,
background: 'transparent', background: 'transparent',
color: 'inherit', color: 'inherit',
cursor: 'pointer', cursor: 'pointer',
}, },
primary: { fontWeight: 600 }, primary: { fontWeight: 600 },
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' }, note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
dirty: { fontSize: '11px', opacity: 0.8 },
} }
/** The editable string form of one field's value. */ /** The editable string form of one field's value. */
@@ -180,6 +222,8 @@ window.__ModuleLoader__.load({
const [draft, setDraft] = react.useState(null) const [draft, setDraft] = react.useState(null)
const [busy, setBusy] = react.useState(false) const [busy, setBusy] = react.useState(false)
const [note, setNote] = react.useState('') const [note, setNote] = react.useState('')
// Collapsed on mount, like every other card in the tab.
const [open, setOpen] = react.useState(false)
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope]) react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
@@ -196,6 +240,7 @@ window.__ModuleLoader__.load({
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key) const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : [] const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
const writable = snapshot.writable === true
const save = async () => { const save = async () => {
setBusy(true) setBusy(true)
@@ -211,6 +256,9 @@ window.__ModuleLoader__.load({
} }
setDraft(null) setDraft(null)
setNote('Saved.') setNote('Saved.')
// A landed save folds the card back up. A failed or blocked one stays
// open with the drafts, which is the whole point of staging them.
setOpen(false)
} catch (error) { } catch (error) {
setNote(`Save failed: ${error?.message ?? error}`) setNote(`Save failed: ${error?.message ?? error}`)
} finally { } finally {
@@ -233,31 +281,39 @@ window.__ModuleLoader__.load({
} }
return h( return h(
'div', 'li',
{ style: S.card }, { style: open ? { ...S.card, ...S.cardOpen } : S.card },
h( h(
'div', 'button',
{ style: S.head }, {
h('div', { style: S.title }, 'Slopo'), type: 'button',
style: S.header,
'aria-expanded': open,
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
onClick: () => setOpen(!open),
},
h( h(
'span',
{ style: S.headText },
h('span', { style: S.name }, TITLE),
h('span', { style: S.description }, DESCRIPTION),
),
// Marked on the header, so unsaved edits are visible while collapsed.
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
),
open
? h(
'div', 'div',
{ style: S.badge }, { style: S.body },
status === 'loading' ? 'loading…' : 'configuration', writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
snapshot.writable === true ? '' : ' · read-only',
),
),
h(
'div',
{ style: S.hint },
'Slopo runs as a separate process. Nothing here is defaulted: it stays unconfigured until you set a model and its dimensions.',
),
...FIELDS.map((field) => ...FIELDS.map((field) =>
h(Field, { h(Field, {
key: field.key, key: field.key,
field, field,
value: current[field.key], value: current[field.key],
overridden: overriddenKeys.includes(field.key), overridden: overriddenKeys.includes(field.key),
disabled: busy || snapshot.writable !== true, disabled: busy || !writable,
onChange: (next) => setDraft({ ...current, [field.key]: next }), onChange: (next) => setDraft({ ...current, [field.key]: next }),
}), }),
), ),
@@ -277,6 +333,8 @@ window.__ModuleLoader__.load({
note === '' ? null : h('span', { style: S.note }, note), note === '' ? null : h('span', { style: S.note }, note),
), ),
) )
: null,
)
} }
/** /**
+98 -40
View File
@@ -10,7 +10,8 @@
* The card is then rendered by calling the registered component and expanding * The card is then rendered by calling the registered component and expanding
* the elements it returns, and its handlers are driven directly. That is not a * the elements it returns, and its handlers are driven directly. That is not a
* substitute for looking at it in a browser, but it does check the wiring the * substitute for looking at it in a browser, but it does check the wiring the
* card cannot work without. * card cannot work without — including the disclosure chrome, which is what
* makes it look like the other cards in the tab.
*/ */
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
@@ -19,9 +20,14 @@ import { test } from 'node:test'
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8') const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
/** Minimal React: enough for createElement plus the three hooks the card uses. */ /**
* Minimal React: createElement, the three hooks the card uses, and an effect
* runner that actually invokes its callback rather than ignoring it.
*
* `useState` consumes one ordered slot per call, as real hooks do.
*/
function makeFakeReact() { function makeFakeReact() {
const states = [] const slots = []
let cursor = 0 let cursor = 0
return { return {
reset() { reset() {
@@ -32,15 +38,17 @@ function makeFakeReact() {
useState(initial) { useState(initial) {
const index = cursor const index = cursor
cursor += 1 cursor += 1
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial
return [ return [
states[index], slots[index],
(next) => { (next) => {
states[index] = typeof next === 'function' ? next(states[index]) : next slots[index] = typeof next === 'function' ? next(slots[index]) : next
}, },
] ]
}, },
useEffect() {}, useEffect(effect) {
effect()
},
useMemo(factory) { useMemo(factory) {
return factory() return factory()
}, },
@@ -62,12 +70,7 @@ function loadBundle() {
return { id: loaded.id, exported, react } return { id: loaded.id, exported, react }
} }
/** /** Resolve function components by calling them, until a host element or null. */
* Resolve function components by calling them, until a host element or null.
*
* This is the whole "renderer": the slot hands back an element whose type is the
* card component, and function-typed children are expanded the same way.
*/
function expand(node, depth = 0) { function expand(node, depth = 0) {
if (depth > 50) throw new Error('the component tree did not settle') if (depth > 50) throw new Error('the component tree did not settle')
if (node === null || node === undefined) return null if (node === null || node === undefined) return null
@@ -150,9 +153,21 @@ function registered() {
}, },
} }
exported.apply(ctx) exported.apply(ctx)
const draw = (scope) => expand(render({ scope })) const draw = (scope) => {
return { id, exported, react, registration, render, draw } react.reset()
return expand(render({ scope }))
} }
/** Render, click the header, and render again the way a state update would. */
const drawOpen = (scope) => {
const header = headerOf(draw(scope))
header.props.onClick()
return draw(scope)
}
return { id, exported, react, registration, draw, drawOpen }
}
const headerOf = (tree) =>
findAll(tree, (node) => node.type === 'button' && node.props['aria-expanded'] !== undefined)[0]
const inputFor = (tree, label) => const inputFor = (tree, label) =>
findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0] findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
@@ -179,9 +194,25 @@ test('applying registers exactly one settings.plugin.item card, keyed on the nam
assert.equal(registration.key, 'dsh-slopo') assert.equal(registration.key, 'dsh-slopo')
}) })
test('the card renders a control for every setting the Host accepts', () => { test('the root is an <li>, because the tab renders every card into a <ul>', () => {
const { draw } = registered()
assert.equal(draw(fakeScope(READY)).type, 'li')
})
test('the card starts collapsed, with a header that says so', () => {
const { draw } = registered() const { draw } = registered()
const tree = draw(fakeScope(READY)) const tree = draw(fakeScope(READY))
assert.equal(headerOf(tree).props['aria-expanded'], false)
assert.match(headerOf(tree).props['aria-label'], /^Expand: /)
// Nothing is disclosed yet.
assert.equal(findAll(tree, (node) => node.type === 'input').length, 0)
})
test('clicking the header discloses every setting the Host accepts', () => {
const { drawOpen } = registered()
const tree = drawOpen(fakeScope(READY))
assert.equal(headerOf(tree).props['aria-expanded'], true)
assert.match(headerOf(tree).props['aria-label'], /^Collapse: /)
const labels = findAll(tree, (node) => node.type === 'label').map(textOf) const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
for (const expected of [ for (const expected of [
'Embedding model', 'Embedding model',
@@ -204,8 +235,8 @@ test('the card renders a control for every setting the Host accepts', () => {
}) })
test('the API key is rendered as a password field', () => { test('the API key is rendered as a password field', () => {
const { draw } = registered() const { drawOpen } = registered()
const tree = draw(fakeScope(READY)) const tree = drawOpen(fakeScope(READY))
assert.equal(inputFor(tree, 'Embedding API key').props.type, 'password') assert.equal(inputFor(tree, 'Embedding API key').props.type, 'password')
}) })
@@ -214,72 +245,98 @@ test('the card renders nothing when the namespace is unavailable', () => {
assert.equal(draw(fakeScope({ ...READY, status: 'unavailable' })), null) assert.equal(draw(fakeScope({ ...READY, status: 'unavailable' })), null)
}) })
test('editing a field marks the card dirty and saving writes it through set()', async () => { test('editing a field marks the header unsaved, and saving writes it through set()', async () => {
const { draw, react } = registered() const { draw, drawOpen, react } = registered()
const scope = fakeScope(READY) const scope = fakeScope(READY)
inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/example:latest' } }) const open = drawOpen(scope)
inputFor(open, 'Embedding model').props.onChange({ target: { value: 'ollama/example:latest' } })
// Re-render the way a state update would.
react.reset()
const tree = draw(scope) const tree = draw(scope)
// Marked on the header, so it is visible while collapsed.
assert.match(textOf(headerOf(tree)), /unsaved/)
const save = buttonStartingWith(tree, 'Save') const save = buttonStartingWith(tree, 'Save')
assert.equal(save.props.disabled, false, 'a changed field must enable Save') assert.equal(save.props.disabled, false, 'a changed field must enable Save')
await save.props.onClick() await save.props.onClick()
assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']]) assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']])
assert.deepEqual(scope.calls.unset, []) assert.deepEqual(scope.calls.unset, [])
assert.equal(react !== undefined, true)
})
test('a successful save collapses the card again', async () => {
const { draw, drawOpen } = registered()
const scope = fakeScope(READY)
inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/x:latest' } })
await buttonStartingWith(draw(scope), 'Save').props.onClick()
// The save sets busy=false and note='Saved.', the effect collapses on the
// next render, and the one after that is the collapsed card.
draw(scope)
const settled = draw(scope)
assert.equal(headerOf(settled).props['aria-expanded'], false, 'a landed save closes the card')
assert.equal(findAll(settled, (node) => node.type === 'input').length, 0)
})
test('staged edits survive collapsing', () => {
const { draw, drawOpen } = registered()
const scope = fakeScope(READY)
inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/kept:latest' } })
// Collapse it by hand.
headerOf(draw(scope)).props.onClick()
const collapsed = draw(scope)
assert.equal(headerOf(collapsed).props['aria-expanded'], false)
assert.match(textOf(headerOf(collapsed)), /unsaved/)
// Re-open: the draft is still there.
headerOf(collapsed).props.onClick()
assert.equal(inputFor(draw(scope), 'Embedding model').props.value, 'ollama/kept:latest')
}) })
test('a whole number field is sent as a number, not the string the input holds', async () => { test('a whole number field is sent as a number, not the string the input holds', async () => {
const { draw, react } = registered() const { draw, drawOpen } = registered()
const scope = fakeScope(READY) const scope = fakeScope(READY)
inputFor(draw(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } }) inputFor(drawOpen(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } })
react.reset()
await buttonStartingWith(draw(scope), 'Save').props.onClick() await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]]) assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]])
}) })
test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => { test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => {
const { draw, react } = registered() const { draw, drawOpen } = registered()
const scope = fakeScope({ ...READY, value: { embeddingModel: 'ollama/old:latest', embeddingDimensions: 768 } }) const scope = fakeScope({ ...READY, value: { embeddingModel: 'ollama/old:latest', embeddingDimensions: 768 } })
inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: '' } }) inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: '' } })
react.reset()
await buttonStartingWith(draw(scope), 'Save').props.onClick() await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.unset, ['embeddingModel']) assert.deepEqual(scope.calls.unset, ['embeddingModel'])
assert.deepEqual(scope.calls.set, [], 'a cleared field must not be written as an empty string') assert.deepEqual(scope.calls.set, [], 'a cleared field must not be written as an empty string')
}) })
test('an exclude list round-trips through comma-separated text', async () => { test('an exclude list round-trips through comma-separated text', async () => {
const { draw, react } = registered() const { draw, drawOpen } = registered()
const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } }) const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } })
inputFor(draw(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } }) inputFor(drawOpen(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } })
react.reset()
await buttonStartingWith(draw(scope), 'Save').props.onClick() await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]]) assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]])
}) })
test('a boolean field writes a real boolean', async () => { test('a boolean field writes a real boolean', async () => {
const { draw, react } = registered() const { draw, drawOpen } = registered()
const scope = fakeScope(READY) const scope = fakeScope(READY)
const checkbox = findAll( const checkbox = findAll(
draw(scope), drawOpen(scope),
(node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing', (node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing',
)[0] )[0]
checkbox.props.onChange({ target: { checked: true } }) checkbox.props.onChange({ target: { checked: true } })
react.reset()
await buttonStartingWith(draw(scope), 'Save').props.onClick() await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.set, [['autoInstall', true]]) assert.deepEqual(scope.calls.set, [['autoInstall', true]])
}) })
test('an overridden field is marked, and Reset clears every override', async () => { test('an overridden field is marked, and Reset clears every override', async () => {
const { draw } = registered() const { drawOpen } = registered()
const scope = fakeScope({ const scope = fakeScope({
...READY, ...READY,
value: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 }, value: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
user: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 }, user: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
}) })
const tree = draw(scope) const tree = drawOpen(scope)
const labels = findAll(tree, (node) => node.type === 'label').map(textOf) const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
assert.ok(labels.some((label) => label.includes('overridden')), 'a user-set field must be marked') assert.ok(labels.some((label) => label.includes('overridden')), 'a user-set field must be marked')
@@ -289,9 +346,10 @@ test('an overridden field is marked, and Reset clears every override', async ()
assert.deepEqual(scope.calls.unset.sort(), ['embeddingDimensions', 'embeddingModel']) assert.deepEqual(scope.calls.unset.sort(), ['embeddingDimensions', 'embeddingModel'])
}) })
test('a read-only namespace disables every control', () => { test('a read-only namespace says so and disables every control', () => {
const { draw } = registered() const { drawOpen } = registered()
const tree = draw(fakeScope({ ...READY, writable: false, mode: 'memory' })) const tree = drawOpen(fakeScope({ ...READY, writable: false, mode: 'memory' }))
assert.match(textOf(tree), /read-only/)
const inputs = findAll(tree, (node) => node.type === 'input') const inputs = findAll(tree, (node) => node.type === 'input')
assert.ok(inputs.length > 0) assert.ok(inputs.length > 0)
for (const input of inputs) assert.equal(input.props.disabled, true) for (const input of inputs) assert.equal(input.props.disabled, true)