diff --git a/src/fixtures/projects.ts b/src/fixtures/projects.ts
index 8113547..b885bfa 100644
--- a/src/fixtures/projects.ts
+++ b/src/fixtures/projects.ts
@@ -173,6 +173,91 @@ export const PROJECTS: Project[] = [
'',
].join('\n'),
},
+ // The two below exist so an import flow can be tested against a project
+ // that CANNOT be imported. Everything above runs in an editor Code Classroom
+ // can embed; these need a Raspberry Pi and something plugged into it, so a
+ // young person cannot open them inside a class however well they suit a
+ // club session. One Scratch and one Python, so a filter has visible work to
+ // do in either technology.
+ //
+ // Physical computing is a real and sizeable part of the live catalogue, and
+ // it is exactly what the "should we only allow embedded editor projects?"
+ // question on the Code Classroom FigJam is about.
+ {
+ id: 'rain-or-shine',
+ title: 'Rain or Shine',
+ language: 'Scratch',
+ ages: '9–13',
+ level: 2,
+ usableInClassroom: false,
+ intro: [
+ 'Build a weather station that records rainfall and shows it on screen.',
+ '',
+ 'You will need a **Raspberry Pi** and a rain sensor connected to its pins.',
+ '',
+ 'You will:',
+ '',
+ '- Wire a sensor to the `GPIO` pins',
+ '- Read the sensor from Scratch',
+ '- Draw a chart of what you collect',
+ ].join('\n'),
+ steps: [
+ {
+ title: 'What you will make',
+ body: [
+ 'A **weather station** that notices when it is raining and keeps a record.',
+ '',
+ 'This project needs hardware: a Raspberry Pi and a rain sensor.',
+ ].join('\n'),
+ },
+ {
+ title: 'Wire up the sensor',
+ body: 'Connect the sensor to the `GPIO` pins, following the diagram.',
+ },
+ ],
+ starterCode: '// Scratch starter project — GPIO sensor blocks\n',
+ },
+ {
+ id: 'door-watcher',
+ title: 'Door Watcher',
+ language: 'Python',
+ ages: '10–14',
+ level: 3,
+ usableInClassroom: false,
+ intro: [
+ 'Make an alarm that tells you when someone opens a door.',
+ '',
+ 'You will need a **Raspberry Pi** and a motion sensor.',
+ '',
+ 'You will:',
+ '',
+ '- Read a sensor with `gpiozero`',
+ '- Play a sound when it triggers',
+ ].join('\n'),
+ steps: [
+ {
+ title: 'What you will make',
+ body: [
+ 'A **door alarm**, built from a Raspberry Pi and a motion sensor.',
+ '',
+ 'The code runs on the Pi itself, not in a browser.',
+ ].join('\n'),
+ },
+ {
+ title: 'Read the sensor',
+ body: 'Use `gpiozero` to tell when the sensor has been triggered.',
+ },
+ ],
+ starterCode: [
+ '# Door Watcher',
+ '# Runs on a Raspberry Pi with a motion sensor attached.',
+ '',
+ 'from gpiozero import MotionSensor',
+ '',
+ 'sensor = MotionSensor(4)',
+ '',
+ ].join('\n'),
+ },
]
export function project(id: string): Project | undefined {
diff --git a/src/fixtures/types.ts b/src/fixtures/types.ts
index 7118f5d..145aff9 100644
--- a/src/fixtures/types.ts
+++ b/src/fixtures/types.ts
@@ -132,4 +132,21 @@ export interface Project {
landingTask?: { title: string; body: string }
steps: ProjectStep[]
starterCode: string
+ /**
+ * Whether this project can run inside Code Classroom — whether it uses an
+ * editor Code Classroom can embed.
+ *
+ * Not everything can. A Scratch project that drives a Raspberry Pi needs
+ * hardware attached, and a download-only resource has no editor at all, so
+ * neither works in a class however well it suits a club. That is the whole
+ * question behind "should we only allow embedded editor projects?" on the
+ * Code Classroom FigJam, and an import flow could not put it in front of
+ * anyone until a project existed that genuinely does not import.
+ *
+ * OPTIONAL, and absent means usable — read it as `usableInClassroom !==
+ * false`. Most projects are usable, so stating it on every fixture would be
+ * noise; and prototypes build their own Project objects for work a young
+ * person invents, which is always an editor project.
+ */
+ usableInClassroom?: boolean
}
diff --git a/src/kit/Placeholder.tsx b/src/kit/Placeholder.tsx
index 96af7f5..437e266 100644
--- a/src/kit/Placeholder.tsx
+++ b/src/kit/Placeholder.tsx
@@ -1,8 +1,12 @@
interface PlaceholderProps {
/** What the real thing would be, e.g. "Project thumbnail". */
label?: string
- /** Height in pixels. Defaults to a shallow block. */
- height?: number
+ /**
+ * Height. A number is pixels; a string is any CSS length, so '100%'
+ * stands in for something that fills whatever it is given — a code editor,
+ * say. Defaults to a shallow block.
+ */
+ height?: number | string
/** Stretch to fill the available width. Defaults to true. */
fullWidth?: boolean
}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/AddProjectChoice.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/AddProjectChoice.tsx
new file mode 100644
index 0000000..cfab9e2
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/AddProjectChoice.tsx
@@ -0,0 +1,249 @@
+import { useState, type CSSProperties } from 'react'
+import { Modal, TextInput } from '../../../kit'
+import { PROJECT_TYPES, type ProjectTypeId } from './projectTypes'
+
+// PROPOSED, and the fork is what this prototype exists to test.
+//
+// ONE dialog with TWO steps, not two dialogs. Step 1 asks find-or-create; if
+// the answer is "create your own", the same dialog becomes the live "Create a
+// new project" dialog rather than opening a second box on top of the first.
+// Stacked dialogs were the first thing anyone objected to, and they were a
+// symptom rather than a styling problem: the fork was wrapping a dialog the
+// product already has, so the fix is to make it the first step OF that dialog.
+//
+// What that buys, beyond not stacking: step 2 is now the real screen, down to
+// the type descriptions and the tile colours, so the branch this prototype is
+// testing AGAINST is the genuine article rather than a page invented to stand
+// in for it. "Back" also means something — it returns to the question instead
+// of dismissing one dialog to reveal another.
+//
+// Step 1 is the only proposed part. It preselects nothing, because
+// preselecting would answer the question being asked, and because the design
+// system's modal takes no `disabled` for its footer buttons, "Continue" with
+// nothing chosen asks for a choice rather than doing nothing.
+//
+// Tiles are flat colour with no glyph, as in ManageClub. The live dialog has
+// small icons in them; there is no icon component in the kit for arbitrary
+// tiles, and grey-boxing keeps a session about the flow rather than the art.
+//
+// Deliberately NOT badged "proposed" on screen; see screens/types.ts.
+
+type Choice = 'find' | 'create'
+
+const ROUTES: Array<{ id: Choice; tone: string; title: string; blurb: string }> = [
+ {
+ id: 'find',
+ tone: 'green',
+ title: 'Find a Code Club project',
+ blurb: 'Instructions and starter code already written.',
+ },
+ {
+ id: 'create',
+ tone: 'blue',
+ title: 'Create your own',
+ blurb: 'An empty project you write yourself.',
+ },
+]
+
+/** A selectable row: coloured tile, title, one line of description. */
+function OptionRow({
+ tone,
+ title,
+ blurb,
+ selected,
+ onSelect,
+}: {
+ tone: string
+ title: string
+ blurb: string
+ selected: boolean
+ onSelect: () => void
+}) {
+ return (
+
+ )
+}
+
+interface Props {
+ isOpen: boolean
+ setIsOpen: (open: boolean) => void
+ className: string
+ onFind: () => void
+ onCreate: (name: string, type: ProjectTypeId) => void
+}
+
+export function AddProjectChoice({ isOpen, setIsOpen, className, onFind, onCreate }: Props) {
+ const [onCreateStep, setOnCreateStep] = useState(false)
+ const [choice, setChoice] = useState()
+ const [nudge, setNudge] = useState(false)
+
+ const [type, setType] = useState('Blocks')
+ const [typed, setTyped] = useState()
+ const [nameError, setNameError] = useState()
+
+ // The live dialog arrives with "Blocks project" already in the field and
+ // Blocks selected, so the name follows the type until the mentor writes
+ // their own — after which it is theirs and stops moving.
+ const name = typed ?? `${type} project`
+
+ function close() {
+ setOnCreateStep(false)
+ setChoice(undefined)
+ setNudge(false)
+ setType('Blocks')
+ setTyped(undefined)
+ setNameError(undefined)
+ setIsOpen(false)
+ }
+
+ function forward() {
+ if (!choice) {
+ setNudge(true)
+ return
+ }
+ if (choice === 'find') {
+ onFind()
+ return
+ }
+ setOnCreateStep(true)
+ }
+
+ function create() {
+ if (!name.trim()) {
+ setNameError('Give the project a name first.')
+ return
+ }
+ onCreate(name.trim(), type)
+ }
+
+ return (
+ // The custom property inherits down to the dialog element, which is how a
+ // narrower modal is possible without touching the shared stylesheet.
+
+ (open ? setIsOpen(true) : close())}
+ heading={onCreateStep ? 'Create a new project' : 'Add a project'}
+ showCloseButton
+ primaryButtonText={onCreateStep ? 'Create project' : 'Continue'}
+ onClickPrimaryButton={onCreateStep ? create : forward}
+ // "Back" rather than "Cancel" on step 2: it came from a question, so
+ // returning to it is more use than dismissing. The header's X still
+ // cancels, which is how the live dialog's two buttons stay two.
+ secondaryButtonText={onCreateStep ? 'Back' : 'Cancel'}
+ onClickSecondaryButton={onCreateStep ? () => setOnCreateStep(false) : close}
+ >
+ {/* The modal's content box centres its children, so this has to claim
+ the full width or every row sits in the middle. */}
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/AddToClass.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/AddToClass.tsx
new file mode 100644
index 0000000..fcfc213
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/AddToClass.tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react'
+import { Button, Card, SelectInput } from '../../../kit'
+import type { ClassGroup } from '../../../fixtures'
+
+// PROPOSED. "Choose new or existing class" from the flow chart, reworked to
+// follow the equivalent screens in Experience CS — the other product that
+// integrates with Code Classroom, and so the closest thing to a precedent we
+// have for how a content product hands a piece of work to a class.
+//
+// Both cards render on the Code Club Projects surface. The mentor picks a Code
+// Classroom class without leaving the site they were browsing on, and the flow
+// crosses products only when they press "View your class". That follows
+// Experience CS rather than the chart, which had put this step in the Code
+// Classroom lane; see prototype.tsx and notes.md.
+//
+// Taken from those screens:
+//
+// - one small centred card on a tinted page, not a full-width page
+// - a heading naming the thing being added: "Add Weather watchers to class"
+// - a sentence explaining what a class is FOR, before asking which one
+// - the class as a single select labelled "School class", not a list of
+// buttons — so picking a class is one decision, not one decision per class
+// - one primary action, bottom right of the card
+// - a success card of the same shape: same heading, a tick, one sentence
+// naming what went where, then where to go next
+//
+// Changed for Code Club Projects rather than copied:
+//
+// - "unit" is Experience CS's word for a group of lessons. Code Club Projects
+// has projects, so this says project.
+// - Experience CS offers no way to create a class here; the chart's diamond
+// does, so "Create a new class" sits beside the primary action. Without it
+// this screen is a dead end for a mentor whose club has no class yet.
+// - Experience CS's mint page is its own brand colour. These sit on Code Club
+// Projects' own page colour rather than importing another product's, so the
+// card is told apart by its border rather than by a tint.
+//
+// Deliberately NOT badged "proposed" on screen; see screens/types.ts.
+
+/** "Add to class" — the select screen. */
+export function AddToClass({
+ projectTitle,
+ classes,
+ onAdd,
+ onNew,
+ onBack,
+}: {
+ projectTitle: string
+ classes: ClassGroup[]
+ onAdd: (classId: string) => void
+ onNew: () => void
+ onBack: () => void
+}) {
+ const [classId, setClassId] = useState(classes[0]?.id ?? '')
+
+ return (
+
+
Add {projectTitle} to class
+
+
+ Classes let you group students and assign projects to them. After adding this project to a
+ class, students will be able to work on it.
+
+
+ {classes.length === 0 ? (
+ // Reachable: a club whose Code Classroom school has no class yet. The
+ // select has nothing to offer, so the only way on is to make one.
+
+ You have no classes yet. Create one and this project will be added to it.
+
+ ) : (
+
+ ({ key: group.id, value: group.name }))}
+ value={classId}
+ onChange={(event) => setClassId(event.target.value)}
+ />
+ {/* Sits under the field it is about rather than in the button row.
+ Three buttons do not fit a 420px card, and more importantly the
+ Experience CS card drives at ONE action — a second button of
+ equal weight would undo that. */}
+
+
+
+ {/* Experience CS uses a circled tick here. There is no icon component in
+ the kit, so this is drawn from a border and a character, and hidden
+ from screen readers — the sentence below it carries the meaning. */}
+
+ ✓
+
+
+
+ {projectTitle} has been added to {className}.
+
+
+
+ You can manage this project anytime from the class project list on your Code Classroom
+ dashboard.
+
+
+ {isNewClass && (
+ // Not in the Experience CS screens, and it matters here: the chart's
+ // new-class branch lands the project in a class with nobody in it.
+
+ Nobody is in {className} yet, so nobody can see it. Add young people to the class from
+ your Code Classroom dashboard.
+
+ )}
+
+
+ {/* Experience CS's secondary here is "View unit" — the thing you just
+ added. This keeps the chart's loop instead, which is the action a
+ mentor setting up a term of sessions actually wants. */}
+
+
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/CreateInClassroom.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/CreateInClassroom.tsx
new file mode 100644
index 0000000..6cfdd3e
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/CreateInClassroom.tsx
@@ -0,0 +1,73 @@
+import { useState } from 'react'
+import { Button, Card, TextareaInput } from '../../../kit'
+
+// The tail of the chart's "create your own" branch. Naming the project and
+// picking its type now happen in step 2 of the add-project dialog, which is
+// the live "Create a new project" screen — see AddProjectChoice.tsx.
+//
+// PROPOSED. Adding instructions exists in Code Classroom, but nobody has built
+// this screen in `screens/` and nobody has checked it against the real thing,
+// so treat the details as wrong.
+//
+// This branch is in the prototype because it is the alternative the mentor is
+// choosing against. If it turns out to be the one they reach for, the
+// hypothesis is wrong in an interesting way.
+
+/** "Add instructions" — the last box before the two branches rejoin. */
+export function AddInstructions({
+ projectTitle,
+ onSave,
+ onBack,
+}: {
+ projectTitle: string
+ onSave: (instructions: string) => void
+ onBack: () => void
+}) {
+ const [text, setText] = useState('')
+ const [error, setError] = useState()
+
+ return (
+
+
+
+
+
+
+
Add instructions
+
+ What young people read in the left-hand panel while they work on {projectTitle}.
+
+ {
+ if (!text.trim()) {
+ setError('Write something, or go back and skip this.')
+ return
+ }
+ onSave(text.trim())
+ }}
+ />
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/MentorSchoolHome.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/MentorSchoolHome.tsx
new file mode 100644
index 0000000..c851e99
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/MentorSchoolHome.tsx
@@ -0,0 +1,60 @@
+import { Card, Tag } from '../../../kit'
+import type { ClassGroup, School } from '../../../fixtures'
+
+// The chart's first box: "Code Classroom". A mentor who is already signed in
+// and already has a school lands on the school, and the classes under it are
+// the only way onwards — there is no navigation to go anywhere else.
+//
+// This screen EXISTS in the real product and has never been built in
+// `screens/`, and NOBODY HAS CHECKED IT against the real educator view. Built
+// to mirror YoungPersonSchoolHome, so treat the details as wrong. It is here
+// only so the flow can start where the chart starts.
+
+interface Props {
+ school: School
+ classes: ClassGroup[]
+ memberCount: (classId: string) => number
+ onOpenClass: (classId: string) => void
+}
+
+export function MentorSchoolHome({ school, classes, memberCount, onOpenClass }: Props) {
+ return (
+
+
+
{school.name}
+ {/* Code Classroom calls this a school even when it is a library. The
+ mismatch is kept, not fixed — see screens/README.md. */}
+
+ School code: {school.schoolCode} · {school.country}
+
+
+
+
+
+
+
Classes
+
+ Young people sign in with the school code and join the class you put them in.
+
+
+
+
+ {classes.length === 0 ? (
+
No classes yet
+ ) : (
+
+ {classes.map((group) => (
+
+ onOpenClass(group.id)}>
+ {group.name}
+
+ {memberCount(group.id)} members
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/NewClass.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/NewClass.tsx
new file mode 100644
index 0000000..e7a1b88
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/NewClass.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react'
+import { Button, Card, TextInput, TextareaInput } from '../../../kit'
+
+// PROPOSED. The chart's "create a new class" branch, reached while adding a
+// project — so a mentor is setting up a class as a side effect of importing.
+//
+// The diamond before this one ("How do you want to create your class") has
+// only ONE branch drawn on the board, to "Name + description". Kept as its own
+// step so the flow matches the chart, with the missing branch called out in
+// the workbench commentary rather than invented here. See notes.md.
+
+/** The diamond: "How do you want to create your class". */
+export function HowToCreateClass({
+ onNameIt,
+ onBack,
+}: {
+ onNameIt: () => void
+ onBack: () => void
+}) {
+ return (
+
+
+
+
+
+
How do you want to create your class?
+
+
+
Name it yourself
+
+ Give the class a name and a description, and add young people afterwards.
+
+ {
+ if (!name.trim()) {
+ setError('Give the class a name first.')
+ return
+ }
+ onCreate(name.trim(), description.trim())
+ }}
+ />
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/ProjectsCatalogue.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/ProjectsCatalogue.tsx
new file mode 100644
index 0000000..8e0e619
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/ProjectsCatalogue.tsx
@@ -0,0 +1,252 @@
+import { useState } from 'react'
+import { Button, Card, CheckboxInput, Placeholder, Tag } from '../../../kit'
+import type { Project } from '../../../fixtures'
+import { isEmbeddedEditorProject } from './projectTypes'
+
+// "CCP page filtered by embedded editor projects" — the real Code Club
+// Projects catalogue, on the real Code Club Projects surface, reached by
+// leaving Code Classroom.
+//
+// Built to match the live category pages (projects.raspberrypi.org/en/
+// technology/scratch): green hero panel with a Back pill, a big category
+// title and a line of description; a results count; a filter rail; then a grid
+// of cards, each an image band over a "language - Level n" eyebrow, a linked
+// title and a description.
+//
+// The category is the proposal. "Scratch" and "Python" are real categories a
+// young person browses by; "Code Classroom compatible" is a category defined
+// by what a mentor can DO with a project rather than by what it teaches, and
+// nothing like it exists on the live site.
+//
+// It now covers Scratch, Python and HTML — every language the fixtures have —
+// so it excludes nothing here and a mentor sees the whole catalogue. Which
+// raises its own question: a category that excludes nothing is a category
+// doing no work. See notes.md, finding 3.
+//
+// Not built in `screens/` because no catalogue screen exists there, and this
+// one is a proposal rather than a reconstruction.
+//
+// Deliberately left out of the copy: the live page's "Start a path" strip of
+// three coloured path cards. Paths are a young person's route through a
+// subject over several weeks, which is not what a mentor is doing here, and
+// the fixtures have no paths to show.
+
+// Approximations of the live category page's palette, sampled from the page.
+// The shared token set has no greens (src/styles/tokens.css), so they are
+// declared once here and scoped to this prototype rather than repeated inline.
+// Delete these if Code Club Projects' palette ever lands in tokens.css.
+const HERO_GREEN = 'hsl(123, 45%, 87%)'
+const HERO_ART = 'hsl(123, 38%, 80%)'
+
+interface Props {
+ projects: Project[]
+ /** Projects already in one of this mentor's classes. */
+ alreadyAddedIds: string[]
+ onView: (projectId: string) => void
+ onBack: () => void
+}
+
+export function ProjectsCatalogue({ projects, alreadyAddedIds, onView, onBack }: Props) {
+ const inCategory = projects.filter(isEmbeddedEditorProject)
+ const hidden = projects.length - inCategory.length
+
+ // The live page filters by difficulty level, and `level` is the one filter
+ // the fixtures can actually honour. Interest and hardware are on the real
+ // page and are not modelled, so they are not faked here either.
+ const levels = [...new Set(inCategory.map((p) => p.level))].sort()
+ const [chosenLevels, setChosenLevels] = useState([])
+
+ const visible =
+ chosenLevels.length === 0
+ ? inCategory
+ : inCategory.filter((p) => chosenLevels.includes(p.level))
+
+ function toggleLevel(level: number, on: boolean) {
+ setChosenLevels((current) =>
+ on ? [...current, level] : current.filter((value) => value !== level),
+ )
+ }
+
+ return (
+
+ {/* Hero. The live one carries a category illustration on the right. */}
+
+
+
+ {/* Says where it goes, not just "Back". A mentor is one click from
+ a different product and the button is the only thing that says
+ so — the live page, which never leaves, can afford "Back". */}
+
+
+
+ Code Classroom compatible
+
+
+ Projects you can add straight to one of your classes in Code Classroom, with their
+ instructions and starter code already written.
+
+
+
+
+
+
+
+
+
+
+ {/* Filter rail beside the results, as on the live page. NOT the shared
+ `cc-columns` layout, which fixes the rail at 260px: the design
+ system's checkbox label carries a hardcoded min-width of 240px, and a
+ 260px card only has 212px inside its padding, so every checkbox in
+ the rail overflowed it. Flex with wrap rather than a grid, so the two
+ columns still stack on a narrow window without needing a media query
+ an inline style cannot express. */}
+
+
+
+
Filter
+
+ The project list automatically updates when you apply a filter.
+
+ {/* Reachable, and the point. The fixtures carry two physical
+ computing projects that need a Raspberry Pi, so the category
+ really is shorter than the catalogue — which is what makes the
+ "can we connect projects that aren't editor projects?" sticky
+ something a session can watch. */}
+ {hidden > 0 && (
+
+ {hidden} more {hidden === 1 ? 'project is' : 'projects are'} not here, because they
+ do not run in an editor Code Classroom can embed.
+
+ {/* The live cards lead with an illustration that runs to
+ the card's edges, so it has to be pulled back out
+ through the card's own padding and its top corners
+ rounded to match. Grey-boxed, as everywhere else
+ here, so a session is about the flow and not the
+ artwork. */}
+
+
+
+
+
+ {project.language} - Level {project.level}
+
+
+ {/* On the live page the title is the link, not the card
+ and not a button. */}
+ onView(project.id)}
+ >
+ {project.title}
+
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/ShowToStudents.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/ShowToStudents.tsx
new file mode 100644
index 0000000..f9c4de4
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/ShowToStudents.tsx
@@ -0,0 +1,89 @@
+import { Alert, Button, Card } from '../../../kit'
+
+// PROPOSED, and it contradicts the live product — which is why it is worth
+// building rather than arguing about.
+//
+// The chart ends both branches at "[Show to students]", in square brackets,
+// with a sticky asking whether mentors understand the project is hidden by
+// default. But the real Code Classroom project page (screens/EducatorProjectPage)
+// offers "Hide from students", which means a project is VISIBLE the moment it
+// exists. So this screen inverts today's behaviour: nothing is shown until the
+// mentor says so.
+//
+// Both readings are defensible. Hidden-by-default lets a mentor set a session
+// up in advance without young people wandering into a half-built project;
+// visible-by-default means one less step and nothing to forget. The cost of
+// hidden-by-default is the whole reason for the sticky: a mentor who does not
+// realise has a room of young people who cannot see the thing they came for.
+//
+// The "Tool tip" sticky is realised as the hint below the toggle. Whether a
+// tooltip is enough to carry a default nobody expects is the thing to watch.
+
+interface Props {
+ projectTitle: string
+ className: string
+ memberCount: number
+ shown: boolean
+ onShow: () => void
+ onDone: () => void
+ onBack: () => void
+}
+
+export function ShowToStudents({
+ projectTitle,
+ className,
+ memberCount,
+ shown,
+ onShow,
+ onDone,
+ onBack,
+}: Props) {
+ return (
+
+
+
+
+
+
+
{projectTitle}
+
In {className}
+
+
+ {shown ? (
+
+
+ {memberCount === 0
+ ? `Nobody is in ${className} yet, so there is still nobody to see it.`
+ : `All ${memberCount} young people in ${className} can open it now.`}
+
+
+ ) : (
+
+
+ You are the only person who can see this project. It stays hidden until you show it.
+
+
+ )}
+
+
+
Show to students
+ {/* The "Tool tip" sticky, as copy. A default nobody expects is being
+ carried by one line of hint text — that is the thing to test. */}
+
+ {shown
+ ? `${projectTitle} is showing to everyone in ${className}. You can hide it again from the project page.`
+ : `New projects are hidden so you can set them up before a session. Young people will not see ${projectTitle} in ${className} until you show it to them.`}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/meta.ts b/src/prototypes/import-mentor/out-to-projects-and-back/meta.ts
new file mode 100644
index 0000000..2b0ae56
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/meta.ts
@@ -0,0 +1,18 @@
+import type { PrototypeMeta } from '../../types'
+
+export const meta: PrototypeMeta = {
+ title: 'Out to Projects and back',
+
+ owner: 'Sarah Tucker',
+
+ hypothesis:
+ 'Mentors treat Code Classroom as the hub for their club, so they will start there — and expect to go out to Code Club Projects, pick a project, and have it land back in one of their classes without leaving the flow.',
+
+ status: 'sketch',
+
+ // Thabo runs one club (Westlands Library Code Club) which already has two
+ // classes — Thursday Scratch and Thursday Python. One club keeps "which club
+ // am I in?" out of the way, and two existing classes make the "new class or
+ // existing class?" decision a real choice rather than a formality.
+ cast: { piAccount: 'pi-mentor-thabo' },
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/notes.md b/src/prototypes/import-mentor/out-to-projects-and-back/notes.md
new file mode 100644
index 0000000..2221be2
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/notes.md
@@ -0,0 +1,275 @@
+A mentor starts in Code Classroom, goes out to Code Club Projects to browse,
+and comes back with a project attached to a class.
+
+Built to Sarah's flow chart, "Option 1: Code Classroom → Code Club Projects"
+(Code Club account management discovery, FigJam).
+
+## What question is this answering?
+
+`import-mentor` — how does a mentor get a Code Club project into Code
+Classroom?
+
+Specifically: what if they never start on Code Club Projects at all? The bet is
+that Code Classroom is where a mentor already is — the hub for their club — so
+the journey is Classroom → out to Projects to browse → back into Classroom with
+the project in a class.
+
+Cast is **Thabo Mokoena** at Westlands Library Code Club. One club, so "which
+club am I in?" stays out of the way, and two existing classes so "new class or
+existing class?" is a real choice. The chart's own starting assumption — the
+mentor is signed in and has a school — is carried by the cast rather than by a
+sign-in step, which is why `ownsSignIn` is not set.
+
+## Why this approach
+
+The bet is about the **starting point**, not the screens. The obvious
+alternative is that a mentor starts on Code Club Projects, where they have
+always gone for club content, and pushes from there. That is worth its own
+prototype; this one deliberately tests the opposite, because if Classroom
+really is the hub then a mentor who starts on Projects has already gone the
+wrong way round.
+
+**The one thing this flow relies on that the young-person lane cannot.** A
+mentor holds a single Raspberry Pi account that signs them in to *both* Code
+Club Projects and Code Classroom, so leaving and coming back costs them
+nothing. A club member holding only a mentor-made classroom account cannot
+follow that route at all. So "out to Projects and back" is available to mentors
+precisely because the identity problem is a young person's problem. That
+asymmetry is the strongest argument for this flow and it is worth saying out
+loud in any writeup.
+
+## What is proposed, and what is real
+
+Real screens, unchanged: `EducatorClassPage`, `EducatorProjectPage`,
+`ProjectPage`, `ClassroomProjectEditor`.
+
+Proposed — nothing like these exists:
+
+- `AddProjectChoice.tsx` — **one dialog with two steps**, over the class page.
+ Step 1 asks find-or-create. Answer "create your own" and the *same* dialog
+ becomes the live **"Create a new project"** dialog — project name, "what kind
+ of project do you want to make for your students?", and the Blocks / Python /
+ Web rows with the real descriptions and tile colours.
+
+ It started as two dialogs, the fork opening the create dialog on top of it.
+ That was worth fixing and not just cosmetically: the stacking was a symptom
+ of the fork **wrapping a dialog the product already has**, so the fix was to
+ make it the first step *of* that dialog rather than a box in front of it.
+
+ Two things that buys. Step 2 is now the genuine screen rather than a page
+ invented to stand in for it, so the branch this prototype tests *against* is
+ real. And "Back" means something — it returns to the question instead of
+ dismissing one dialog to reveal another.
+
+ Keeping the class visible behind it also means adding a project reads as
+ something happening *to* this class rather than a place you go, and "back"
+ out of the find branch returns to the class with the dialog open again.
+
+ **Two deliberate departures from the live dialog.** It preselects "Blocks";
+ step 1 preselects nothing, because preselecting would answer the question the
+ prototype is asking. And because the design system's modal takes no
+ `disabled` for its footer buttons, "Continue" with nothing chosen asks for a
+ choice rather than doing nothing.
+
+ Kept from the live dialog because it is a nice touch: the project name
+ arrives as "Blocks project" and follows the selected type until the mentor
+ types their own, after which it is theirs and stops moving.
+
+ Worth knowing that select-then-confirm is not just fidelity — it is the only
+ arrangement that does not bias the result. Two footer actions would make one
+ option the primary button and the other the secondary, and which option a
+ mentor picks is the finding.
+
+ Tiles are flat colour with no glyph, as in `ManageClub`. The live dialog has
+ small icons in them; there is no icon component in the kit for arbitrary
+ tiles, and grey-boxing keeps a session about the flow rather than the artwork.
+
+ **The alternative worth building as its own prototype:** no fork dialog at
+ all. Two buttons in the Projects card header — "Add project" opening the real
+ create dialog untouched, and "Find a Code Club project" beside it. That tests
+ a different question. Here the import route is hidden behind "Add project", so
+ a session shows whether mentors *pick* it once they are looking at it; with
+ two buttons it shows whether they *notice* it, which is closer to the
+ hypothesis. Rejected for this prototype rather than for good.
+- `ProjectsCatalogue.tsx` — the filtered catalogue, built to match the live
+ Code Club Projects category pages (`/en/technology/scratch`): green hero
+ panel with a Back pill, big category title and a line of description, then a
+ results count, a filter rail and a grid of cards — image band, "language -
+ Level n" eyebrow, linked title, description.
+
+ **The category itself is the proposal, and it is a different kind of category
+ from the ones on the live site.** "Scratch" and "Python" describe what a
+ project teaches. "Code Classroom compatible" describes what a mentor can
+ *do* with it. Nothing on Code Club Projects is organised that way today, and
+ it is worth asking whether it should be: a category that exists because of a
+ technical constraint in another product is a strange thing to put in front of
+ people who are browsing for something to teach.
+
+ The live page's "Start a path" strip is deliberately left out — paths are a
+ young person's route through a subject over weeks, not what a mentor is doing
+ here, and the fixtures have no paths. The filter rail keeps only difficulty
+ level, the one filter the fixtures can honour; interest and hardware are on
+ the real page and are not faked here.
+- `AddToClass.tsx` — picking the class, and the success card. Built to follow
+ the equivalent screens in **Experience CS**, the other product that
+ integrates with Code Classroom and so the nearest thing to a precedent for
+ how a content product hands work to a class: one small centred card, a
+ sentence on what a class is for before asking which one, the class as a
+ single select labelled "School class" rather than a button per class, one
+ primary action, and a success card of the same shape with a tick and one
+ sentence naming what went where.
+
+ Changed rather than copied: "unit" is Experience CS's word for a group of
+ lessons, so this says project; Experience CS offers no way to create a class
+ here and the chart's diamond does, so "Create a new class instead" sits under
+ the select as a link rather than competing with the primary button; and
+ Experience CS's mint page is its own brand colour, so these sit on Code Club
+ Projects' own page colour instead of importing another product's — the card
+ is told apart by its border rather than by a tint.
+- all of `NewClass.tsx`.
+- `ShowToStudents.tsx` — and it contradicts the live product. See finding 2.
+- `ProjectPage`'s "Add to a class" button — the shared screen takes an
+ `onImport` prop for exactly this, and passing it is a proposal.
+
+`MentorSchoolHome` exists in the real product but has never been built in
+`screens/` and **nobody has checked it against the real educator view.** Treat
+the details as wrong.
+
+Every screen above lives in this folder. **One change does not**: page width.
+`--surface-measure` in `src/surfaces/tokens.css` and the rules using it in
+`src/styles/global.css` cap a product page at 1100px, because screens read
+badly stretched across a large monitor. Divya asked for it and it applies to
+every prototype in the repo, not just this one — so it wants reviewing and
+merging separately from this prototype rather than riding along with it.
+
+## A design system constraint worth knowing
+
+`.rpf-input-checkbox` carries a hardcoded `min-width: 240px`. The shared
+`.cc-columns` layout fixes its rail at 260px, which leaves 212px inside a
+card's padding — so **any filter rail built with `cc-columns` and checkboxes
+overflows its own card.** The catalogue's rail here used to, by 6px. It now
+uses a flex rail with a 19rem basis instead, which also still stacks on a
+narrow window. The sibling prototype hit the same thing. Worth raising
+upstream, or worth a wider rail variant in the shared CSS.
+
+## Five things the build exposed
+
+Not design opinions — things that fell out of making the chart actually run.
+
+**1. "View project code" comes before there is anything to view.** The chart
+orders the create branch: name it → project page → view project code → add
+instructions. So at "view project code" the project has no instructions, and
+the Code Classroom editor renders `project.steps[stepIndex]` with no empty
+state — a project with no steps crashes it. The prototype puts a "No
+instructions yet" step in rather than a fake first step, so the gap is visible
+on screen. Either the chart's two boxes want swapping, or creating a project
+needs to ask for instructions up front — and now that naming happens in the
+live "Create a new project" dialog, that dialog is where such a question would
+have to go. Which makes it a change to a shipped screen, not a new one.
+
+**2. "Show to students" inverts the live product.** The chart ends both
+branches at `[Show to students]`, with the sticky asking whether mentors
+understand a project is hidden by default. But the real project page offers
+**"Hide from students"** — which means a project is visible the moment it
+exists. One of the two has to give. Both defaults are defensible: hidden-by-
+default lets a mentor set up a session in advance; visible-by-default is one
+less step to forget. Worth deciding before this is engineered, because it is a
+behaviour change to a shipped product, not a new screen.
+
+The prototype implements the chart's version (hidden by default) so the sticky's
+question can actually be tested. Note the trap it creates: after importing, the
+project page still only offers "Hide from students" — a mentor looking for
+"show to students" will not find it where they landed.
+
+**3. The category really is shorter than the catalogue, and the sticky's
+question is testable.** "CCP page filtered by embedded editor projects" is now
+read from `usableInClassroom` on each project rather than guessed from its
+language — two earlier versions of this got it wrong in opposite directions,
+first dropping Scratch and then excluding nothing at all.
+
+The field and two physical computing projects (`rain-or-shine`,
+`door-watcher`) were added to `src/fixtures` for exactly this. They need a
+Raspberry Pi and something plugged into it, so a young person cannot open them
+inside a class however well they suit a club session — which is what Sarah's
+pink sticky, *"can we connect projects that aren't editor projects?"*, is
+about. The catalogue says on screen that two are missing and why.
+
+So a session can now watch the thing that matters: **what a mentor does when
+the project they wanted is not there.** That was impossible while the filter
+excluded nothing.
+
+**4. The product boundary moved, and the chart is now wrong about where it
+sits.** The chart puts "choose new or existing class" and everything after it
+in the Code Classroom lane, so clicking "Add to a class" on Code Club Projects
+carried the mentor across a product boundary mid-task, before they had
+finished. Experience CS — the other product that feeds Code Classroom — does
+the whole interaction on **its own side** and crosses only at "View your
+class".
+
+**Divya's call, September 2026: follow Experience CS.** So the whole
+add-to-class interaction now happens on Code Club Projects — choosing the
+class, creating one, and the success card — and the single crossing into Code
+Classroom is the "View your class" button, which the mentor has to press. The
+flow therefore has exactly one product change in it, and it is one they asked
+for. **Sarah's chart still shows the old boundary and wants updating to
+match.**
+
+Two things this buys, and one it costs. It means a mentor never loses the
+catalogue they were browsing, so "Find another project" is a cheap loop rather
+than a return trip. It also means the chrome never changes underneath them —
+which is why these steps do NOT use the Surface's own `layout="centred"`: that
+mode suppresses the product's navigation, and Code Club Projects' nav
+disappearing halfway through would make it look like they had left after all.
+
+What it costs: creating a class from here is now a **write into another
+product** from inside Code Club Projects. Picking an existing class is only a
+read. That is a much bigger ask of engineering than the rest of this flow, and
+it is the one part of the new boundary worth pushing on before anyone commits.
+
+**5. "Success" succeeds at less than it says.** On the new-class branch the
+project lands in a class with nobody in it. The mentor still has to go and add
+young people, and the flow never mentions it. The screen says so rather than
+claiming victory.
+
+Two smaller ones:
+
+- The chart says the button is **"add to my club"**; the shared screen says
+ **"Add to a class"**. Code Club's word and Code Classroom's, on either side of
+ one click. Worth picking one.
+- The board has two overlapping **"View project code"** boxes (the second has
+ no connectors attached, so it reads as a leftover), and the **"How do you want
+ to create your class"** diamond has only one branch drawn. The prototype keeps
+ the diamond as a single-option step rather than inventing a second answer —
+ five minutes with Sarah would settle it.
+
+## What I'd want to watch in testing
+
+- **Where a mentor goes first, before anything is on screen.** Ask them to find
+ a project for next week and watch which product they open. That is the
+ hypothesis, and it is answered before they touch a prototype screen.
+- **Whether "Find a project" wins because mentors want it or because it is
+ first.** Neither option is preselected and neither is the primary button, so
+ reading order is the only bias left. If it wins every time, swap the two rows
+ round in a later session before believing the result.
+- **Whether "Add to a class" survives "Start project".** The Code Club Projects
+ landing page drives at "Start project", repeated top and bottom. Watch whether
+ a mentor who came to import ends up starting the project instead.
+- **The one crossing, and whether they take it.** Everything up to and
+ including "added to Thursday Scratch group" now happens on Code Club
+ Projects; "View your class" is the only button that leaves. Watch whether
+ anyone presses it at all, or whether "Find another project" wins and they
+ never go and look at what they built. If nobody ever crosses, the class page
+ is not where a mentor checks their work.
+- **What they think the category means.** Everything in the fixtures qualifies,
+ so nothing on screen explains why this category exists. Ask at the end: "what
+ would you expect to find in a category called Code Classroom compatible, and
+ what would you expect to be missing?" If a mentor cannot answer, the category
+ is doing no work for them.
+- **Whether they leave "Show to students" without pressing it** — then ask what
+ a young person can see right now. Do not ask whether they understood.
+- **Whether the loop gets used.** "Find another project" is the only cheap way
+ to set up a term of sessions in one sitting.
+
+## What we learned
+
+Not tested yet.
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/projectTypes.ts b/src/prototypes/import-mentor/out-to-projects-and-back/projectTypes.ts
new file mode 100644
index 0000000..40748b7
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/projectTypes.ts
@@ -0,0 +1,106 @@
+import type { Project, ProjectStep } from '../../../fixtures'
+
+// Two decisions the flow chart implies but does not spell out, kept in one
+// file so the flow cannot quietly disagree with itself and so changing either
+// is a one-line change.
+
+/**
+ * "CCP page filtered by embedded editor projects" — the chart's own words for
+ * the catalogue the mentor browses.
+ *
+ * Reads `usableInClassroom` from the fixtures. Two earlier versions of this
+ * guessed: first that "embedded editor" meant Python and HTML only, which
+ * wrongly dropped Scratch; then that every language qualified, which made the
+ * filter exclude nothing and quietly took the question off the table. The
+ * answer belongs per project, not per language — a Scratch project driving a
+ * Raspberry Pi does not import however Scratch it is.
+ *
+ * So the category is genuinely shorter than the whole catalogue again, and the
+ * "can we connect projects that aren't editor projects?" sticky is something a
+ * session can watch rather than something the notes apologise for.
+ */
+export function isEmbeddedEditorProject(project: Project): boolean {
+ return project.usableInClassroom !== false
+}
+
+/**
+ * "Name and project type e.g blocks" — the chart names one option, Blocks, so
+ * the other two are taken from what the Code Editor supports.
+ *
+ * Code Classroom says Blocks, Python and Web; Code Club Projects labels the
+ * same things Scratch, Python and HTML. The two products disagree, and a
+ * mentor who picks Blocks and then browses a list of things called Scratch has
+ * to work out they are the same. Mapped here so the mismatch is visible.
+ *
+ * Descriptions and tile colours are the live dialog's own, not invented.
+ */
+export const PROJECT_TYPES = [
+ {
+ id: 'Blocks',
+ language: 'Scratch' as const,
+ tone: 'orange',
+ blurb: 'Based on the open-source Scratch editor',
+ },
+ {
+ id: 'Python',
+ language: 'Python' as const,
+ tone: 'green',
+ blurb: 'Wide range of built-in libraries',
+ },
+ {
+ id: 'Web',
+ language: 'HTML' as const,
+ tone: 'purple',
+ blurb: 'HTML, CSS, and JavaScript',
+ },
+]
+
+export type ProjectTypeId = (typeof PROJECT_TYPES)[number]['id']
+
+export function projectType(id: ProjectTypeId) {
+ return PROJECT_TYPES.find((type) => type.id === id)!
+}
+
+/**
+ * A project the mentor has named but not yet written instructions for.
+ *
+ * The step is a stand-in, and it is here because of a real gap. The chart
+ * sends the mentor to "View project code" BEFORE "Add instructions", so at
+ * that moment the project has no instructions at all — and the Code Classroom
+ * editor renders `project.steps[stepIndex]` with no empty state, so a project
+ * with no steps crashes it. Rather than hide that behind a fake first step,
+ * the stand-in says on screen what is missing. See notes.md, finding 1.
+ */
+const NO_INSTRUCTIONS_YET: ProjectStep = {
+ title: 'No instructions yet',
+ body: [
+ 'You have not added instructions to this project.',
+ '',
+ 'Young people opening it will see this panel.',
+ ].join('\n'),
+}
+
+export function createdProject(name: string, type: ProjectTypeId): Project {
+ return {
+ id: `created-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
+ title: name,
+ language: projectType(type).language,
+ level: 1,
+ ages: '',
+ intro: '',
+ steps: [NO_INSTRUCTIONS_YET],
+ starterCode: '',
+ }
+}
+
+/** What "Add instructions" leaves behind, so the editor has something real. */
+export function withInstructions(project: Project, instructions: string): Project {
+ return {
+ ...project,
+ steps: [{ title: 'Step 1', body: instructions }],
+ }
+}
+
+export function hasInstructions(project: Project): boolean {
+ return project.steps[0]?.title !== NO_INSTRUCTIONS_YET.title
+}
diff --git a/src/prototypes/import-mentor/out-to-projects-and-back/prototype.tsx b/src/prototypes/import-mentor/out-to-projects-and-back/prototype.tsx
new file mode 100644
index 0000000..a1c8286
--- /dev/null
+++ b/src/prototypes/import-mentor/out-to-projects-and-back/prototype.tsx
@@ -0,0 +1,632 @@
+import { useState, type ReactNode } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { Alert, Button, Card } from '../../../kit'
+import {
+ PROJECTS,
+ classesInSchool,
+ project,
+ school,
+ schoolsForMentor,
+ type ClassGroup,
+ type Project,
+} from '../../../fixtures'
+import {
+ ClassroomProjectEditor,
+ EducatorClassPage,
+ EducatorProjectPage,
+ ProjectPage,
+} from '../../../screens'
+import { Surface } from '../../../surfaces'
+import { AddProjectChoice } from './AddProjectChoice'
+import { AddInstructions } from './CreateInClassroom'
+import { AddToClass, AddedToClass } from './AddToClass'
+import { MentorSchoolHome } from './MentorSchoolHome'
+import { HowToCreateClass, NameAndDescription } from './NewClass'
+import { ProjectsCatalogue } from './ProjectsCatalogue'
+import { ShowToStudents } from './ShowToStudents'
+import { createdProject, hasInstructions, withInstructions } from './projectTypes'
+import { meta } from './meta'
+
+// Everything comes from the one mentor named in meta.ts. Change the cast and
+// the club, the classes and the school code all follow. Nothing below
+// hardcodes Westlands.
+//
+// The pink sticky on the flow chart — "this assumes the mentor is logged in
+// and has a school" — is satisfied by the cast rather than by a sign-in step,
+// which is why `ownsSignIn` is not set: the workbench signs Thabo in before
+// the flow renders, and the flow opens where the chart opens.
+const MENTOR = meta.cast?.piAccount ?? ''
+const CLUB = school(schoolsForMentor(MENTOR)[0].id)!
+const CLASSES = classesInSchool(CLUB.id)
+
+/**
+ * What the mentor set up before today, so "Add project" sits in a class that
+ * is already running rather than an empty one. Not from the chart — the chart
+ * starts at a class page without saying what is in it.
+ */
+const ALREADY_THERE: Record = {
+ [CLASSES[0].id]: ['space-talk'],
+}
+
+/**
+ * The narrow centred column the add-to-class steps sit in, like the Experience
+ * CS cards they follow.
+ *
+ * Done here rather than with the Surface's own `layout="centred"`, which looks
+ * right and is wrong for this: it suppresses the product's navigation, because
+ * the pages it was built for — Pi Accounts, the Code Classroom sign-in — do not
+ * have any. Code Club Projects does, and the whole point of keeping these steps
+ * on Projects is that the mentor does not appear to leave. Chrome vanishing
+ * halfway through would undo exactly what the change is for.
+ *
+ * 520px rather than that layout's 420px, measured rather than picked: the
+ * widest button row here is "Find another project" plus "View your class" at
+ * 241 + 8 + 190, and the card's own padding adds 48, so 488px is where they
+ * stop stacking. The rest is slack. The row still wraps on a narrow window,
+ * which is what should happen there.
+ */
+const CENTRED_COLUMN = { width: '100%', maxWidth: '520px', margin: '0 auto' } as const
+
+/** A project sitting in one of this mentor's classes. */
+interface Placed {
+ project: Project
+ classId: string
+ /** Hidden until the mentor says otherwise — see ShowToStudents.tsx. */
+ shownToStudents: boolean
+}
+
+// Neither the fork ("find a project or create your own") nor naming a new
+// project is a step here. Both are steps INSIDE one dialog over the class page,
+// so the class stays visible behind the question and nothing ever stacks — see
+// AddProjectChoice.tsx. "Back" out of the find branch returns to the class with
+// that dialog open again, rather than to a page of its own.
+type Step =
+ // Code Classroom
+ | 'school'
+ | 'class'
+ // Create your own
+ | 'create-project-page'
+ | 'create-code'
+ | 'create-instructions'
+ // Find a project — Code Club Projects, then back
+ | 'browse'
+ | 'project-landing'
+ | 'choose-class'
+ | 'new-class-how'
+ | 'new-class-name'
+ | 'success'
+ // Both branches end here
+ | 'project-page'
+ | 'show-to-students'
+
+export default function OutToProjectsAndBack() {
+ const [params] = useSearchParams()
+ // Author commentary belongs to the team, not to the person being tested.
+ const showWorkbench = params.get('full') !== '1'
+
+ const [step, setStep] = useState('school')
+ /** The "add a project" fork, open over the class page. */
+ const [addOpen, setAddOpen] = useState(false)
+
+ /** Back out of either branch: the class, with the fork open again. */
+ function backToFork() {
+ setStep('class')
+ setAddOpen(true)
+ }
+
+ // Classes can grow: the find branch can create one on the way through.
+ const [classes, setClasses] = useState(CLASSES)
+ const [placed, setPlaced] = useState(() =>
+ Object.entries(ALREADY_THERE).flatMap(([classId, projectIds]) =>
+ projectIds.map((id) => ({ project: project(id)!, classId, shownToStudents: true })),
+ ),
+ )
+
+ const [activeClassId, setActiveClassId] = useState(CLASSES[0].id)
+ const [activeProjectId, setActiveProjectId] = useState()
+ /** The project being looked at on Code Club Projects, before it is added. */
+ const [viewingId, setViewingId] = useState()
+ const [newClassName, setNewClassName] = useState()
+ const [stepIndex, setStepIndex] = useState(0)
+
+ const activeClass = classes.find((group) => group.id === activeClassId) ?? classes[0]
+ const activePlaced = placed.find(
+ (item) => item.project.id === activeProjectId && item.classId === activeClassId,
+ )
+ const activeProject = activePlaced?.project
+ const viewing = viewingId ? project(viewingId) : undefined
+
+ const inClass = (classId: string) =>
+ placed.filter((item) => item.classId === classId).map((item) => item.project)
+
+ /** Projects in a class that students cannot see yet — the crossed-out eye. */
+ const hiddenIn = (classId: string) =>
+ placed
+ .filter((item) => item.classId === classId && !item.shownToStudents)
+ .map((item) => item.project.id)
+
+ function memberCount(classId: string) {
+ return classes.find((group) => group.id === classId)?.studentIds.length ?? 0
+ }
+
+ /** Put a project in a class and make it the one being looked at. */
+ function place(item: Project, classId: string) {
+ setPlaced((current) =>
+ current.some((p) => p.project.id === item.id && p.classId === classId)
+ ? current
+ : [...current, { project: item, classId, shownToStudents: false }],
+ )
+ setActiveClassId(classId)
+ setActiveProjectId(item.id)
+ }
+
+ /** Replace a placed project, for "Add instructions". */
+ function update(item: Project) {
+ setPlaced((current) =>
+ current.map((p) =>
+ p.project.id === item.id && p.classId === activeClassId ? { ...p, project: item } : p,
+ ),
+ )
+ }
+
+ function show() {
+ setPlaced((current) =>
+ current.map((p) =>
+ p.project.id === activeProjectId && p.classId === activeClassId
+ ? { ...p, shownToStudents: true }
+ : p,
+ ),
+ )
+ }
+
+ /** Author commentary. Rendered only in the workbench. */
+ function Note({ children }: { children: ReactNode }) {
+ if (!showWorkbench) return null
+ return (
+
+
{children}
+
+ )
+ }
+
+ const crumbs = (...rest: string[]) => ['Your school', ...rest]
+
+ /**
+ * Where a breadcrumb goes. Code Classroom has no other navigation, so this
+ * is the only way back up — and the trail here is always
+ * ['Your school', class, project, ...], so an index maps to a step.
+ */
+ function goToCrumb(index: number) {
+ if (index === 0) setStep('school')
+ else if (index === 1) setStep('class')
+ else if (index === 2) setStep('project-page')
+ }
+
+ // --- Code Classroom: where the chart starts --------------------------------
+
+ if (step === 'school') {
+ return (
+
+
+ {
+ setActiveClassId(classId)
+ setStep('class')
+ }}
+ />
+
+ The chart's starting assumption, and the whole bet: {CLUB.name} already exists in Code
+ Classroom and the mentor is already in it. If that is true, Code Classroom is the hub
+ and this is where a mentor goes looking for a project. If a mentor would really open
+ Code Club Projects first, this prototype is answering the wrong question — and that is
+ the thing to watch for, before anyone touches a screen.
+
+
+ setAddOpen(true)}
+ onOpenProject={(projectId) => {
+ setActiveProjectId(projectId)
+ setStep('project-page')
+ }}
+ onCopyLink={() => {}}
+ onClassMembers={() => {}}
+ />
+
+ {/* The fork, over the class rather than instead of it. */}
+ {
+ setAddOpen(false)
+ setStep('browse')
+ }}
+ onCreate={(name, type) => {
+ setAddOpen(false)
+ place(createdProject(name, type), activeClass.id)
+ setStep('create-project-page')
+ }}
+ />
+
+
+ The real class page, and everything on it except the dialog is real. Two things it
+ already says that this flow has to live with: projects "contain starter code created
+ by a teacher", so a project is the adult's to set up, and there is no sign anywhere
+ that Code Club Projects exists.
+
+
+ The crossed-out eye is the live product's own marker for a project students cannot see
+ yet, and it is worth watching against finding 2 in notes.md: a project imported by
+ this flow arrives hidden, so it shows up here marked, in a list beside projects that
+ are live. Whether a mentor reads that icon as "not finished" or misses it entirely is
+ the cheapest test of the hidden-by-default question.
+
+
+ "Add project" is the only way on, and what it opens is the proposal. Today it goes
+ straight to creating a project from nothing, with no question asked and no mention of
+ Code Club Projects. Watch which option a mentor reads first, whether "find a project"
+ reads as Code Club's own projects or as something vaguer, and whether anyone closes
+ the dialog to go and look at the class again before choosing.
+
+
+
+ )
+ }
+
+ // --- Create your own ------------------------------------------------------
+
+ if (step === 'create-project-page' && activeProject) {
+ return (
+
+
+ {
+ setStepIndex(0)
+ setStep('create-code')
+ }}
+ onHideFromStudents={() => setStep('show-to-students')}
+ onCopyLink={() => {}}
+ onOpenWork={() => {}}
+ />
+
+ The real project page, and it disagrees with the chart. It offers "Hide from students",
+ which means a project is visible as soon as it exists — the chart ends at "Show to
+ students", which means the opposite. One of the two has to give; see notes.md.
+
+
+
+ {hasInstructions(activeProject)
+ ? 'The instructions the mentor wrote, in the panel a young person reads. Compare it with what an imported Code Club project arrives with.'
+ : 'The chart sends the mentor here BEFORE "Add instructions", so the instructions panel has nothing in it — the mentor is writing starter code into a shell. The panel says so out loud rather than showing an empty step, because the screen has no empty state to show. See notes.md, finding 1.'}
+
+
+ {
+ update(withInstructions(activeProject, instructions))
+ setStep('show-to-students')
+ }}
+ onBack={() => setStep('create-code')}
+ />
+
+ Where the two branches rejoin. The chart runs a long connector from here straight to
+ "Show to students", skipping the class page — so a mentor who creates their own project
+ never passes back through the class to check it landed.
+
+
+
+ )
+ }
+
+ // --- Find a project: out to Code Club Projects -----------------------------
+
+ if (step === 'browse') {
+ return (
+
+
+ item.project.id)}
+ onView={(projectId) => {
+ setViewingId(projectId)
+ setStep('project-landing')
+ }}
+ onBack={backToFork}
+ />
+
+ A different product, and the chrome says so. Worth noticing what this crossing costs a
+ mentor and does not cost a young person: {CLUB.name}'s mentor signs in here with the
+ same Raspberry Pi account they use for Code Classroom, so leaving is free. A club
+ member holding only a classroom account cannot follow them — which is why this route
+ exists for mentors and not for the young people they are choosing on behalf of.
+
+
+ setStep('project-landing')}
+ onImport={() => setStep('choose-class')}
+ />
+
+ The real Code Club Projects landing page. "Add to my club" is PROPOSED — nothing like
+ it exists on the live site or in the designs — and it has to compete with "Start
+ project", which the page repeats top and bottom. Watch whether a mentor who came here
+ to import ends up starting the project instead.
+
+
+
+ )
+ }
+
+ // --- Adding to a class, all of it still on Code Club Projects -------------
+ //
+ // A DELIBERATE DEPARTURE FROM THE CHART, decided by Divya. The chart puts
+ // "choose new or existing class" and everything after it in the Code
+ // Classroom lane, so clicking "Add to a class" on Projects crossed a product
+ // boundary mid-task, before the mentor had finished. Experience CS — the
+ // other product that feeds Code Classroom — does the whole interaction on
+ // its own side and crosses only at "View your class", and that is what the
+ // flow does now.
+ //
+ // So every step below stays on the `ccp` surface, and no step below carries
+ // breadcrumbs: breadcrumbs are Code Classroom's entire navigation model and
+ // Code Club Projects does not have them.
+
+ if (step === 'choose-class' && viewing) {
+ return (
+
+
+ {
+ place(viewing, classId)
+ setNewClassName(undefined)
+ setStep('success')
+ }}
+ onNew={() => setStep('new-class-how')}
+ onBack={() => setStep('project-landing')}
+ />
+
+ Still on Code Club Projects, which is the change: the mentor picks a Code Classroom
+ class without leaving the site they are browsing on. Following Experience CS, which
+ does the same job for the same Code Classroom. Watch whether anyone is surprised to
+ be choosing a class here — and whether "School class" means anything to a volunteer
+ whose club is not a school.
+
+
+ setStep('new-class-name')}
+ onBack={() => setStep('choose-class')}
+ />
+
+ A decision point with one option. The chart draws this diamond with a single branch
+ leaving it, so either there is a second way to create a class that the board does not
+ show, or the diamond should not be a diamond. Left as drawn rather than invented —
+ worth five minutes with Sarah before this goes in front of anyone.
+
+
+ Worth noticing what it now claims, though: creating a Code Classroom class from
+ inside Code Club Projects. Picking an existing class from here is a read; making a new
+ one is a write into another product, and a bigger thing to ask for.
+
+
+ {
+ const group: ClassGroup = {
+ id: `class-new-${classes.length + 1}`,
+ schoolId: CLUB.id,
+ name,
+ kind: 'code-club',
+ studentIds: [],
+ }
+ setClasses((current) => [...current, group])
+ setNewClassName(name)
+ place(viewing, group.id)
+ setStep('success')
+ }}
+ onBack={() => setStep('new-class-how')}
+ />
+
+ A class with nobody in it. The chart's next box is "Success", and it is worth asking
+ what succeeded: the project is in a class no young person can reach until the mentor
+ goes and adds them, which is a job this flow never mentions — and which now sits in a
+ different product from the one they are standing in.
+
+
+ setStep('class')}
+ onFindAnother={() => setStep('browse')}
+ />
+
+ The same card as the step before it, which is how Experience CS does it — same
+ heading, a tick, and one sentence saying what went where.
+
+
+ This is the only screen in the flow that crosses products, and it makes the mentor ask
+ for it: "View your class" is the one button that leaves Code Club Projects. "Find
+ another project" keeps them here and loops back to the catalogue, which is the chart's
+ own loop and the thing a mentor setting up a term of sessions actually wants. Watch
+ which of the two they reach for — and whether they notice the product changed when
+ they do leave.
+
+
+
+ )
+ }
+
+ // --- Both branches end here -----------------------------------------------
+
+ if (step === 'project-page' && activeProject) {
+ return (
+
+
+ {
+ setStepIndex(0)
+ setStep('create-code')
+ }}
+ onHideFromStudents={() => setStep('show-to-students')}
+ onCopyLink={() => {}}
+ onOpenWork={() => {}}
+ />
+
+ {activePlaced?.shownToStudents
+ ? 'The imported project, in the class, visible. "Student work" is empty and stays empty until someone saves — a mentor cannot tell who has started and got stuck from who has not started.'
+ : 'The imported project is here and hidden. The only thing on this page about visibility is "Hide from students" — the opposite of what this flow just did to it. A mentor looking for "show to students" will not find it here.'}
+
+
+ setStep('class')}
+ onBack={() => setStep('project-page')}
+ />
+
+ The end of both branches, and the most uncertain screen here — it is in square brackets
+ on the chart for a reason. It inverts the live product: today a project is visible the
+ moment it exists. The sticky's question is the one to test, and it cannot be tested by
+ asking. Watch whether a mentor leaves this screen without pressing the button, then ask
+ them what a young person can see.
+
+
+
+ )
+ }
+
+ // Any step whose data went missing — only reachable by editing state by hand.
+ return (
+
+
+
+
This step needs a project and there is not one.
+
+ setStep('school')} />
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/AddToClass.tsx b/src/prototypes/import-mentor/start-on-projects/AddToClass.tsx
new file mode 100644
index 0000000..fcfc213
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/AddToClass.tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react'
+import { Button, Card, SelectInput } from '../../../kit'
+import type { ClassGroup } from '../../../fixtures'
+
+// PROPOSED. "Choose new or existing class" from the flow chart, reworked to
+// follow the equivalent screens in Experience CS — the other product that
+// integrates with Code Classroom, and so the closest thing to a precedent we
+// have for how a content product hands a piece of work to a class.
+//
+// Both cards render on the Code Club Projects surface. The mentor picks a Code
+// Classroom class without leaving the site they were browsing on, and the flow
+// crosses products only when they press "View your class". That follows
+// Experience CS rather than the chart, which had put this step in the Code
+// Classroom lane; see prototype.tsx and notes.md.
+//
+// Taken from those screens:
+//
+// - one small centred card on a tinted page, not a full-width page
+// - a heading naming the thing being added: "Add Weather watchers to class"
+// - a sentence explaining what a class is FOR, before asking which one
+// - the class as a single select labelled "School class", not a list of
+// buttons — so picking a class is one decision, not one decision per class
+// - one primary action, bottom right of the card
+// - a success card of the same shape: same heading, a tick, one sentence
+// naming what went where, then where to go next
+//
+// Changed for Code Club Projects rather than copied:
+//
+// - "unit" is Experience CS's word for a group of lessons. Code Club Projects
+// has projects, so this says project.
+// - Experience CS offers no way to create a class here; the chart's diamond
+// does, so "Create a new class" sits beside the primary action. Without it
+// this screen is a dead end for a mentor whose club has no class yet.
+// - Experience CS's mint page is its own brand colour. These sit on Code Club
+// Projects' own page colour rather than importing another product's, so the
+// card is told apart by its border rather than by a tint.
+//
+// Deliberately NOT badged "proposed" on screen; see screens/types.ts.
+
+/** "Add to class" — the select screen. */
+export function AddToClass({
+ projectTitle,
+ classes,
+ onAdd,
+ onNew,
+ onBack,
+}: {
+ projectTitle: string
+ classes: ClassGroup[]
+ onAdd: (classId: string) => void
+ onNew: () => void
+ onBack: () => void
+}) {
+ const [classId, setClassId] = useState(classes[0]?.id ?? '')
+
+ return (
+
+
Add {projectTitle} to class
+
+
+ Classes let you group students and assign projects to them. After adding this project to a
+ class, students will be able to work on it.
+
+
+ {classes.length === 0 ? (
+ // Reachable: a club whose Code Classroom school has no class yet. The
+ // select has nothing to offer, so the only way on is to make one.
+
+ You have no classes yet. Create one and this project will be added to it.
+
+ ) : (
+
+ ({ key: group.id, value: group.name }))}
+ value={classId}
+ onChange={(event) => setClassId(event.target.value)}
+ />
+ {/* Sits under the field it is about rather than in the button row.
+ Three buttons do not fit a 420px card, and more importantly the
+ Experience CS card drives at ONE action — a second button of
+ equal weight would undo that. */}
+
+ Create a new class instead
+
+
+
+ {/* Experience CS uses a circled tick here. There is no icon component in
+ the kit, so this is drawn from a border and a character, and hidden
+ from screen readers — the sentence below it carries the meaning. */}
+
+ ✓
+
+
+
+ {projectTitle} has been added to {className}.
+
+
+
+ You can manage this project anytime from the class project list on your Code Classroom
+ dashboard.
+
+
+ {isNewClass && (
+ // Not in the Experience CS screens, and it matters here: the chart's
+ // new-class branch lands the project in a class with nobody in it.
+
+ Nobody is in {className} yet, so nobody can see it. Add young people to the class from
+ your Code Classroom dashboard.
+
+ )}
+
+
+ {/* Experience CS's secondary here is "View unit" — the thing you just
+ added. This keeps the chart's loop instead, which is the action a
+ mentor setting up a term of sessions actually wants. */}
+
+
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/NewClass.tsx b/src/prototypes/import-mentor/start-on-projects/NewClass.tsx
new file mode 100644
index 0000000..e7a1b88
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/NewClass.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react'
+import { Button, Card, TextInput, TextareaInput } from '../../../kit'
+
+// PROPOSED. The chart's "create a new class" branch, reached while adding a
+// project — so a mentor is setting up a class as a side effect of importing.
+//
+// The diamond before this one ("How do you want to create your class") has
+// only ONE branch drawn on the board, to "Name + description". Kept as its own
+// step so the flow matches the chart, with the missing branch called out in
+// the workbench commentary rather than invented here. See notes.md.
+
+/** The diamond: "How do you want to create your class". */
+export function HowToCreateClass({
+ onNameIt,
+ onBack,
+}: {
+ onNameIt: () => void
+ onBack: () => void
+}) {
+ return (
+
+
+
+
+
+
How do you want to create your class?
+
+
+
Name it yourself
+
+ Give the class a name and a description, and add young people afterwards.
+
+ {
+ if (!name.trim()) {
+ setError('Give the class a name first.')
+ return
+ }
+ onCreate(name.trim(), description.trim())
+ }}
+ />
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/ProjectSelector.tsx b/src/prototypes/import-mentor/start-on-projects/ProjectSelector.tsx
new file mode 100644
index 0000000..01b69f6
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/ProjectSelector.tsx
@@ -0,0 +1,318 @@
+import { useState } from 'react'
+import { Card, CheckboxInput, Placeholder, SearchInput, Tag } from '../../../kit'
+import type { Project } from '../../../fixtures'
+import { FILTER_LABEL, interestOf, usableInClassroom } from './facets'
+
+// The first page, and on the live site it is the project selector:
+// projects.raspberrypi.org/en/projects — a green "Find a project" band with a
+// search box, one filter rail, "Showing N projects", then a grid of cards.
+//
+// It collapses two of the chart's boxes into one screen, and that is the
+// chart read correctly rather than a shortcut. "Code Club Projects" then
+// "Scratch" then "Filter by 'can be used in my code classroom'" are not three
+// pages: on the real site Scratch is a checkbox in the Technology group of
+// this rail, right next to where the new filter would go. A mentor arrives,
+// ticks Scratch, ticks the Code Classroom filter, and is looking at their
+// shortlist — all here.
+//
+// That is also better for the question Sarah wants answered. Her assumption on
+// the chart is *"mentors will find a project based on technology over
+// interest"*, and the live rail puts Interest and Technology side by side. On
+// a category page you cannot see that choice being made; here you can watch
+// which group they reach for first.
+//
+// THE PROPOSAL IS ONE CHECKBOX. The page around it is the live page.
+//
+// Sarah's sticky floated a second idea — *"we could also have a tag design or
+// a way to differentiate which cards are compatible with Code Classroom"* —
+// and it was built and then removed. Three reasons, in order of weight:
+//
+// - It would sit on nearly every card. A badge on the majority carries no
+// information; if anything is worth marking it is the exceptions.
+// - THIS PAGE IS SHARED WITH YOUNG PEOPLE. The selector has no idea who is
+// looking, and "Works with Code Classroom" means nothing to a nine-year-old
+// browsing for something to make.
+// - At the grid's narrowest column the tag was wider than the card's content
+// box, so it dragged the card's text out past its own edges.
+//
+// The mentor who never opens the filter rail is still covered: the project
+// page carries a banner naming their club. See notes.md for the inverted
+// version of this idea, which is probably the right one.
+//
+// Only filter groups the fixtures can actually honour are here. The live rail
+// also has PDF only and Hardware; faking them would put controls in a testing
+// session that do nothing. Interest works, but only because facets.ts invents
+// it — see the warning there.
+
+/** The live band colour, sampled from the page. No green in the token set. */
+const HERO_GREEN = 'rgb(65, 180, 82)'
+
+export interface Filters {
+ query: string
+ classroomOnly: boolean
+ levels: number[]
+ interests: string[]
+ technologies: string[]
+}
+
+interface Props {
+ projects: Project[]
+ filters: Filters
+ onFiltersChange: (next: Filters) => void
+ onView: (projectId: string) => void
+}
+
+function toggle(list: T[], value: T, on: boolean) {
+ return on ? [...list, value] : list.filter((item) => item !== value)
+}
+
+export function ProjectSelector({
+ projects,
+ filters,
+ onFiltersChange,
+ onView,
+}: Props) {
+ const set = (patch: Partial) => onFiltersChange({ ...filters, ...patch })
+
+ // The design system's SearchInput is uncontrolled, so its value has to come
+ // in as `defaultValue` — and wiring that straight to live state is a trap:
+ // React re-assigns the DOM value when `defaultValue` changes, which can
+ // fight whatever the person is typing. Captured once here instead, so it
+ // never changes while mounted. Returning from a successful import remounts
+ // the screen, which is when the stored query gets picked up again.
+ const [initialQuery] = useState(filters.query)
+
+ const levels = [...new Set(projects.map((p) => p.level))].sort()
+ const technologies = [...new Set(projects.map((p) => p.language))].sort()
+ const interests = [...new Set(projects.map(interestOf).filter(Boolean) as string[])].sort()
+
+ const query = filters.query.trim().toLowerCase()
+ const visible = projects.filter((p) => {
+ if (filters.classroomOnly && !usableInClassroom(p)) return false
+ if (filters.levels.length > 0 && !filters.levels.includes(p.level)) return false
+ if (filters.technologies.length > 0 && !filters.technologies.includes(p.language)) return false
+ const interest = interestOf(p)
+ if (filters.interests.length > 0 && (!interest || !filters.interests.includes(interest)))
+ return false
+ if (query && !`${p.title} ${p.intro}`.toLowerCase().includes(query)) return false
+ return true
+ })
+
+ return (
+
+ {/* The green band. 24px radius and 40px of padding are the live values;
+ the token set has neither, and neither is a colour or a font size. */}
+
+
Find a project
+
+ {/* Requires a visible label, so this carries one where the live
+ page has none — an accessibility gain rather than a fidelity
+ loss. See `initialQuery` above for why the value is not wired
+ straight to state. */}
+ set({ query: event.target.value })}
+ onClick={() => {}}
+ />
+
+
+
+ {/* Not the shared `cc-columns` rail-and-content layout, which fixes the
+ rail at 260px. The design system's checkbox label carries a hardcoded
+ min-width of 240px, and a 260px card only has 212px inside its
+ padding — so every checkbox in the rail overflowed it. Flex with wrap
+ rather than a grid, so the two columns still stack on a narrow window
+ without needing a media query an inline style cannot express. */}
+
+
+
+
Filter
+
+ The project list automatically updates when you apply a filter.
+
+
+ {/* The proposal. Ungrouped, above the first group heading, which is
+ exactly where the live rail puts its standalone "PDF only"
+ checkbox — so it needs no legend of its own, and a legend reading
+ "Code Classroom" above a label saying the same thing was only
+ ever repetition.
+
+ Where it sits is still worth arguing about: first makes it
+ findable, and also makes it the first thing a mentor has to
+ understand about a page they came to for something else. */}
+ set({ classroomOnly: event.target.checked })}
+ />
+
+
+
+
+
+ {/* Where the chart's "Scratch" box actually lives. */}
+
+
+
+ {/* The live cards lead with an illustration running to
+ the card's edges, so it is pulled back out through
+ the card's own padding. */}
+
+
+
+
+
+ {project.language} - Level {project.level}
+
+
+ {/* On the live page the title is the link, not the card
+ and not a button. */}
+ onView(project.id)}
+ >
+ {project.title}
+
+
+
+
+ {/* Only the topic tag, as on the live cards. Whether a
+ project is already in one of the mentor's classes is
+ deliberately NOT here: a card in a catalogue shared
+ with young people is the wrong place for a fact about
+ one mentor's classes, and "already added" only
+ matters once you are looking at the project. It is on
+ the project page instead. */}
+ {interest && (
+
+
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/ShowToStudents.tsx b/src/prototypes/import-mentor/start-on-projects/ShowToStudents.tsx
new file mode 100644
index 0000000..f9c4de4
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/ShowToStudents.tsx
@@ -0,0 +1,89 @@
+import { Alert, Button, Card } from '../../../kit'
+
+// PROPOSED, and it contradicts the live product — which is why it is worth
+// building rather than arguing about.
+//
+// The chart ends both branches at "[Show to students]", in square brackets,
+// with a sticky asking whether mentors understand the project is hidden by
+// default. But the real Code Classroom project page (screens/EducatorProjectPage)
+// offers "Hide from students", which means a project is VISIBLE the moment it
+// exists. So this screen inverts today's behaviour: nothing is shown until the
+// mentor says so.
+//
+// Both readings are defensible. Hidden-by-default lets a mentor set a session
+// up in advance without young people wandering into a half-built project;
+// visible-by-default means one less step and nothing to forget. The cost of
+// hidden-by-default is the whole reason for the sticky: a mentor who does not
+// realise has a room of young people who cannot see the thing they came for.
+//
+// The "Tool tip" sticky is realised as the hint below the toggle. Whether a
+// tooltip is enough to carry a default nobody expects is the thing to watch.
+
+interface Props {
+ projectTitle: string
+ className: string
+ memberCount: number
+ shown: boolean
+ onShow: () => void
+ onDone: () => void
+ onBack: () => void
+}
+
+export function ShowToStudents({
+ projectTitle,
+ className,
+ memberCount,
+ shown,
+ onShow,
+ onDone,
+ onBack,
+}: Props) {
+ return (
+
+
+
+
+
+
+
{projectTitle}
+
In {className}
+
+
+ {shown ? (
+
+
+ {memberCount === 0
+ ? `Nobody is in ${className} yet, so there is still nobody to see it.`
+ : `All ${memberCount} young people in ${className} can open it now.`}
+
+
+ ) : (
+
+
+ You are the only person who can see this project. It stays hidden until you show it.
+
+
+ )}
+
+
+
Show to students
+ {/* The "Tool tip" sticky, as copy. A default nobody expects is being
+ carried by one line of hint text — that is the thing to test. */}
+
+ {shown
+ ? `${projectTitle} is showing to everyone in ${className}. You can hide it again from the project page.`
+ : `New projects are hidden so you can set them up before a session. Young people will not see ${projectTitle} in ${className} until you show it to them.`}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/facets.ts b/src/prototypes/import-mentor/start-on-projects/facets.ts
new file mode 100644
index 0000000..86eab12
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/facets.ts
@@ -0,0 +1,68 @@
+import type { Project } from '../../../fixtures'
+
+// What the project selector filters on. Compatibility is a real field on the
+// fixtures now; interest is still stood in for here — read the warning.
+
+/**
+ * Which projects can be used inside Code Classroom.
+ *
+ * Reads `usableInClassroom` from the fixtures, which is the real thing rather
+ * than a guess. An earlier version of this file invented the answer, because
+ * the fixtures had no such field and a filter that excluded nothing could not
+ * be tested — the field and two physical computing projects were added for
+ * exactly this. Absent means usable; see the field's comment in
+ * `src/fixtures/types.ts`.
+ *
+ * Sarah's sticky is the reason any of it exists: *"Should we only allow them
+ * to add embedded editor projects? Think yes — we know that having separate
+ * tabs is pain for younger users."* Note whose problem that is. The filter
+ * looks like a convenience for the mentor and is really about the young
+ * person's session.
+ */
+export function usableInClassroom(project: Project): boolean {
+ return project.usableInClassroom !== false
+}
+
+/**
+ * What the filter is called. Sarah's sticky says the wording is TBD, so this
+ * is a proposal rather than a decision.
+ *
+ * Built to match the live rail's own idiom: it already carries a standalone
+ * "PDF only" checkbox above the first group heading, so an "X only" filter is
+ * a shape a mentor has seen on this page before. Short enough for one line in
+ * the rail, which "Can be used in my Code Classroom" was not.
+ *
+ * Dropping "my" is deliberate and not only about length. Compatibility is a
+ * property of the PROJECT — whether it runs in an embedded editor — and has
+ * nothing to do with which classroom happens to be yours. "My" implied a
+ * personalised filter that was never personalised.
+ *
+ * Alternatives, if this one tests badly: "Works in Code Classroom" is clearer
+ * and still fits; "Can be added to a class" describes what the mentor gets to
+ * do rather than what the project is.
+ */
+export const FILTER_LABEL = 'Code Classroom only'
+
+/**
+ * What a project is *about*. **Also invented**, and for a specific reason.
+ *
+ * Sarah's testing assumption on the chart is that *"mentors will find a
+ * project based on technology over interest"*. The live selector puts Interest
+ * and Technology side by side in one rail, which makes that assumption
+ * watchable — you can see which group a mentor reaches for first.
+ *
+ * The fixtures carry no interest, so it could not be tested at all without
+ * this. Values are taken from the live page's own Interest list; which project
+ * got which is a guess, and a harmless one — nothing in the flow branches on
+ * it. Unlike the compatibility map above, being wrong here costs nothing.
+ */
+const INTERESTS: Record = {
+ 'space-talk': 'Space',
+ 'rock-band': 'Music',
+ chatbot: 'Communication',
+ 'find-the-bug': 'Games',
+}
+
+export function interestOf(project: Project): string | undefined {
+ return INTERESTS[project.id]
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/meta.ts b/src/prototypes/import-mentor/start-on-projects/meta.ts
new file mode 100644
index 0000000..46a8a0f
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/meta.ts
@@ -0,0 +1,24 @@
+import type { PrototypeMeta } from '../../types'
+
+export const meta: PrototypeMeta = {
+ title: 'Start on Projects, not Classroom',
+
+ owner: 'Sarah Tucker',
+
+ hypothesis:
+ 'Mentors are more likely to start on Code Club Projects, because it is the site they already know — so the route into Code Classroom should begin in the catalogue, with a filter for what will actually work in a class, rather than beginning in Code Classroom and sending them out.',
+
+ status: 'sketch',
+
+ // The SAME mentor as out-to-projects-and-back, on purpose. These two
+ // prototypes exist to be compared, and they should differ in exactly one
+ // thing: where the mentor starts. Same person, same club, same two classes,
+ // so anything a session turns up is about the route and not about the cast.
+ cast: { piAccount: 'pi-mentor-thabo' },
+
+ // The flow starts SIGNED OUT and signs Thabo in itself, because a mentor
+ // browsing Code Club Projects usually has not logged in — there has never
+ // been a reason to. That is the condition the import route actually has to
+ // work from, and it changes what the site is allowed to know about them.
+ ownsSignIn: true,
+}
diff --git a/src/prototypes/import-mentor/start-on-projects/notes.md b/src/prototypes/import-mentor/start-on-projects/notes.md
new file mode 100644
index 0000000..8dd510c
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/notes.md
@@ -0,0 +1,224 @@
+A mentor starts on Code Club Projects' own project selector, filters to what
+will work in a class, and imports. The mirror of its sibling,
+`out-to-projects-and-back`, which starts in Code Classroom.
+
+Built to Sarah's flow chart, "Option 2: Code Club Projects → Code Classroom"
+(Code Club account management discovery, FigJam).
+
+## What question is this answering?
+
+`import-mentor` — how does a mentor get a Code Club project into Code
+Classroom?
+
+Both prototypes in this lane answer that. They disagree about one thing, and
+only one thing: **where a mentor starts.** Option 1 bets that Code Classroom is
+the hub for a club, so the route begins there and goes out. This one bets that
+Code Club Projects is the site a mentor already knows, so the route begins in
+the catalogue and ends in a class.
+
+That is the point of having two. Everything else is held identical on purpose
+— same mentor (**Thabo Mokoena** at Westlands Library Code Club), same club,
+same two classes, same add-to-class cards, same boundary between the products.
+If a session reacts to something, it should be the starting point and not the
+furniture.
+
+**One thing is deliberately NOT the same, and it is a consequence of the
+starting point.** This flow begins **signed out**. A mentor who opens Code Club
+Projects to browse has usually not logged in — there has never been a reason
+to, because browsing needs no account. Its sibling starts inside Code
+Classroom, which you cannot reach without logging in, so it can assume an
+identity from the first screen. This one cannot, and that changes what the site
+is allowed to know.
+
+## Why this approach
+
+**The first screen is the live project selector**
+(`projects.raspberrypi.org/en/projects`), and using the real page settled two
+things a mocked-up home page had got wrong.
+
+**It collapses three of the chart's boxes into one screen, and that is the
+chart read correctly rather than a shortcut.** "Code Club Projects", then
+"Scratch", then "Filter by 'can be used in my code classroom'" are not three
+pages. On the real site Scratch is a checkbox in the **Technology** group of
+the selector's filter rail — right next to where the new filter would go. A
+mentor arrives, ticks Scratch, ticks the Code Classroom filter, and is looking
+at their shortlist, all in one place.
+
+**It makes Sarah's own testing assumption answerable.** Her sticky says
+*"mentors will find a project based on technology over interest"*. The live
+rail puts Interest and Technology side by side, so you can watch which group a
+mentor reaches for first. An earlier version of this prototype sent them to a
+Scratch category page, which quietly assumed the answer — you cannot observe a
+choice that has already been made for you.
+
+**A filter beats a category, and building both made that obvious.** The
+sibling prototype gives compatibility a category of its own, "Code Classroom
+compatible". A category defined by a constraint in another product sits oddly
+beside categories defined by what they teach, and if nearly everything
+qualifies it is a category doing no work. As a filter it is exactly what
+filters are for: narrowing a list you already chose to browse.
+
+**Both of Sarah's "explore in design" stickies are built, not just noted.**
+
+- *"Wording TBD. We could also have a tag design or a way to differentiate
+ which cards are compatible with Code Classroom"* — **built, then removed.**
+ See below; the idea is right but the version on the card is not.
+- *"If a user finds themselves on a project page and has a club connected to
+ their account, can we/should we call out the ability to get started with Code
+ Classroom"* — the project landing page carries a banner naming their club. It
+ is the only thing in the flow connecting a mentor's club to the project in
+ front of them, and it is worth asking whether it earns its place on a page
+ whose whole design drives at "Start project".
+
+## What is proposed, and what is real
+
+Real screens, unchanged: `ProjectPage`, `EducatorClassPage`,
+`EducatorProjectPage`.
+
+Proposed — nothing like these exists:
+
+- **One checkbox** in `ProjectSelector.tsx`. Everything else on that screen is
+ the live page: the green "Find a project" band with its search box, the
+ filter rail, "Showing N projects", the card grid. Cards carry an interest tag
+ like the live ones, and "In a class" once a project has been added.
+- The **club banner** on the project landing page, and the **"already in a
+ class"** header that replaces it once the project has been added. Both only
+ appear when signed in.
+- Offering **"Add to a class" while signed out**, with the log-in coming after
+ the intent rather than before it.
+- `ProjectPage`'s "Add to a class" button — the shared screen takes an
+ `onImport` prop for exactly this, and passing it is a proposal.
+- `AddToClass.tsx` and `NewClass.tsx` — copied from the sibling prototype
+ rather than shared, which is what this repo asks for. They follow Experience
+ CS's equivalent screens.
+- `ShowToStudents.tsx` — and it contradicts the live product. Same open
+ question as the sibling: the real project page only offers "Hide from
+ students", so a project is visible the moment it exists.
+
+Only filter groups the fixtures can honour are in the rail. The live page also
+has **PDF only** and **Hardware**; faking them would put controls in a testing
+session that do nothing.
+
+Nothing outside this folder was touched.
+
+## Starting signed out is the most interesting thing here
+
+It was a one-line change to `meta.ts` and it produced the sharpest findings in
+either prototype.
+
+**Signed out, Code Club Projects cannot call out a club it does not know
+about.** Sarah's green sticky asks whether we should surface Code Classroom "if
+a user has a club connected to their account" — and the answer is that there is
+no account to connect to until they log in. So the project page says nothing
+about their club on first visit. The call-out she is asking for is only
+possible *after* the step most likely to lose them.
+
+**"Add to a class" is offered anyway, and the log-in comes after the intent.**
+Asking someone to log in before they have said what they want is how you lose
+them, so the button is visible signed out and the sign-in is triggered by
+pressing it. Whether an offer to add to a class means anything to someone the
+site has not identified is a real question for a session.
+
+**It adds a third product to the crossing.** Signing in is a Pi Accounts page,
+so a mentor who started on Code Club Projects passes through Pi Accounts on the
+way to Code Classroom. Three products, two of which they did not ask for. The
+sign-in screen is `screens/MentorSignIn`, which is **not verified** — it is
+behind a login, so it was built from the pattern and its details are probably
+wrong.
+
+**The log-in wall is now the most expensive step in the flow**, and the thing
+most worth watching. It also makes the return path matter: signing in returns
+them to choosing a class for the project they were looking at, not to the
+catalogue. Any other behaviour here would be infuriating, and it is the kind of
+detail that gets lost between a flow chart and a build.
+
+## Why there is no "Works with Code Classroom" tag
+
+Sarah's sticky floated it, it was built, and Divya asked whether it was
+necessary. It is not, and the reasons are worth keeping:
+
+- **It would sit on nearly every card.** A badge on the majority carries no
+ information. If anything is worth marking it is the exceptions.
+- **This page is shared with young people.** The selector has no idea who is
+ looking, and "Works with Code Classroom" means nothing to a nine-year-old
+ browsing for something to make. A mentor-only concern does not belong in a
+ catalogue everyone uses.
+- **It broke the cards.** The tag rendered 245px wide; at the grid's narrowest
+ column a card has 192px inside its padding, so the tag dragged the card's own
+ text out past its edges.
+
+The mentor who never opens the filter rail is still covered — the project page
+carries a banner naming their club, once they are signed in.
+
+**"In a class" is not on the cards either**, for the same reason and one more.
+Whether a project is already in one of *your* classes is a fact about one
+mentor, and the catalogue is shared with young people; it also only matters
+once you are looking at the project rather than scanning a grid. It lives on
+the project page's header instead, which is where a mentor is deciding.
+
+**The inverted version is probably the right idea**, and it is not built: say
+nothing on the cards, and tell a mentor "this will not work in a class" at the
+moment they try to add it. Mark the exception, at the point of action, to the
+person it concerns. Worth designing if the filter survives testing.
+
+## A design system constraint worth knowing
+
+`.rpf-input-checkbox` carries a hardcoded `min-width: 240px`. The shared
+`.cc-columns` layout fixes its rail at 260px, which leaves 212px inside a
+card's padding — so **any filter rail built with `cc-columns` and checkboxes
+overflows its own card.** Both prototypes in this lane hit it. Both now use a
+flex rail with a 19rem basis instead, which also still stacks on a narrow
+window. Worth raising upstream, or worth a wider rail variant in the shared
+CSS.
+
+## What the filter actually excludes
+
+`usableInClassroom` is a real field on the fixtures, and two projects carry it
+as `false`: **Rain or Shine** (Scratch, needs a Raspberry Pi and a rain sensor)
+and **Door Watcher** (Python, needs a Pi and a motion sensor). Ticking the
+filter takes the catalogue from six projects to four.
+
+That matters more than it sounds. An earlier version of this prototype invented
+which project was incompatible, because the fixtures had no such field — so the
+filter either excluded nothing, or excluded something for no reason anyone could
+defend. Neither could be put in front of a mentor: a filter that visibly lies is
+worse than no filter. The field and the two hardware projects were added to
+`src/fixtures` to fix that, which is the one change in this work that reaches
+outside a prototype folder.
+
+Physical computing is a real and sizeable part of the live catalogue, so the
+exclusion is the honest kind. **The question a session can now answer is what a
+mentor does when the project they wanted is not in the list** — and whether
+"Code Classroom only" tells them enough to work out why.
+
+Interest is still invented (see `facets.ts`). Nothing branches on it; it exists
+so the technology-versus-interest question can be watched at all, and being
+wrong about which project is about Space costs nothing.
+
+## What I'd want to watch in testing
+
+- **Which product they open, before any screen is on offer.** Ask a mentor to
+ find something for next week and watch what they reach for. That is the
+ hypothesis, and it is answered before they touch either prototype.
+- **Which filter group they reach for first.** Interest and Technology are side
+ by side. This is Sarah's assumption, and this screen is where it gets tested.
+- **Whether they open the rail at all.** Many people search instead, or just
+ scroll. If the tag on the cards is doing all the work, the filter is a
+ designer's answer to a problem the tag already solved.
+- **Whether anything on that page says Code Classroom exists.** One checkbox,
+ in a rail, on a page whose job is finding a project — in front of a mentor
+ who may not know the two products connect at all.
+- **Whether the club banner earns its place** on the project page, or reads as
+ being told what you run when you came here to find something.
+- **Whether "Add to a class" survives "Start project"**, which the page repeats
+ top and bottom.
+- **Whether the log-in wall loses them.** It arrives mid-browse, after they
+ have found something they want. Watch for hesitation, and ask afterwards
+ whether they would have bothered.
+- **Whether they ever press "View your class".** It is the only button that
+ leaves Code Club Projects. If nobody does, the class page is not where a
+ mentor checks their work — and the crossed-out eye nobody sees is a problem.
+
+## What we learned
+
+Not tested yet.
diff --git a/src/prototypes/import-mentor/start-on-projects/prototype.tsx b/src/prototypes/import-mentor/start-on-projects/prototype.tsx
new file mode 100644
index 0000000..a7b1782
--- /dev/null
+++ b/src/prototypes/import-mentor/start-on-projects/prototype.tsx
@@ -0,0 +1,565 @@
+import { useState, type ReactNode } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { Alert, Button, Card } from '../../../kit'
+import {
+ PROJECTS,
+ classesInSchool,
+ piAccount,
+ project,
+ school,
+ schoolsForMentor,
+ type ClassGroup,
+ type Project,
+} from '../../../fixtures'
+import {
+ EducatorClassPage,
+ EducatorProjectPage,
+ MentorSignIn,
+ ProjectPage,
+} from '../../../screens'
+import { Surface } from '../../../surfaces'
+import { useSession } from '../../../session'
+import { AddToClass, AddedToClass } from './AddToClass'
+import { ProjectSelector, type Filters } from './ProjectSelector'
+import { HowToCreateClass, NameAndDescription } from './NewClass'
+import { ShowToStudents } from './ShowToStudents'
+import { meta } from './meta'
+
+// Everything comes from the one mentor named in meta.ts — the same mentor as
+// the sibling prototype, so the two can be compared. Change the cast and the
+// club and its classes follow. Nothing below hardcodes Westlands.
+//
+// The pink sticky on the flow chart — "this assumes the mentor is logged in
+// and has a school" — is carried by the cast rather than by a sign-in step,
+// which is why `ownsSignIn` is not set.
+const MENTOR = meta.cast?.piAccount ?? ''
+const CLUB = school(schoolsForMentor(MENTOR)[0].id)!
+const CLASSES = classesInSchool(CLUB.id)
+const MENTOR_EMAIL = piAccount(MENTOR)?.email ?? ''
+
+/** What the mentor set up before today, so the class is already running. */
+const ALREADY_THERE: Record = {
+ [CLASSES[0].id]: ['space-talk'],
+}
+
+/** The narrow centred column the add-to-class cards sit in. */
+const CENTRED_COLUMN = { width: '100%', maxWidth: '520px', margin: '0 auto' } as const
+
+/** A project sitting in one of this mentor's classes. */
+interface Placed {
+ project: Project
+ classId: string
+ /** Hidden until the mentor says otherwise — see ShowToStudents.tsx. */
+ shownToStudents: boolean
+}
+
+// There is no "create your own" branch in this chart. Option 2 is entirely
+// about importing, which is why it is shorter than its sibling.
+type Step =
+ // Code Club Projects
+ | 'selector'
+ | 'project-landing'
+ | 'sign-in'
+ | 'choose-class'
+ | 'new-class-how'
+ | 'new-class-name'
+ | 'success'
+ // Code Classroom, reached only by asking for it
+ | 'class'
+ | 'project-page'
+ | 'show-to-students'
+
+export default function StartOnProjects() {
+ const [params] = useSearchParams()
+ // Author commentary belongs to the team, not to the person being tested.
+ const showWorkbench = params.get('full') !== '1'
+
+ // Signed in, or not, is the session's business rather than this flow's —
+ // `ownsSignIn` in meta.ts holds the cast back so the fiction starts logged
+ // out, and this reads the result.
+ const { piAccount: account, signInPiAccount } = useSession()
+ const signedIn = account?.id === MENTOR
+
+ // `?autofill=0` empties the sign-in fields, which is what you want when the
+ // thing you are there to watch is a mentor actually logging in.
+ const autofill = params.get('autofill') !== '0'
+ const [email, setEmail] = useState(autofill ? MENTOR_EMAIL : '')
+ // Visibly not a real password. Nobody should ever type one into this.
+ const [password, setPassword] = useState(autofill ? 'not-a-real-password' : '')
+ const [signInError, setSignInError] = useState()
+
+ const [step, setStep] = useState('selector')
+ /**
+ * The selector's filters live here, not in the screen, because the chart
+ * loops "find another project" back to the FILTERED list — so they have to
+ * survive leaving the page. All start empty: filters that arrive switched on
+ * would narrow the catalogue before the mentor knows they exist, and "do
+ * they ever touch the Code Classroom one?" is a thing to watch.
+ */
+ const [filters, setFilters] = useState({
+ query: '',
+ classroomOnly: false,
+ levels: [],
+ interests: [],
+ technologies: [],
+ })
+
+ const [classes, setClasses] = useState(CLASSES)
+ const [placed, setPlaced] = useState(() =>
+ Object.entries(ALREADY_THERE).flatMap(([classId, projectIds]) =>
+ projectIds.map((id) => ({ project: project(id)!, classId, shownToStudents: true })),
+ ),
+ )
+
+ const [activeClassId, setActiveClassId] = useState(CLASSES[0].id)
+ const [activeProjectId, setActiveProjectId] = useState()
+ /** The project being looked at on Code Club Projects, before it is added. */
+ const [viewingId, setViewingId] = useState()
+ const [newClassName, setNewClassName] = useState()
+
+ const activeClass = classes.find((group) => group.id === activeClassId) ?? classes[0]
+ const activePlaced = placed.find(
+ (item) => item.project.id === activeProjectId && item.classId === activeClassId,
+ )
+ const activeProject = activePlaced?.project
+ const viewing = viewingId ? project(viewingId) : undefined
+
+ const inClass = (classId: string) =>
+ placed.filter((item) => item.classId === classId).map((item) => item.project)
+
+ /** The mentor's classes that already hold the project being looked at. */
+ const viewingIn = viewing
+ ? placed
+ .filter((item) => item.project.id === viewing.id)
+ .map((item) => classes.find((group) => group.id === item.classId))
+ .filter((group): group is ClassGroup => Boolean(group))
+ : []
+
+ /** Projects in a class that students cannot see yet — the crossed-out eye. */
+ const hiddenIn = (classId: string) =>
+ placed
+ .filter((item) => item.classId === classId && !item.shownToStudents)
+ .map((item) => item.project.id)
+
+ function memberCount(classId: string) {
+ return classes.find((group) => group.id === classId)?.studentIds.length ?? 0
+ }
+
+ function place(item: Project, classId: string) {
+ setPlaced((current) =>
+ current.some((p) => p.project.id === item.id && p.classId === classId)
+ ? current
+ : [...current, { project: item, classId, shownToStudents: false }],
+ )
+ setActiveClassId(classId)
+ setActiveProjectId(item.id)
+ }
+
+ function show() {
+ setPlaced((current) =>
+ current.map((p) =>
+ p.project.id === activeProjectId && p.classId === activeClassId
+ ? { ...p, shownToStudents: true }
+ : p,
+ ),
+ )
+ }
+
+ /** Author commentary. Rendered only in the workbench. */
+ function Note({ children }: { children: ReactNode }) {
+ if (!showWorkbench) return null
+ return (
+
+
{children}
+
+ )
+ }
+
+ const crumbs = (...rest: string[]) => ['Your school', ...rest]
+
+ /** Code Classroom's only navigation. The trail is always school, class... */
+ function goToCrumb(index: number) {
+ if (index === 0) setStep('class')
+ else if (index === 1) setStep('class')
+ }
+
+ // --- Code Club Projects: where this chart starts --------------------------
+
+ if (step === 'selector') {
+ return (
+
+
+ {
+ setViewingId(projectId)
+ setStep('project-landing')
+ }}
+ />
+
+ The whole bet, and the opposite of the sibling prototype's. This one says a mentor
+ opens the site they already know and finds a project the way they always have — so
+ the route into Code Classroom has to start here. If they would really open Code
+ Classroom first, this flow begins in the wrong place and "Out to Projects and back" is
+ the right shape. Run the two with different mentors and the answer is the finding.
+
+
+ This is the live project selector, and the proposal is one checkbox in the rail plus a
+ tag on the cards. Three things to watch, in order of how much they would change:
+
+
+ Which filter group they reach for. Sarah's assumption on the chart is
+ that "mentors will find a project based on technology over interest". Interest
+ and Technology sit side by side here, so this screen can actually answer that — and
+ the chart's "Scratch" box is a tick in Technology, not a page of its own.
+
+
+ Whether they open the rail at all. Most people do not, which is why
+ compatible cards carry a tag regardless. If the tag is doing all the work, the filter
+ is a designer's answer to a problem the tag already solved.
+
+
+ Whether anything here says Code Classroom exists. One checkbox, in a
+ rail, on a page whose job is finding a project — against a mentor who may not know
+ the two products connect at all.
+
+
+ {/* The project's header, and the only place that says anything
+ about this mentor's classes. Deliberately not on the catalogue
+ cards: "already in a class" is a fact about one mentor, and the
+ catalogue is shared with young people.
+
+ Signed out there is nothing here at all, which is the honest
+ state — Code Club Projects has no idea who is looking, so it
+ cannot call out a club it does not know about. Sarah's green
+ sticky asks whether we should make that call-out "if a user has a
+ club connected to their account", and the answer only exists once
+ they are logged in. */}
+ {signedIn &&
+ (viewingIn.length > 0 ? (
+ group.name).join(' and ')}`}
+ >
+
+ Young people in {viewingIn.length === 1 ? 'that class' : 'those classes'} can
+ already find {viewing.title}. You can add it to another class as well.
+
+
+ ) : (
+
+
+ You can add this project to one of your classes in Code Classroom, with its
+ instructions and starter code.
+
+
+ ))}
+
+ setStep('project-landing')}
+ // Visible whether or not they are logged in, and the sign-in
+ // happens after the intent rather than before it. Asking someone
+ // to log in before they have said what they want is how you lose
+ // them.
+ onImport={() => setStep(signedIn ? 'choose-class' : 'sign-in')}
+ />
+
+
+ {signedIn
+ ? 'Signed in, so the page can say something about their club — the banner is the green sticky\'s question made concrete. Worth asking whether it earns its place: it is the loudest thing on a page whose whole design drives at "Start project", and a mentor who came here to find something for Thursday may not want to be told what they run.'
+ : 'Signed out, which is how a mentor usually arrives, and the page has nothing to say about their club because it does not know they have one. "Add to a class" is still offered — the intent comes first and the log-in comes after, because asking someone to log in before they have said what they want is how you lose them. Watch whether an offer to add to a class means anything to someone the site has not identified.'}
+
+
+ Either way "Add to a class" has to compete with "Start project", which the live page
+ repeats top and bottom.
+
+
+ {
+ setEmail(value)
+ setSignInError(undefined)
+ }}
+ onPasswordChange={(value) => {
+ setPassword(value)
+ setSignInError(undefined)
+ }}
+ onLogIn={() => {
+ // Matched on email only; the password is never checked, because
+ // nothing here is real.
+ if (email.trim().toLowerCase() !== MENTOR_EMAIL.toLowerCase()) {
+ setSignInError(
+ `We do not recognise that email address. This mentor's account is ${MENTOR_EMAIL}.`,
+ )
+ return
+ }
+ signInPiAccount(MENTOR)
+ setSignInError(undefined)
+ setStep('choose-class')
+ }}
+ error={signInError}
+ />
+
+ A third product, mid-task. Adding a project to a class turns out to need a Raspberry Pi
+ account, and signing in to one is a Pi Accounts page — so a mentor who started on Code
+ Club Projects has now been sent somewhere with different chrome before they have
+ finished the thing they came to do.
+
+
+ This screen is NOT VERIFIED: it is behind a login, so `screens/MentorSignIn` was built
+ from the pattern and its details are probably wrong.
+
+
+ Two things worth watching. Whether anyone abandons here — a log-in wall in the middle
+ of a browsing session is the most expensive step in the flow. And whether they come
+ back to the right place: after signing in this returns them to choosing a class for
+ the project they were looking at, rather than to the catalogue, which is the only
+ version of this that is not infuriating.
+
+
+
+ )
+ }
+
+ // --- Still on Projects: adding to a class ---------------------------------
+ //
+ // The chart puts these in the Code Classroom lane. They stay on Code Club
+ // Projects here for two reasons: Divya settled that question on the sibling
+ // prototype, following Experience CS, and holding it identical is what lets
+ // the two prototypes be compared on the one thing they are meant to differ
+ // about — where the mentor starts.
+
+ if (step === 'choose-class' && viewing) {
+ return (
+
+
+ {
+ place(viewing, classId)
+ setNewClassName(undefined)
+ setStep('success')
+ }}
+ onNew={() => setStep('new-class-how')}
+ onBack={() => setStep('project-landing')}
+ />
+
+ Identical to the sibling prototype, on purpose. If these two flows differed here as
+ well as at the start, a session could not tell which difference it was reacting to.
+
+
+ setStep('new-class-name')}
+ onBack={() => setStep('choose-class')}
+ />
+
+ A decision point with one option: the chart draws this diamond with a single branch
+ leaving it, in both options. Left as drawn rather than invented.
+
+
+ And it still claims the big thing — creating a Code Classroom class from inside Code
+ Club Projects. Picking an existing class is a read; making one is a write into another
+ product.
+
+
+ setStep('class')}
+ // The chart loops back to the FILTERED LIST, not to a fresh
+ // catalogue — so a mentor keeps every filter they set.
+ onFindAnother={() => setStep('selector')}
+ />
+
+ "Find another project" returns to the selector with every filter and the search
+ text as they left them, which is how the chart draws it. That matters more in this
+ flow than in its sibling: a mentor who started here is already mid-browse, and setting
+ up a term of sessions is the obvious next thing.
+
+
+ "View your class" is the only button in the whole flow that leaves Code Club Projects.
+
+
+
+ )
+ }
+
+ // --- Code Classroom, reached only by asking -------------------------------
+
+ if (step === 'class') {
+ return (
+
+
+ setStep('selector')}
+ onOpenProject={(projectId) => {
+ setActiveProjectId(projectId)
+ setStep('project-page')
+ }}
+ onCopyLink={() => {}}
+ onClassMembers={() => {}}
+ />
+
+ The first Code Classroom screen in the flow, and the mentor asked to come here. The
+ project they just imported is in the list and marked with a crossed-out eye, because
+ it arrives hidden from students.
+
+
+ "Add project" here opens Code Classroom's own create dialog in the real product. In
+ this prototype it goes back to Code Club Projects, which is a shortcut rather than a
+ proposal — the sibling prototype is where that fork is designed.
+
+
+ {}}
+ onHideFromStudents={() => setStep('show-to-students')}
+ onCopyLink={() => {}}
+ onOpenWork={() => {}}
+ />
+
+ {activePlaced?.shownToStudents
+ ? 'Visible now. "Student work" stays empty until someone saves, so a mentor cannot tell who has started and got stuck from who has not started.'
+ : 'The imported project, hidden. The only thing on this page about visibility is "Hide from students" — the opposite of what the flow just did to it.'}
+
+
+ setStep('class')}
+ onBack={() => setStep('project-page')}
+ />
+
+ In square brackets on the chart, and it inverts the live product, which only offers
+ "hide". Same open question as the sibling prototype — see notes.md there and here.
+
+
+ Sarah's other sticky lands here too: "when a YP starts the project, should they
+ only be able to do it once, or are the options more like continue or start a new
+ one". Nothing in this flow answers that, and it is a young person's question
+ rather than a mentor's — it belongs in the import-yp lane.
+
+
+
+ )
+ }
+
+ // Any step whose data went missing — only reachable by editing state by hand.
+ return (
+
+
+
+
This step needs a project and there is not one.
+
+ setStep('selector')} />
+
+
+ )
+}
diff --git a/src/screens/README.md b/src/screens/README.md
index 9e62226..758c023 100644
--- a/src/screens/README.md
+++ b/src/screens/README.md
@@ -69,6 +69,13 @@ Two structural facts worth designing around:
- **A mentor only sees work that has been SAVED.** "Only students who have
saved their project will appear here." Walking round a room, a mentor cannot
tell who has started and got stuck from who has not started at all.
+- **Visibility is per project, and the class page shows it.** Each row on
+ `EducatorClassPage` can carry a crossed-out eye, which is how a mentor tells
+ a project nobody can see yet from one that is live. Pass `hiddenProjectIds`
+ if your flow has a notion of visibility. Any flow that puts a project into a
+ class inherits some visibility state whether it decides one or not — and
+ `EducatorProjectPage` only offers "Hide from students", so where "show" lives
+ is an open question rather than a settled one.
## Where mentor onboarding actually starts
diff --git a/src/screens/ccp/ProjectEditor.tsx b/src/screens/ccp/ProjectEditor.tsx
index bdcbc2a..f5eedf0 100644
--- a/src/screens/ccp/ProjectEditor.tsx
+++ b/src/screens/ccp/ProjectEditor.tsx
@@ -152,7 +152,7 @@ export function ProjectEditor({
diff --git a/src/screens/classroom/ClassroomProjectEditor.tsx b/src/screens/classroom/ClassroomProjectEditor.tsx
index 76535a4..c531ed2 100644
--- a/src/screens/classroom/ClassroomProjectEditor.tsx
+++ b/src/screens/classroom/ClassroomProjectEditor.tsx
@@ -113,7 +113,7 @@ export function ClassroomProjectEditor({
diff --git a/src/screens/classroom/EducatorClassPage.tsx b/src/screens/classroom/EducatorClassPage.tsx
index 7a11369..0290b74 100644
--- a/src/screens/classroom/EducatorClassPage.tsx
+++ b/src/screens/classroom/EducatorClassPage.tsx
@@ -1,4 +1,4 @@
-import { Button, Card, Tag } from '../../kit'
+import { Button, Card } from '../../kit'
import type { ClassGroup, Project } from '../../fixtures'
import type { ScreenMeta } from '../types'
@@ -6,7 +6,7 @@ export const meta: ScreenMeta = {
surface: 'classroom',
existsToday: true,
verified: true,
- note: 'What a mentor sees inside a class. Projects are shared with the whole class; each carries starter code the mentor set up.',
+ note: 'What a mentor sees inside a class. Projects are shared with the whole class; each carries starter code the mentor set up, and each is hidden or shown to students individually.',
}
interface Props {
@@ -17,12 +17,27 @@ interface Props {
onOpenProject: (projectId: string) => void
onCopyLink: () => void
onClassMembers: () => void
+ /**
+ * Projects students cannot see yet. The live page marks each row with a
+ * crossed-out eye, so a mentor can tell at a glance what is not live — pass
+ * this if your flow has a notion of visibility, and leave it out if not.
+ */
+ hiddenProjectIds?: string[]
+ /** The per-row overflow menu. Renders whether or not you wire it. */
+ onProjectMenu?: (projectId: string) => void
+ /** The gear beside "Copy link". Renders whether or not you wire it. */
+ onSettings?: () => void
}
/**
* The class page an educator lands on.
*
- * Two things to notice for Code Club:
+ * Built from the live page, so the furniture is the real furniture: "Class
+ * members" is the primary action, projects sit in a bordered list of pale rows
+ * with an overflow menu each, and a project name is bold body text rather than
+ * a link.
+ *
+ * Three things to notice for Code Club:
*
* - "Projects are shared with students and contain starter code created by a
* teacher" — projects here are set up BY the adult, not chosen by the young
@@ -30,6 +45,10 @@ interface Props {
* person browses and picks.
* - "Copy link" is how young people get in, which is the shareable route that
* skips the school code screen.
+ * - **Visibility is per project and shown right here.** The crossed-out eye is
+ * how a mentor tells a project nobody can see yet from one that is live. Any
+ * flow that adds a project to a class inherits that state whether it means
+ * to or not.
*/
export function EducatorClassPage({
classGroup,
@@ -39,14 +58,31 @@ export function EducatorClassPage({
onOpenProject,
onCopyLink,
onClassMembers,
+ hiddenProjectIds = [],
+ onProjectMenu,
+ onSettings,
}: Props) {
return (
{classGroup.name}
-
-
+ {/* Primary on the live page. Getting young people into the class is
+ the job a mentor comes here to do. */}
+
+
+ {})}
+ />
@@ -57,21 +93,49 @@ export function EducatorClassPage({
Projects are shared with students and contain starter code created by a teacher.
+
+ {hidden && (
+ // Labelled rather than aria-hidden: the icon is the only
+ // thing on the row carrying this, so it has to be read.
+
+ visibility_off
+
+ )}
+ onOpenProject(project.id)}>
+ {project.title}
+
+
+ onProjectMenu?.(project.id)}
+ />
+
+ )
+ })}
)}
diff --git a/src/styles/global.css b/src/styles/global.css
index a1d315f..de7d917 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -406,6 +406,12 @@ hr.divider {
display: flex;
flex-direction: column;
width: 100%;
+ /* Capped so a screen does not stretch across a large monitor. In the
+ workbench the whole framed product sits on this measure, centred — the
+ frame already reads as a screenshot rather than a browser window, so
+ narrowing it is consistent with how it is presented. Full screen moves the
+ cap inside, onto the page content; see the full-screen block below. */
+ max-width: var(--surface-measure);
min-height: 420px;
overflow: hidden;
border-radius: var(--radius-sm);
@@ -681,6 +687,43 @@ hr.divider {
border-bottom: none;
}
+/* The class page draws its projects as a bordered, rounded group of pale rows
+ rather than as bare lines of text. Opt-in, because the same .cc-row is used
+ for plain lists elsewhere. */
+.cc-list-boxed {
+ border: 1px solid var(--rpf-grey-150);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+.cc-list-boxed .cc-row {
+ padding: var(--space-2);
+ background: var(--rpf-off-white);
+}
+/* Icon and name travel together on the left; the overflow menu stays right. */
+.cc-row-main {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ min-width: 0;
+}
+/* A project name on the live page is bold body text, not a blue link — the
+ row's overflow menu is the action affordance. Underlined on hover and focus
+ anyway, so it does not rely on being guessable. */
+.cc-row-title {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ font-weight: var(--fw-bold);
+ color: var(--rpf-text);
+ text-align: left;
+ cursor: pointer;
+}
+.cc-row-title:hover,
+.cc-row-title:focus-visible {
+ text-decoration: underline;
+}
+
/* The educator project page puts guidance beside the student work list. */
.cc-columns {
display: grid;
@@ -707,7 +750,12 @@ hr.divider {
there is room, which is how the real editor lays out. */
grid-template-columns: 1fr;
gap: var(--space-2);
- min-height: 420px;
+ /* The editor IS the page it is on, not a panel sitting in the middle of one.
+ Sized from the viewport so it fills a large monitor, with 420px kept as a
+ floor for short windows. `flex: 1` alone would not do it: in the workbench
+ the surface is only as tall as its content, so there is no spare height to
+ grow into. */
+ min-height: max(420px, 68vh);
}
@media (min-width: 860px) {
.editor {
@@ -824,6 +872,10 @@ hr.divider {
.editor-canvas {
flex: 1 1 auto;
padding: var(--space-2);
+ /* So the thing standing in for the editor can stretch to the whole pane
+ rather than sitting as a short box with dead space beneath it. */
+ display: flex;
+ min-height: 0;
}
/* Project instructions markdown */
@@ -1146,6 +1198,30 @@ body.full-screen-prototype .surface {
border: none;
border-radius: 0;
min-height: 100vh;
+ /* Chrome goes edge to edge, as a real product's header and nav do. */
+ max-width: none;
+}
+/* So the cap moves to the page content instead. The bars keep their own
+ full-width backgrounds and borders; only the content column is measured,
+ which is what the real sites do. */
+body.full-screen-prototype .surface-body {
+ width: 100%;
+ max-width: var(--surface-measure);
+ margin-inline: auto;
+}
+/* The bars stay full width but their contents line up with that column,
+ otherwise a breadcrumb sits alone at the far left of a wide monitor while
+ the page it describes starts halfway across. Padding rather than a wrapper,
+ so the background and the bottom border still run edge to edge, and `max()`
+ keeps the ordinary padding once the window is narrower than the measure. */
+body.full-screen-prototype .surface-topbar,
+body.full-screen-prototype .surface-breadcrumbs,
+body.full-screen-prototype .surface-nav,
+body.full-screen-prototype .surface-footer {
+ padding-inline: max(
+ var(--space-2),
+ calc((100% - var(--surface-measure)) / 2 + var(--space-3))
+ );
}
.prototype-full-toggle {
diff --git a/src/surfaces/Surface.tsx b/src/surfaces/Surface.tsx
index 5155fb1..72c189c 100644
--- a/src/surfaces/Surface.tsx
+++ b/src/surfaces/Surface.tsx
@@ -26,6 +26,17 @@ interface SurfaceProps {
* are. The last item is the current page and is not a link.
*/
breadcrumbs?: string[]
+ /**
+ * What happens when a crumb is clicked, by its index in `breadcrumbs`.
+ *
+ * Pass this and the earlier crumbs become working controls; leave it out and
+ * they are plain text. Deliberately NOT optional-with-a-default-destination:
+ * this is the only navigation Code Classroom has, so where a crumb goes is
+ * the prototype's decision, the same as every other callback here. It used
+ * to be a hardcoded link to the screens gallery, which took anyone who
+ * clicked it out of the flow they were being tested on.
+ */
+ onCrumb?: (index: number) => void
/** Right-hand top bar item: "Your Account" for an educator, "Log Out" for a young person. */
account?: string
}
@@ -46,6 +57,7 @@ export function Surface({
badge,
layout,
breadcrumbs,
+ onCrumb,
account,
}: SurfaceProps) {
const surface = SURFACES[id]
@@ -74,8 +86,12 @@ export function Surface({
{index > 0 && /}
{index === breadcrumbs.length - 1 ? (
{crumb}
+ ) : onCrumb ? (
+ onCrumb(index)}>
+ {crumb}
+
) : (
- {crumb}
+ {crumb}
)}
))}
diff --git a/src/surfaces/tokens.css b/src/surfaces/tokens.css
index 1ca09a8..e305cd9 100644
--- a/src/surfaces/tokens.css
+++ b/src/surfaces/tokens.css
@@ -18,6 +18,15 @@
--surface-accent: var(--rpf-text);
--surface-nav: var(--rpf-white);
--surface-nav-border: var(--rpf-grey-150);
+ /* How wide a product page is allowed to get. Screens read badly stretched
+ across a large monitor — line lengths go past what anyone scans and a
+ two-column layout drifts apart. Applied in global.css to the framed
+ surface in the workbench, and to the page content in full screen, where
+ the chrome goes edge to edge as it does in the real products.
+
+ A prototype that genuinely needs a wider or narrower page can override
+ this on its own wrapper rather than changing it here. */
+ --surface-measure: 1100px;
}
/* codeclub.org — raspberry accent on the pale green page the dashboard