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. */} +
+ {onCreateStep ? ( + <> + { + setTyped(event.target.value) + setNameError(undefined) + }} + /> + +

+ What kind of project do you want to make for your students? +

+ +
+ {PROJECT_TYPES.map((option) => ( + setType(option.id)} + /> + ))} +
+ + ) : ( + <> +

+ What kind of project do you want to add to {className}? +

+ + {nudge && ( +

+ Pick one of these to carry on. +

+ )} + +
+ {ROUTES.map((option) => ( + { + setChoice(option.id) + setNudge(false) + }} + /> + ))} +
+ + )} +
+
+
+ ) +} 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. */} + +
+ )} + +
+
+
+ ) +} + +/** The success card. Experience CS keeps the same heading and swaps the body. */ +export function AddedToClass({ + projectTitle, + className, + isNewClass, + onViewClass, + onFindAnother, +}: { + projectTitle: string + className: string + isNewClass: boolean + onViewClass: () => void + onFindAnother: () => void +}) { + return ( + +

Add {projectTitle} to class

+ + {/* 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}. +

+
+ + + { + setText(event.target.value) + setError(undefined) + }} + /> + + +
+
+
+ ) +} 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) => ( +
  • + + {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. +

+
+ ) +} + +/** "Name + description". */ +export function NameAndDescription({ + projectTitle, + onCreate, + onBack, +}: { + projectTitle: string + onCreate: (name: string, description: string) => void + onBack: () => void +}) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [error, setError] = useState() + + return ( +
+
+
+ +
+

Name your class

+

{projectTitle} will be added to it.

+
+ + + { + setName(event.target.value) + setError(undefined) + }} + /> + + + + setDescription(event.target.value)} + /> + + +
+
+
+ ) +} 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. +

+
+ + Difficulty level + + {levels.map((level) => ( + toggleLevel(level, event.target.checked)} + /> + ))} +
+
+
+ +
+
+

+ {visible.length} {visible.length === 1 ? 'result' : 'results'} +

+ {/* 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. +

+ )} +
+ + {visible.length === 0 ? ( + +

No projects match that filter

+
+ ) : ( +
+ {visible.map((project) => { + const added = alreadyAddedIds.includes(project.id) + return ( + +
+ {/* 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. */} + + +

+ {project.intro.split('\n')[0].replace(/\*\*/g, '')} +

+ + {added && ( +
+ +
+ )} +
+
+ ) + })} +
+ )} +
+
+
+ ) +} 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. + +
+
+ ) + } + + if (step === 'class') { + return ( + +
+ 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. + +
+
+ ) + } + + if (step === 'create-code' && activeProject) { + return ( + +
+ {}} + onSave={() => {}} + onUpload={() => {}} + onDownload={() => {}} + onBack={() => setStep('create-project-page')} + /> +
+
+ + {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.'} + +
+
+ ) + } + + if (step === 'create-instructions' && activeProject) { + return ( + +
+ { + 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. + +
+
+ ) + } + + if (step === 'project-landing' && viewing) { + return ( + +
+ 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. + +
+
+ ) + } + + if (step === 'new-class-how') { + return ( + +
+ 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. +
+
+
+ ) + } + + if (step === 'new-class-name' && viewing) { + return ( + +
+ { + 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. + +
+
+ ) + } + + if (step === 'success' && activeProject) { + return ( + +
+ 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.'} + +
+
+ ) + } + + if (step === 'show-to-students' && activeProject) { + return ( + +
+ 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.

+
+
+
+ ) +} 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. */} + +
+ )} + +
+
+
+ ) +} + +/** The success card. Experience CS keeps the same heading and swaps the body. */ +export function AddedToClass({ + projectTitle, + className, + isNewClass, + onViewClass, + onFindAnother, +}: { + projectTitle: string + className: string + isNewClass: boolean + onViewClass: () => void + onFindAnother: () => void +}) { + return ( + +

Add {projectTitle} to class

+ + {/* 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. +

+
+ ) +} + +/** "Name + description". */ +export function NameAndDescription({ + projectTitle, + onCreate, + onBack, +}: { + projectTitle: string + onCreate: (name: string, description: string) => void + onBack: () => void +}) { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [error, setError] = useState() + + return ( +
+
+
+ +
+

Name your class

+

{projectTitle} will be added to it.

+
+ + + { + setName(event.target.value) + setError(undefined) + }} + /> + + + + setDescription(event.target.value)} + /> + + +
+
+
+ ) +} 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 })} + /> + +
+ + Difficulty level + + {levels.map((level) => ( + + set({ + levels: toggle(filters.levels, level, event.target.checked), + }) + } + /> + ))} +
+ +
+ + Interest + + {interests.map((interest) => ( + + set({ + interests: toggle(filters.interests, interest, event.target.checked), + }) + } + /> + ))} +
+ + {/* Where the chart's "Scratch" box actually lives. */} +
+ + Technology + + {technologies.map((tech) => ( + + set({ + technologies: toggle(filters.technologies, tech, event.target.checked), + }) + } + /> + ))} +
+
+
+ +
+

+ Showing {visible.length} {visible.length === 1 ? 'project' : 'projects'} +

+ + {visible.length === 0 ? ( + +

No projects match those filters

+
+ ) : ( +
+ {visible.map((project) => { + const interest = interestOf(project) + return ( + +
+ {/* 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. */} + + +

+ {project.intro.split('\n')[0].replace(/\*\*/g, '')} +

+ + {/* 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. +
+
+
+ ) + } + + if (step === 'project-landing' && viewing) { + return ( + +
+ {/* 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. +
+
+
+ ) + } + + if (step === 'sign-in' && viewing) { + return ( + +
+ { + 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. + +
+
+ ) + } + + if (step === 'new-class-how') { + return ( + +
+ 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. +
+
+
+ ) + } + + if (step === 'new-class-name' && viewing) { + return ( + +
+ { + 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, and the mentor is two products away from the screen where + they would add anyone. + +
+
+ ) + } + + if (step === 'success' && activeProject) { + return ( + +
+ 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. +
+
+
+ ) + } + + if (step === 'project-page' && activeProject) { + return ( + +
+ {}} + 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.'} + +
+
+ ) + } + + if (step === 'show-to-students' && activeProject) { + return ( + +
+ 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.

+
+
+
+ ) +} 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}

-
@@ -57,21 +93,49 @@ export function EducatorClassPage({ Projects are shared with students and contain starter code created by a teacher.

- - - - ))} +
    + {projects.map((project) => { + const hidden = hiddenProjectIds.includes(project.id) + return ( +
  • + + {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 + + )} + + +
  • + ) + })}
)} 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 ? ( + ) : ( - {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