WS-2 R3: containers/preamble as background backdrops, tick-N, no false overlap flag
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled

From KC's 2nd review:
- z-order: boundaries, preamble bands and container frames drawn behind; figures,
  responses and parts on top (a figure's description tooltip is no longer blocked
  by the preamble box). Preamble + containers are pointerEvents:none.
- the 'large box around all content' is gone: a container renders as a small
  corner tab (label + total marks), not an enclosing box; intermediate part-groups
  keep a faint dashed nesting frame. Main-question extent is shown by its boundaries.
- MC 'tick N' count (select_n) surfaced next to the option boxes.
- fixed the spurious 'overlapping shapes' review flag on containers (they legitimately
  enclose their children; only genuine partial overlaps between non-containers flag now).
tsc clean; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
This commit is contained in:
Kevin Carter (via Claude) 2026-07-04 13:13:46 +00:00
parent 81e1b5ed25
commit c346493a34
3 changed files with 47 additions and 3 deletions

View File

@ -154,11 +154,23 @@ function bringDomainShapesToFront(editor: Editor) {
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
}
// Draw order = z-order (tldraw stacks by creation). Backdrops (boundaries, preamble bands, container frames)
// go first/behind so the figures, responses and parts on top stay hoverable and legible.
function zPriority(m: ExamCanvasShapeModel): number {
if (m.kind === 'boundary') return 0
if (m.kind === 'context' && m.contextType === 'preamble') return 1
if (m.kind === 'part' && m.isContainer) return 2
if (m.kind === 'part') return 3
if (m.kind === 'context') return 5 // figures/tables on top so their description tooltip is reachable
return 4 // responses + other regions
}
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!models.length) return
editor.createShapes(models.map((m) => ({
const ordered = [...models].sort((a, b) => zPriority(a) - zPriority(b))
editor.createShapes(ordered.map((m) => ({
id: createShapeId(m.id),
type: SHAPE_TYPES[m.kind],
x: m.x,

View File

@ -184,6 +184,11 @@ function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode {
const filled = Number(b.fill ?? 0) > 0.35
els.push(<div key={`bx${i}`} title={`option ${i + 1}${filled ? ' (marked)' : ''}`} style={{ position: 'absolute', left: pct(rel.x0), top: pct(rel.y0), width: pct(Number(rel.x1) - Number(rel.x0)), height: pct(Number(rel.y1) - Number(rel.y0)), border: '1.5px solid var(--exam-stroke)', borderRadius: 3, background: filled ? 'var(--exam-stroke)' : 'transparent', opacity: filled ? 0.5 : 0.85, boxSizing: 'border-box' }} />)
})
// "tick N" instruction for multiple-choice (select_n = how many boxes to tick)
const selectN = Number(m.select_n ?? 0)
if (selectN > 0 && boxes.length) {
els.push(<span key="tickn" className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: 2, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>tick {selectN}</span>)
}
const fal = m.final_answer_line
if (fal && (fal.unit || fal.quantity)) {
els.push(<span key="fal" className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 4, top: `calc(${pct(fal.rel_y, 0.9)} - 8px)`, maxWidth: 'calc(100% - 8px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 10, fontWeight: 800, borderRadius: 6, padding: '0 5px', opacity: 0.95 }} title="expected final answer">{[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}</span>)
@ -191,6 +196,29 @@ function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode {
return els.length ? <>{els}</> : null
}
// A container question is a BACKDROP, not a big box over the content: the main question (depth 0) relies on
// its start/end boundary rules for extent and shows only a small corner tab (label + total marks); an
// intermediate part-group (depth ≥ 1) also gets a faint dashed frame to convey nesting. Non-interactive so it
// never blocks a hover on the figures/responses on top.
function renderContainerTab(shape: ExamCanvasTLShape) {
const depth = shape.props.depth ?? 0
const stroke = containerStroke(depth)
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
const marks = shape.props.maxMarks
const title = provenanceTitle(shape, depth === 0 ? 'Question (container)' : 'Part group (container)')
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none', overflow: 'visible' }}>
<style>{shapeCss}</style>
{depth > 0 && <div style={{ position: 'absolute', inset: 0, border: `1px dashed ${stroke}`, borderRadius: 12, opacity: 0.3, boxSizing: 'border-box' }} />}
<span className="exam-canvas-shape__pill" title={title} style={{ position: 'absolute', left: 0, top: -3, display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 900, borderRadius: 8, padding: '2px 7px', color: stroke, border: `1.5px solid ${stroke}`, background: 'rgba(255,255,255,0.92)', whiteSpace: 'nowrap' }}>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (depth === 0 ? '▣' : '▢')}</span>
{shape.props.label}
{typeof marks === 'number' && marks > 0 && <span style={{ opacity: 0.85 }}>· {marks}m</span>}
</span>
</HTMLContainer>
)
}
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
@ -198,6 +226,8 @@ function renderShape(shape: ExamCanvasTLShape) {
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
if (isBoundary) return renderBoundaryLine(shape)
const isContainer = shape.props.isContainer === true && kind === 'part'
if (isContainer) return renderContainerTab(shape)
const isPreamble = kind === 'context' && shape.props.contextType === 'preamble'
const depth = shape.props.depth ?? 0
const stroke = isContainer ? containerStroke(depth) : p.stroke
const darkStroke = isContainer ? containerStroke(depth) : p.darkStroke
@ -206,7 +236,7 @@ function renderShape(shape: ExamCanvasTLShape) {
const roleLabel = isContainer ? (depth === 0 ? 'Question: container' : 'Part: container') : `${p.label}: ${p.role}`
const title = shape.props.description ? `${provenanceTitle(shape, roleLabel)}${shape.props.description}` : provenanceTitle(shape, roleLabel)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: isPreamble ? 'none' : 'all' }}>
<style>{shapeCss}</style>
<div
className={`exam-canvas-shape exam-canvas-shape--${kind}${isContainer ? ' exam-canvas-shape--container' : ''}`}

View File

@ -288,7 +288,9 @@ function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageG
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
const samePageOverlap = shapes.some((other, otherIndex) => otherIndex !== index && shape.kind !== 'boundary' && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && (shape.kind === other.kind || (!contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))))
// Containers are MEANT to enclose their children, so never flag them (or their children) for overlap;
// flag only genuine partial overlaps between non-container shapes where neither contains the other.
const samePageOverlap = !shape.isContainer && shape.kind !== 'boundary' && shapes.some((other, otherIndex) => otherIndex !== index && !other.isContainer && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && !contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))
if (samePageOverlap) flags.push('overlapping shapes')
return flags.length ? { ...shape, reviewFlags: flags } : shape
})