CogniPrep's assessment centre practice covers six exercise types: e-tray, in-tray, written, presentation, group discussion and role-play. You can read what each one is on the assessment centre practice hub, and there is a free guide per type, for example the in-tray guide and the group exercise guide.
Six types do not need six runners. They need two, and one small function to decide which.
The mapping is four lines, with an override
/** Resolve the runner layout for an exercise (explicit, else derived from kind). */
export function getExerciseLayout(exercise: Exercise): ExerciseLayout {
if (exercise.layout) return exercise.layout;
return exercise.kind === 'e-tray' || exercise.kind === 'in-tray' ? 'inbox' : 'linear';
}
The derivation is the useful part, and so is the escape hatch in front of it. Kind is an editorial property of a scenario, layout is a property of the screen, and most of the time the first implies the second. When it stops implying it, the content sets layout and nothing in the component tree has to learn a new special case.
Everything with an inbox gets the workspace. Everything else gets the linear runner: a persistent brief plus a sequence of item cards.
Three panes, and the two classes holding them up
The inbox workspace is the classic mail client shape. Brief on the left, message list next to it, reading pane taking the rest:
<div className="flex min-h-0 flex-1">
<aside className="border-border w-full shrink-0 overflow-auto border-r lg:block lg:w-72 ...">
<BriefPanel exercise={exercise} />
</aside>
<div className="border-border w-full shrink-0 overflow-auto border-r lg:block lg:w-80 ...">
<InboxList ... />
</div>
<div className="min-w-0 flex-1 overflow-auto ...">
<ReadingPane ... />
</div>
</div>
Two utilities in there do all the quiet work.
min-h-0 on the row. A flex child's default min-height: auto refuses to shrink below its content, so overflow-auto on the panes inside it never gets a bounded height to scroll within. Without it the whole page grows and the three panes scroll as one, which is exactly what a workspace must not do.
min-w-0 on the reading pane. Same rule on the other axis. The pane holds truncate text, and a flex item that will not shrink below its content width makes text-overflow: ellipsis unreachable, so a long subject line pushes the layout sideways instead of being cut.
Those two classes are the difference between a workspace and a very tall web page.
On a phone, the same three panes are tabs
There is no responsive arrangement of three scrollable panes that works at 360px. So below the lg breakpoint the panes stop being side by side and start being one at a time:
const [mobileView, setMobileView] = useState<MobileView>('inbox');
<div className="border-border flex shrink-0 border-b lg:hidden">
{(['brief', 'inbox', 'task'] as MobileView[]).map((v) => (
<button
key={v}
onClick={() => setMobileView(v)}
className={`flex-1 py-2.5 text-sm font-medium capitalize ${
mobileView === v ? 'border-primary text-foreground border-b-2' : 'text-muted-foreground'
}`}
>
{v}
</button>
))}
</div>
Each pane then carries a pair of classes that read a little oddly out of context:
className={`... lg:block ${mobileView === 'brief' ? 'block' : 'hidden'}`}
So mobileView is authoritative on small screens and completely ignored on large ones, where lg:block wins. One piece of state, one breakpoint, no duplicated subtree and no useMediaQuery deciding what to render. Resize a laptop window across the breakpoint mid-exercise and nothing remounts, because nothing was conditionally rendered in the first place: the only thing that changed is which elements are display: none.
That is the trade being made, and it is worth saying out loud. All three panes are in the DOM on a phone, including a message list the candidate cannot currently see. For a fixed scenario with a handful of inbox messages that is a few dozen nodes, and it buys the thing that matters here: a half-written answer in the reading pane survives every tab switch, because the reading pane never went away.
One handler that moves the view
The interesting bug in a design like this is the dead tap. On a desktop, clicking a message in the list updates the pane to its right and you see it happen. On a phone, that pane is hidden, so the same click appears to do nothing at all.
const handleSelect = (id: string) => {
onSelectMessage(id);
setMobileView('task');
};
Selecting a message is also a navigation on mobile. The list and the pane are one screen on a laptop and two steps on a phone, and the handler is where that difference is absorbed rather than pushed into the list component, which still only knows that something was selected.
The reverse direction is left alone on purpose: finishing a message does not auto-switch you back to the inbox. An automatic return would move the screen out from under someone who is re-reading what they just answered.
What the linear runner reuses
The non-inbox types have no message list, so their layout is a brief and a stack of cards. The brief is permanent on desktop and a toggle on mobile:
const [showBrief, setShowBrief] = useState(false);
Both runners take the same props from the same parent: the exercise, the answers map, the two answer callbacks and onFinish. Both render ItemAnswerBlock for the items and BriefPanel for the brief. The split between them is layout and nothing else, which is why adding the sixth exercise type needed no new runner, only a line in the content.
If you want to see what the six types actually are before writing any of this, the guides are public and need no account: the presentation exercise and the role-play exercise are the two that most often surprise people.
Top comments (0)