Files
cms-onpageeditor/src/ui/Rail.tsx
T
mats fc0eb2c6be editor: resizable rail, Esc closes panel, dev tab remembered
Rail gets a drag handle on its edge (240-640px, width persisted like
the rest of the panel state). Escape now closes the panel as the last
step of its chain (dialog > inline > selection > panel). The dev panel
remembers its tab across reloads. Panel toggle button title notes the
Esc shortcut. Harness covers resize/persist, Esc-close and dev tab.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-11-07 12:40:46 +01:00

763 lines
33 KiB
TypeScript

import { createElement, useEffect, useMemo, useRef, useState } from 'react'
import { InsertPicker } from '../editors/builtin'
import type { CmsNode, EvaluateContext, ExperimentAnalytics, ExperimentAssignment, ExperimentRule, TypeInfo } from '../api'
import { allowedChildren, connectionsOf, labelKey, sortGroups, typeName } from '../schema'
import type { DropTarget } from '../page/dnd'
import { Icon, TypeIcon } from './Icon'
import { Summary } from './Summary'
import { useCtrl, useEditorState, usePersistentState } from './hooks'
import type { Block, Controller } from '../controller'
/**
* Left panel: outline + blocks + nodes in one place (no tabs).
*
* - Outline is a tree with its slots (e.g. a pdp's `info-section` /
* `3d-accessories` connections): drag any row by its grip to reorder, drop
* palette blocks and searched nodes onto the gaps between rows or into slots.
* - Blocks are the new types that fit this page (drag onto the page or the outline).
* - Nodes finds *existing* nodes (type filter + text) to link into the page.
*/
const RAIL_W_MIN = 240
const RAIL_W_MAX = 640
export function Rail() {
const ctrl = useCtrl()
const open = useEditorState((s) => s.rail)
const [width, setWidth] = usePersistentState<number>('cms-onpage.railwidth', 320)
useEffect(() => {
if (!open) return
// Types carry every schema (titles, groups, slots): one request seeds them all.
void ctrl.api
.types()
.then((ts) => {
ctrl.ingestTypes(ts)
void ctrl.loadSchemas(ts.map(typeName))
})
.catch(() => {})
void ctrl.fillHandles()
}, [open, ctrl])
if (!open) return null
const w = Math.max(RAIL_W_MIN, Math.min(width, window.innerWidth - 24))
const startResize = (e: React.PointerEvent) => {
if (e.button !== 0) return
e.preventDefault()
const doc = e.currentTarget.ownerDocument
const rail = e.currentTarget.parentElement!
const move = (m: PointerEvent) => {
const left = rail.getBoundingClientRect().left
setWidth(Math.round(Math.max(RAIL_W_MIN, Math.min(RAIL_W_MAX, m.clientX - left))))
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}
return (
<aside className="cms-rail" aria-label="Page panel" style={{ width: w }}>
<header>
<span className="cms-rail__title">
<Icon name="outline" size={16} /> Page
</span>
<button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel" title="Close panel (Esc)">
<Icon name="close" />
</button>
</header>
<div
className="cms-rail__resize"
role="separator"
aria-orientation="vertical"
aria-label="Resize panel"
title="Drag to resize"
onPointerDown={startResize}
/>
<div className="cms-rail__body">
<OutlineTree />
<Palette />
<NodeSearch />
<ExperimentsPanel />
</div>
</aside>
)
}
// --- outline -----------------------------------------------------------------
type Gap = { parentId: string; handle?: string; index: number }
function OutlineTree() {
const ctrl = useCtrl()
const blocks = useEditorState((s) => s.blocks)
const selected = useEditorState((s) => s.selected)
const drag = useEditorState((s) => s.drag)
useEditorState((s) => s.tick)
useEditorState((s) => s.schemasVersion)
useEditorState((s) => s.labelsVersion)
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [over, setOver] = useState<Gap | null>(null)
const dragging = !!drag
// Our pointerup runs before the page engine's (registered at mount, theirs at
// drag start): a drop on a gap commits to the outline, anywhere else on the
// panel just cancels the page ghost.
useEffect(() => {
const onUp = (e: PointerEvent) => {
const d = ctrl.store.get().drag
if (!d) return
const path = e.composedPath() as Element[]
const inRail = path.some((el) => el instanceof Element && el.classList?.contains('cms-rail'))
if (!inRail) return
e.stopImmediatePropagation()
e.preventDefault()
const gapEl = path.find((el) => el instanceof Element && el.hasAttribute?.('data-ogap'))
const target: DropTarget | null = gapEl
? {
parentId: gapEl.getAttribute('data-parent') ?? '',
handle: gapEl.getAttribute('data-handle') || undefined,
index: Number(gapEl.getAttribute('data-index') ?? 0),
line: { x: 0, y: 0, w: 0, h: 0 },
}
: null
setOver(null)
ctrl.cancelDrag()
if (!target) return
if (d.source === 'block' && d.id) void ctrl.dropBlock(d.id, target)
else if (d.source === 'existing' && d.nodeId) void ctrl.dropExisting(target, d.nodeId)
else if (d.source === 'palette' && d.type && !d.blueprint) void ctrl.dropNew(target, { type: d.type })
else if (d.source === 'palette' && d.blueprint && !target.handle) void ctrl.dropNew(target, { blueprint: d.blueprint })
else if (d.source === 'palette' && d.blueprint && target.handle) ctrl.toast('info', 'Blueprints cannot go straight into a slot — drop it on the page first, then move it')
}
ctrl.doc.addEventListener('pointerup', onUp, true)
return () => ctrl.doc.removeEventListener('pointerup', onUp, true)
}, [ctrl])
useEffect(() => {
if (!drag) setOver(null)
}, [drag])
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true)
if (!blocks.length) return null
return (
<section aria-label="Outline">
<h4 className="cms-rail__sec">
<button className="cms-sectoggle" aria-expanded={open} aria-label="Outline" onClick={() => setOpen((o) => !o)}>
Outline ({blocks.length})
</button>
</h4>
{open && (
<>
<ul className="cms-outline cms-outline--tree">
{roots.map((b) => (
<OutlineNode key={b.id} block={b} level={0} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
))}
</ul>
{dragging && <OutlineGap parentId="" index={roots.length} over={over} setOver={setOver} />}
</>
)}
</section>
)
}
function OutlineGap({ parentId, handle, index, over, setOver }: { parentId: string; handle?: string; index: number; over: Gap | null; setOver: (g: Gap | null) => void }) {
const ctrl = useCtrl()
const drag = useEditorState((s) => s.drag)
if (!drag) return null
const active = over?.parentId === parentId && (over?.handle ?? '') === (handle ?? '') && over?.index === index
// Blueprints cannot land in slots; slot gaps refuse them.
const refused = !!drag.blueprint && !!handle
// Page drops onto a type the slot does not take are refused too.
const typeRefused = !!drag.type && !drag.blueprint && parentId !== '' && !ctrl.accepts(parentId, drag.type) && !(handle ? ctrl.acceptsInSlot(parentId, handle, drag.type) : ctrl.handleForDrop(parentId, drag.type))
return (
<div
className={`cms-ogap${active && !refused ? ' is-over' : ''}${refused || typeRefused ? ' is-refused' : ''}`}
data-ogap=""
data-parent={parentId}
data-handle={handle ?? ''}
data-index={index}
onPointerEnter={() => (refused ? setOver(null) : setOver({ parentId, handle, index }))}
onPointerLeave={() => setOver(null)}
/>
)
}
function OutlineNode({ block: b, level, collapsed, setCollapsed, over, setOver, dragging, selected }: {
block: Block
level: number
collapsed: Set<string>
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
over: Gap | null
setOver: (g: Gap | null) => void
dragging: boolean
selected: string | null
}) {
const ctrl = useCtrl()
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === b.id && !ctrl.handleOf(x.id)))
const slots = useMemo(() => ctrl.slotsFor(b.id), [ctrl, b.id, b.schema, kids.length])
useEditorState((s) => s.tick)
const isCollapsed = collapsed.has(b.id)
const isSel = b.id === selected
const toggle = () => setCollapsed((s) => {
const n = new Set(s)
if (n.has(b.id)) n.delete(b.id)
else n.add(b.id)
return n
})
const title = ctrl.labelFor(b.id)
const hasKids = kids.length > 0 || slots.some((sl) => ctrl.slotChildren(b.id, sl.name).length > 0)
return (
<li>
<div className={`cms-orow${isSel ? ' is-sel' : ''}`} style={{ paddingLeft: 4 + level * 14 }}>
{(b.container || slots.length > 0) && hasKids ? (
<button className="cms-ico cms-orow__chev" onClick={toggle} aria-label={isCollapsed ? 'Expand' : 'Collapse'} title={isCollapsed ? 'Expand' : 'Collapse'}>
<span className={`cms-chev${isCollapsed ? '' : ' is-open'}`} style={{ display: 'inline-flex' }}>
<Icon name="chevron" size={14} />
</span>
</button>
) : (
<span className="cms-orow__spacer" />
)}
<span
className="cms-orow__grip"
title="Drag to reorder"
aria-label={`Drag ${title ?? b.type} to reorder`}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
const x0 = e.clientX
const y0 = e.clientY
const doc = e.currentTarget.ownerDocument
const move = (m: PointerEvent) => {
if (Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
ctrl.setMode('edit')
ctrl.beginDrag(b.id)
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}}
>
<Icon name="grip" size={14} />
</span>
<button
className="cms-orow__main"
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
onMouseLeave={() => ctrl.store.set({ hovered: null })}
onClick={() => {
ctrl.setMode('edit')
ctrl.select(b.id, { scroll: true })
}}
title={`${b.type}${ctrl.handleOf(b.id) ? ` · in slot ${ctrl.handleOf(b.id)}` : ''}`}
>
<TypeIcon type={b.type} size={15} />
{title ? (
<>
<span className="cms-outline__type">{title}</span>
<span className="cms-outline__text">{b.type}</span>
</>
) : (
<>
<span className="cms-outline__type">{b.type || '?'}</span>
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
</>
)}
{ctrl.handleOf(b.id) && <em className="cms-oslot-tag">{ctrl.handleOf(b.id)}</em>}
</button>
</div>
{!isCollapsed && (
<ul className="cms-outline cms-outline--nested">
{dragging && <OutlineGap parentId={b.id} index={0} over={over} setOver={setOver} />}
{kids.map((k, i) => (
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
))}
{dragging && kids.length > 0 && <OutlineGap parentId={b.id} index={kids.length} over={over} setOver={setOver} />}
{slots.map((sl) => (
<SlotGroup key={sl.name} parentId={b.id} level={level + 1} slot={sl} over={over} setOver={setOver} dragging={dragging} collapsed={collapsed} setCollapsed={setCollapsed} selected={selected} />
))}
</ul>
)}
</li>
)
}
/** A plain (non-slot) child: its own row, followed by the gap after it. */
function OutlineNodeInner(props: { block: Block; index: number; level: number } & Omit<Parameters<typeof OutlineNode>[0], 'block' | 'level'>) {
const { block, index, level, ...rest } = props
return (
<>
<OutlineNode block={block} level={level} {...rest} />
{rest.dragging && <OutlineGap parentId={block.parentId ?? ''} index={index + 1} over={rest.over} setOver={rest.setOver} />}
</>
)
}
function SlotGroup({ parentId, level, slot, over, setOver, dragging, collapsed, setCollapsed, selected }: {
parentId: string
level: number
slot: { name: string; def: { to: string[]; kind: string; description?: string }; count: number; accepts: (t: string) => boolean }
over: Gap | null
setOver: (g: Gap | null) => void
dragging: boolean
collapsed: Set<string>
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
selected: string | null
}) {
const ctrl = useCtrl()
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === parentId && ctrl.handleOf(x.id) === slot.name))
const add = () => {
let key = 0
key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) })
}
return (
<li className="cms-oslot" style={{ marginLeft: level * 14 }}>
<div className="cms-oslot__head" title={slot.def.description ?? `Slot: ${slot.name}`}>
<Icon name="shape" size={13} />
<span>{slot.name}</span>
<span className="cms-muted">{slot.def.to.length ? slot.def.to.join(', ') : 'any'}</span>
<button className="cms-ico" onClick={add} title={`Add to ${slot.name}`} aria-label={`Add to ${slot.name}`}>
<Icon name="add" size={14} />
</button>
</div>
<ul className="cms-outline cms-outline--nested">
{dragging && <OutlineGap parentId={parentId} handle={slot.name} index={0} over={over} setOver={setOver} />}
{kids.map((k, i) => (
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
))}
{dragging && kids.length > 0 && <OutlineGap parentId={parentId} handle={slot.name} index={kids.length} over={over} setOver={setOver} />}
</ul>
</li>
)
}
// --- palette (new blocks) -------------------------------------------------------
const DRAG_THRESHOLD = 5
type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string }
function Palette() {
const ctrl = useCtrl()
const [types, setTypes] = useState<TypeInfo[] | null>(null)
const [blueprints, setBlueprints] = useState<import('../api').Blueprint[]>([])
const [q, setQ] = useState('')
const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks }))
const schemasVersion = useEditorState((s) => s.schemasVersion)
const suppressClick = useRef(false)
useEffect(() => {
void ctrl.api
.types()
.then((ts) => {
ctrl.ingestTypes(ts) // /api/types includes every schema: titles + groups with no further requests
setTypes(ts)
void ctrl.loadSchemas(ts.map(typeName)) // only for entries that arrived without a schema
})
.catch(() => setTypes([]))
void ctrl.api.blueprints().then(setBlueprints).catch(() => {})
}, [ctrl])
// Types some container on the page (or the page itself) can hold - the rest would only ever be refused.
const items = useMemo<Item[]>(() => {
if (!types) return []
const all = types.map(typeName).filter(Boolean)
const allowed = new Set<string>()
for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t))
// Slots accept more than plain children (e.g. a pdp's info-section): include slot types too.
for (const b of blocks) for (const h of Object.keys(connectionsOf(b.schema))) for (const t of connectionsOf(b.schema)[h]?.to ?? []) allowed.add(t)
const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing
return [
...types
.map((t) => ({ t, name: typeName(t) }))
.filter(({ name }) => name && !ctrl.isStaticRoot(name) && (rootAllowed || allowed.has(name)))
.map(({ name }) => {
const title = ctrl.typeTitle(name)
return { key: `t:${name}`, label: title, sub: title !== name ? name : undefined, type: name, group: ctrl.groupOfType(name), description: ctrl.schemaOf(name)?.description as string | undefined }
}),
...blueprints.map((b) => ({ key: `b:${b.id}`, label: b.name ?? b.id, blueprint: b.id, group: 'Blueprints', description: b.description })),
]
}, [types, blueprints, blocks, ctrl, schemasVersion])
const needle = q.toLowerCase()
const shown = items.filter((i) => i.label.toLowerCase().includes(needle) || (i.type ?? '').toLowerCase().includes(needle))
const groups = sortGroups(shown.map((i) => i.group).filter((g) => g !== 'Blueprints'))
if (shown.some((i) => i.group === 'Blueprints')) groups.push('Blueprints')
const addToSelection = async (item: Item) => {
const sel = ctrl.block(selected)
try {
let parent: string
let index: number | undefined
let handle: string | undefined
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type) || (handle = ctrl.handleForDrop(sel.id, item.type)))) parent = sel.id
else if (sel) {
parent = await ctrl.parentIdOf(sel.id)
handle = ctrl.handleOf(sel.id)
index = (await ctrl.position(sel.id)).index + 1
} else parent = await ctrl.pageRoot()
if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index)
else if (item.type) await ctrl.insert(parent, item.type, { ...(index !== undefined ? { index } : {}), ...(handle ? { handle } : {}) })
} catch (e) {
ctrl.toast('error', (e as Error).message)
}
}
const onPointerDown = (e: React.PointerEvent, item: Item) => {
if (e.button !== 0) return
const x0 = e.clientX
const y0 = e.clientY
const doc = e.currentTarget.ownerDocument
const move = (m: PointerEvent) => {
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
stop()
suppressClick.current = true
ctrl.setMode('edit')
ctrl.beginPaletteDrag({ type: item.type, blueprint: item.blueprint, label: item.label })
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}
const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true)
return (
<section aria-label="Blocks">
<h4 className="cms-rail__sec">
<button className="cms-sectoggle" aria-expanded={open} aria-label="Blocks" onClick={() => setOpen((o) => !o)}>
Blocks
</button>
</h4>
{open && (
<>
<div className="cms-pad">
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
<p className="cms-muted cms-hint">Drag onto the page or the outline, or click to add to the selection.</p>
</div>
{!types && <p className="cms-muted cms-pad">Loading…</p>}
{groups.map((g) => (
<details key={g} className="cms-group-sec" open>
<Summary>
{g} <span className="cms-muted">{shown.filter((i) => i.group === g).length}</span>
</Summary>
<div className="cms-palette__grid">
{shown
.filter((i) => i.group === g)
.map((i) => (
<button
key={i.key}
className="cms-block"
data-type={i.type}
title={i.description ?? `Add ${i.label}`}
onPointerDown={(e) => onPointerDown(e, i)}
onClick={() => {
if (suppressClick.current) return void (suppressClick.current = false)
void addToSelection(i)
}}
>
<TypeIcon type={i.type ?? i.label} size={22} />
<span>{i.label}</span>
{i.sub && <small>{i.sub}</small>}
</button>
))}
</div>
</details>
))}
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
</>
)}
</section>
)
}
// --- node search (existing nodes) -----------------------------------------------
function nodeTitle(ctrl: Controller, n: CmsNode): string | undefined {
const key = labelKey(ctrl.schemaOf(n.dataType))
const v = key ? (n.data as Record<string, unknown>)?.[key] : undefined
const text = typeof v === 'string' || typeof v === 'number' ? String(v).replace(/\s+/g, ' ').trim() : ''
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text
const first = Object.values(n.data ?? {}).find((x) => typeof x === 'string' && x.trim())
const fb = typeof first === 'string' ? first.trim().slice(0, 40) : ''
return fb || undefined
}
function NodeSearch() {
const ctrl = useCtrl()
const [typeNames, setTypeNames] = useState<string[]>([])
const [type, setType] = useState('')
const [q, setQ] = useState('')
const [results, setResults] = useState<CmsNode[] | null>(null)
const [total, setTotal] = useState(0)
const [busy, setBusy] = useState(false)
const selected = useEditorState((s) => s.selected)
useEditorState((s) => s.schemasVersion)
useEffect(() => {
void ctrl.api
.types()
.then((ts) => {
ctrl.ingestTypes(ts)
setTypeNames(ts.map(typeName).filter(Boolean).sort())
})
.catch(() => {})
}, [ctrl])
useEffect(() => {
const t = setTimeout(() => {
setBusy(true)
void ctrl.api
.searchNodes({ type: type || undefined, q: q || undefined, limit: 20 })
.then((r) => {
setResults(r.items)
setTotal(r.total)
void ctrl.loadSchemas([...new Set(r.items.map((n) => n.dataType))])
})
.catch(() => setResults([]))
.finally(() => setBusy(false))
}, q ? 250 : 0)
return () => clearTimeout(t)
}, [q, type, ctrl])
const linkToSelection = async (node: CmsNode) => {
const sel = ctrl.block(selected)
try {
if (sel && sel.container && (ctrl.accepts(sel.id, node.dataType) || ctrl.handleForDrop(sel.id, node.dataType))) {
await ctrl.linkExisting(sel.id, node.id, { handle: ctrl.handleForDrop(sel.id, node.dataType) })
} else if (sel) {
const parent = await ctrl.parentIdOf(sel.id)
const { index } = await ctrl.position(sel.id)
await ctrl.linkExisting(parent, node.id, { handle: ctrl.handleOf(sel.id), index: index + 1 })
} else {
await ctrl.linkExisting(await ctrl.pageRoot(), node.id, {})
}
} catch (e) {
ctrl.toast('error', (e as Error).message)
}
}
const onPointerDown = (e: React.PointerEvent, node: CmsNode) => {
if (e.button !== 0) return
const x0 = e.clientX
const y0 = e.clientY
const doc = e.currentTarget.ownerDocument
const move = (m: PointerEvent) => {
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
stop()
ctrl.setMode('edit')
ctrl.beginExistingDrag({ id: node.id, type: node.dataType, label: nodeTitle(ctrl, node) ?? node.dataType })
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}
const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true)
return (
<section aria-label="Nodes">
<h4 className="cms-rail__sec">
<button className="cms-sectoggle" aria-expanded={open} aria-label="Nodes" onClick={() => setOpen((o) => !o)}>
Nodes
</button>
</h4>
{open && (
<>
<div className="cms-pad cms-nodefind">
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
<select className="cms-input" aria-label="Filter by type" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All types</option>
{typeNames.map((t) => (
<option key={t} value={t}>
{ctrl.typeTitle(t)}
</option>
))}
</select>
<p className="cms-muted cms-hint">Link a shared block into the page — drag it onto the outline, or click + to add to the selection.</p>
</div>
{busy && !results && <p className="cms-muted cms-pad">Searching…</p>}
{results && !results.length && <p className="cms-muted cms-pad">No nodes match.</p>}
{!!results?.length && (
<ul className="cms-nodelist">
{results.map((n) => (
<li key={n.id} className="cms-noderow" onPointerDown={(e) => onPointerDown(e, n)} title={`${n.dataType} · ${n.id}`}>
<TypeIcon type={n.dataType} size={15} />
<span className="cms-noderow__main">
<span className="cms-outline__type">{nodeTitle(ctrl, n) ?? n.dataType}</span>
<span className="cms-outline__text">
{n.dataType} · {n.id.slice(0, 8)}
</span>
</span>
<button
className="cms-ico"
aria-label={`Link ${nodeTitle(ctrl, n) ?? n.id}`}
title="Link into the page"
onClick={(e) => {
e.stopPropagation()
void linkToSelection(n)
}}
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="add" size={15} />
</button>
</li>
))}
</ul>
)}
{!!results?.length && total > results.length && <p className="cms-muted cms-pad">Showing {results.length} of {total} — refine the search.</p>}
</>
)}
</section>
)
}
// --- experiments: QA + eligibility on the page (plan-onpage-experiments-personalisation.md P0) ---------
const expCookie = (name: string): string | null => {
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
return m ? decodeURIComponent(m[1]) : null
}
const setExpCookie = (name: string, value: string | null) => {
document.cookie = value === null ? `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` : `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax`
}
const targetingSummary = (rules?: ExperimentRule['targeting']): string =>
!rules?.length ? 'everyone' : rules.map((r) => `${r.field} ${r.op} ${r.value ?? (r.values ?? []).join('|')}`).join(' · ')
const armNames = (rule: ExperimentRule): string[] => Object.keys(rule.variant_weights ?? {})
function ExperimentsPanel() {
const ctrl = useCtrl()
const [rules, setRules] = useState<ExperimentRule[] | null>(null)
const [adminDenied, setAdminDenied] = useState(false)
const [assign, setAssign] = useState<Record<string, ExperimentAssignment>>({})
const [stats, setStats] = useState<Record<string, ExperimentAnalytics>>({})
const [confirmOff, setConfirmOff] = useState<string | null>(null)
const [tick, setTick] = useState(0) // re-read override cookies after set/clear
const [open, setOpen] = usePersistentState('cms-onpage.sec.experiments', false)
useEffect(() => {
if (!open) return
let live = true
const ctx: EvaluateContext = { pagePath: location.pathname, referrer: document.referrer || undefined, language: navigator.language || undefined }
void ctrl.api
.trackingConfig()
.then((cfg) => {
if (!live) return
const rs = cfg.experiment_rules ?? []
setRules(rs)
if (!rs.length) return
void ctrl.api.evaluateExperiments(rs.map((r) => r.experiment_id), ctx).then(
(res) => live && setAssign(Object.fromEntries((res.assignments ?? []).map((a) => [a.experimentId, a]))),
() => {}, // evaluate is public; a failure just leaves assignments blank
)
void ctrl.api.experimentAnalytics().then(
(rows) => live && setStats(Object.fromEntries((rows ?? []).map((s) => [s.experiment_id, s]))),
() => live && setAdminDenied(true), // admin-gated: eligibility still works, stats hide
)
})
.catch(() => live && (setRules([]), setAdminDenied(true)))
return () => void (live = false)
}, [open, ctrl])
const toggleDisabled = async (rule: ExperimentRule) => {
if (confirmOff !== rule.experiment_id) return void setConfirmOff(rule.experiment_id)
setConfirmOff(null)
try {
const res = await ctrl.api.setExperimentDisabled(rule.experiment_id, !rule.disabled)
setRules((rs) => rs?.map((r) => (r.experiment_id === rule.experiment_id ? { ...r, disabled: res.disabled } : r)) ?? rs)
ctrl.toast('info', `${rule.experiment_id} ${res.disabled ? 'paused' : 'resumed'}`)
} catch (e) {
ctrl.toast('error', (e as Error).message)
}
}
return (
<section aria-label="Experiments panel">
<h4 className="cms-rail__sec">
<button className="cms-sectoggle" aria-expanded={open} aria-label="Experiments" onClick={() => setOpen((o) => !o)}>
Experiments{rules ? ` (${rules.length})` : ''}
</button>
</h4>
{open && (
<div className="cms-pad">
{rules === null && <p className="cms-muted">Loading experiments…</p>}
{rules !== null && !rules.length && <p className="cms-muted">No experiments configured in tracking.</p>}
{adminDenied && !!rules?.length && <p className="cms-muted cms-hint">Tracking admin refused — eligibility still works; stats and pause need an admin credential.</p>}
{!!rules?.length && (
<ul className="cms-explist">
{rules.map((rule) => {
const a = assign[rule.experiment_id]
const s = stats[rule.experiment_id]
const arms = armNames(rule)
const cookie = expCookie(a?.cookieName ?? `exp_${rule.experiment_id}`)
void tick
return (
<li key={rule.experiment_id} className="cms-exp">
<div className="cms-exp__head">
<strong>{rule.experiment_id}</strong>
<span className={`cms-pill${rule.disabled ? ' is-off' : ''}`}>{rule.disabled ? 'paused' : 'live'}</span>
</div>
<div className="cms-muted cms-exp__meta">
traffic {Math.round((rule.traffic_fraction ?? 1) * 100)}% · arms {arms.length ? arms.join(' / ') : 'a / b'} · {targetingSummary(rule.targeting)}
</div>
<div className="cms-exp__assign">
{a ? (a.eligible ? <>you: <b>{a.variant || '—'}</b></> : 'you: not eligible') : 'you: …'}
{cookie && cookie !== a?.variant && <span className="cms-muted"> (override: {cookie})</span>}
</div>
{s && (
<div className="cms-muted cms-exp__meta">
{s.total_sessions} sessions · winner <b>{s.winner || '—'}</b>
{s.winner ? ` (${Math.round((s.winner_probability ?? 0) * 100)}%)` : ''}
{s.significant ? ' · significant' : ''} — {s.conclusion || 'collecting data'}
</div>
)}
<div className="cms-exp__actions">
<label className="cms-muted">
QA arm{' '}
<select
className="cms-input"
aria-label={`Force variant for ${rule.experiment_id}`}
value={cookie ?? ''}
onChange={(e) => {
setExpCookie(a?.cookieName ?? `exp_${rule.experiment_id}`, e.target.value || null)
setTick((t) => t + 1)
}}
>
<option value="">session default</option>
{arms.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<button
className={`cms-ico${confirmOff === rule.experiment_id ? ' is-on' : ''}`}
aria-label={rule.disabled ? `Resume ${rule.experiment_id}` : `Pause ${rule.experiment_id}`}
title={rule.disabled ? 'Resume (re-buckets new sessions)' : 'Pause (kill-switch: new sessions see control)'}
onClick={() => void toggleDisabled(rule)}
>
<Icon name={confirmOff === rule.experiment_id ? 'check' : rule.disabled ? 'play' : 'pause'} size={15} />
{confirmOff === rule.experiment_id && <span>confirm</span>}
</button>
</div>
</li>
)
})}
</ul>
)}
<p className="cms-muted cms-hint">Overrides set the storefront variant cookie — reload the page to preview the arm.</p>
</div>
)}
</section>
)
}