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:
1 parent
6b9d91b436
commit
2d7a7d1e73
3 files changed
+209
-88
No files matched your search
+106
-48
@@ -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 `<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
|
||||
* 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 <ul>, so the root must be an <li> 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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user