From c346493a343814b8c790761e70da257b8508d3af Mon Sep 17 00:00:00 2001 From: "Kevin Carter (via Claude)" Date: Sat, 4 Jul 2026 13:13:46 +0000 Subject: [PATCH] WS-2 R3: containers/preamble as background backdrops, tick-N, no false overlap flag 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 Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV --- .../exam/setup/ExamTemplateSetupPage.tsx | 14 +++++++- src/pages/exam/setup/examCanvasShapes.tsx | 32 ++++++++++++++++++- src/utils/exam-canvas/model.ts | 4 ++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index 7186213..2023027 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -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, diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index f40bcb9..b2e8a81 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -184,6 +184,11 @@ function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode { const filled = Number(b.fill ?? 0) > 0.35 els.push(
) }) + // "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(tick {selectN}) + } const fal = m.final_answer_line if (fal && (fal.unit || fal.quantity)) { els.push({[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}) @@ -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 ( + + + {depth > 0 &&
} + + + {shape.props.label} + {typeof marks === 'number' && marks > 0 && · {marks}m} + + + ) +} + 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 ( - +
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 })