Files
cms-onpageeditor/src/ui/Rail.tsx
T
mats bb875a0956 editor: friendlier panels - collapsible rail, remembered state, no more overlap
Rail sections (Outline/Blocks/Nodes) collapse via their headers like
Experiments already did, so long pages stay navigable. Rail open state
and section toggles persist in localStorage across reloads (the QA
preview flow reloads constantly). The assistant panel shifts left while
the dev panel is open instead of stacking exactly underneath it, and
the rail/dev/assistant widths cap at the viewport on small screens.

Tests: rail-sections harness (collapse, reload persistence, shifted
assistant); harness boots with a fresh profile unless keepStorage is
passed; in-memory localStorage shim for the origin-less jsdom.

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

734 lines
32 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.
*/
export function Rail() {
const ctrl = useCtrl()
const open = useEditorState((s) => s.rail)
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
return (
<aside className="cms-rail" aria-label="Page panel">
<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">
<Icon name="close" />
</button>
</header>
<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>
)
}