diff --git a/README.md b/README.md
index fdda454..362f1e4 100644
--- a/README.md
+++ b/README.md
@@ -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
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
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
diff --git a/client/client.js b/client/client.js
index 06480dc..b223323 100644
--- a/client/client.js
+++ b/client/client.js
@@ -7,6 +7,14 @@
* claims, so a served namespace with no card draws nothing at all, and a card
* whose namespace is not served is never dispatched.
*
+ * The chrome follows the framework's `PluginCard`: the root is an `
` (the
+ * tab renders every card into a ``), the header is its own `` 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
* 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
@@ -14,8 +22,8 @@
* platform seed table; anything outside that table would have to be bundled
* into this file or declared in `dsh.client.external`.
*
- * Styling is inline on purpose. The client bundle purity gate forbids
- * cross-plugin value imports, so there is no stylesheet this file may reach.
+ * Styling is inline on purpose, for the same reason: there is no stylesheet this
+ * file may reach.
*/
window.__ModuleLoader__.load({
id: 'dsh-slopo',
@@ -28,6 +36,9 @@ window.__ModuleLoader__.load({
const NS = 'dsh-slopo'
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.
*
@@ -79,11 +90,43 @@ window.__ModuleLoader__.load({
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
]
+ const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
+
const S = {
- card: { border: '1px solid var(--dsh-border, #3a3a3a)', borderRadius: '8px', padding: '16px', margin: '8px 0' },
- head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px', marginBottom: '4px' },
- title: { fontSize: '14px', fontWeight: 600 },
- badge: { fontSize: '11px', opacity: 0.7 },
+ // The tab renders into a , so the root must be an or the list
+ // styling never reaches it.
+ card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
+ 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 },
row: { marginTop: '12px' },
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
@@ -93,7 +136,7 @@ window.__ModuleLoader__.load({
padding: '6px 8px',
fontSize: '12px',
borderRadius: '6px',
- border: '1px solid var(--dsh-border, #3a3a3a)',
+ border: BORDER,
background: 'transparent',
color: 'inherit',
},
@@ -102,14 +145,13 @@ window.__ModuleLoader__.load({
padding: '6px 12px',
fontSize: '12px',
borderRadius: '6px',
- border: '1px solid var(--dsh-border, #3a3a3a)',
+ border: BORDER,
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
},
primary: { fontWeight: 600 },
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
- dirty: { fontSize: '11px', opacity: 0.8 },
}
/** The editable string form of one field's value. */
@@ -180,6 +222,8 @@ window.__ModuleLoader__.load({
const [draft, setDraft] = react.useState(null)
const [busy, setBusy] = react.useState(false)
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])
@@ -196,6 +240,7 @@ window.__ModuleLoader__.load({
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 writable = snapshot.writable === true
const save = async () => {
setBusy(true)
@@ -211,6 +256,9 @@ window.__ModuleLoader__.load({
}
setDraft(null)
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) {
setNote(`Save failed: ${error?.message ?? error}`)
} finally {
@@ -233,49 +281,59 @@ window.__ModuleLoader__.load({
}
return h(
- 'div',
- { style: S.card },
+ 'li',
+ { style: open ? { ...S.card, ...S.cardOpen } : S.card },
h(
- 'div',
- { style: S.head },
- h('div', { style: S.title }, 'Slopo'),
+ 'button',
+ {
+ type: 'button',
+ style: S.header,
+ 'aria-expanded': open,
+ 'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
+ onClick: () => setOpen(!open),
+ },
h(
- 'div',
- { style: S.badge },
- status === 'loading' ? 'loading…' : 'configuration',
- snapshot.writable === true ? '' : ' · read-only',
+ '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 }, '▾'),
),
- 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) =>
- h(Field, {
- key: field.key,
- field,
- value: current[field.key],
- overridden: overriddenKeys.includes(field.key),
- disabled: busy || snapshot.writable !== true,
- onChange: (next) => setDraft({ ...current, [field.key]: next }),
- }),
- ),
- h(
- 'div',
- { style: S.actions },
- h(
- 'button',
- { type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
- dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
- ),
- h(
- 'button',
- { type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
- `Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
- ),
- note === '' ? null : h('span', { style: S.note }, note),
- ),
+ open
+ ? h(
+ 'div',
+ { style: S.body },
+ writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
+ ...FIELDS.map((field) =>
+ h(Field, {
+ key: field.key,
+ field,
+ value: current[field.key],
+ overridden: overriddenKeys.includes(field.key),
+ disabled: busy || !writable,
+ onChange: (next) => setDraft({ ...current, [field.key]: next }),
+ }),
+ ),
+ h(
+ 'div',
+ { style: S.actions },
+ h(
+ 'button',
+ { type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
+ dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
+ ),
+ h(
+ 'button',
+ { type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
+ `Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
+ ),
+ note === '' ? null : h('span', { style: S.note }, note),
+ ),
+ )
+ : null,
)
}
diff --git a/test/client.test.js b/test/client.test.js
index e6d016c..433cfb8 100644
--- a/test/client.test.js
+++ b/test/client.test.js
@@ -10,7 +10,8 @@
* 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
* 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'
@@ -19,9 +20,14 @@ import { test } from 'node:test'
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() {
- const states = []
+ const slots = []
let cursor = 0
return {
reset() {
@@ -32,15 +38,17 @@ function makeFakeReact() {
useState(initial) {
const index = cursor
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 [
- states[index],
+ slots[index],
(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) {
return factory()
},
@@ -62,12 +70,7 @@ function loadBundle() {
return { id: loaded.id, exported, react }
}
-/**
- * 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.
- */
+/** Resolve function components by calling them, until a host element or null. */
function expand(node, depth = 0) {
if (depth > 50) throw new Error('the component tree did not settle')
if (node === null || node === undefined) return null
@@ -150,10 +153,22 @@ function registered() {
},
}
exported.apply(ctx)
- const draw = (scope) => expand(render({ scope }))
- return { id, exported, react, registration, render, draw }
+ const draw = (scope) => {
+ 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) =>
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')
})
-test('the card renders a control for every setting the Host accepts', () => {
+test('the root is an , because the tab renders every card into a ', () => {
+ 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 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)
for (const expected of [
'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', () => {
- const { draw } = registered()
- const tree = draw(fakeScope(READY))
+ const { drawOpen } = registered()
+ const tree = drawOpen(fakeScope(READY))
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)
})
-test('editing a field marks the card dirty and saving writes it through set()', async () => {
- const { draw, react } = registered()
+test('editing a field marks the header unsaved, and saving writes it through set()', async () => {
+ const { draw, drawOpen, react } = registered()
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)
+ // Marked on the header, so it is visible while collapsed.
+ assert.match(textOf(headerOf(tree)), /unsaved/)
const save = buttonStartingWith(tree, 'Save')
assert.equal(save.props.disabled, false, 'a changed field must enable Save')
await save.props.onClick()
assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']])
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 () => {
- const { draw, react } = registered()
+ const { draw, drawOpen } = registered()
const scope = fakeScope(READY)
- inputFor(draw(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } })
- react.reset()
+ inputFor(drawOpen(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } })
await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]])
})
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 } })
- inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: '' } })
- react.reset()
+ inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: '' } })
await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.unset, ['embeddingModel'])
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 () => {
- const { draw, react } = registered()
+ const { draw, drawOpen } = registered()
const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } })
- inputFor(draw(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } })
- react.reset()
+ inputFor(drawOpen(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } })
await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]])
})
test('a boolean field writes a real boolean', async () => {
- const { draw, react } = registered()
+ const { draw, drawOpen } = registered()
const scope = fakeScope(READY)
const checkbox = findAll(
- draw(scope),
+ drawOpen(scope),
(node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing',
)[0]
checkbox.props.onChange({ target: { checked: true } })
- react.reset()
await buttonStartingWith(draw(scope), 'Save').props.onClick()
assert.deepEqual(scope.calls.set, [['autoInstall', true]])
})
test('an overridden field is marked, and Reset clears every override', async () => {
- const { draw } = registered()
+ const { drawOpen } = registered()
const scope = fakeScope({
...READY,
value: { 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)
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'])
})
-test('a read-only namespace disables every control', () => {
- const { draw } = registered()
- const tree = draw(fakeScope({ ...READY, writable: false, mode: 'memory' }))
+test('a read-only namespace says so and disables every control', () => {
+ const { drawOpen } = registered()
+ const tree = drawOpen(fakeScope({ ...READY, writable: false, mode: 'memory' }))
+ assert.match(textOf(tree), /read-only/)
const inputs = findAll(tree, (node) => node.type === 'input')
assert.ok(inputs.length > 0)
for (const input of inputs) assert.equal(input.props.disabled, true)