diff --git a/.claude/launch.json b/.claude/launch.json index 1fb40d3..9890a0d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,8 +4,12 @@ { "name": "c4-user-flows-prototype", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 5173 + "runtimeArgs": [ + "run", + "dev" + ], + "port": 5173, + "autoPort": true } ] -} +} \ No newline at end of file diff --git a/DESIGNS.md b/DESIGNS.md index e680015..4d8e84b 100644 --- a/DESIGNS.md +++ b/DESIGNS.md @@ -13,6 +13,8 @@ You need a Figma seat with Dev Mode access to open the Figma links. | Code Club Projects | [Live blocks editor](https://projects.raspberrypi.org/en/projects/editor-neil-the-seal/editor) | `ProjectEditor` | | Code Classroom | [Young people creating their own projects (FigJam)](https://www.figma.com/board/EuQrBiiwuk5wTc5NJ0ip7q/Young-people-students-can-create-their-own-projects-in-Code-Classroom?node-id=67-49) | `EducatorClassPage`, `EducatorProjectPage`, `YoungPersonSchoolHome`, `YoungPersonClassPage`, `ClassroomProjectEditor` | | Code Classroom | [Code Editor — Designs, student user flow](https://www.figma.com/design/SrINjD1MCT6VWYf1Yc9fV1/Code-Editor---Designs?node-id=19655-360604) | Background on the Google SSO join route | +| Code Classroom | [Code Editor — Designs, "Individual class"](https://www.figma.com/design/SrINjD1MCT6VWYf1Yc9fV1/Code-Editor---Designs?node-id=7355-37981) | `EducatorClassPage`, and the Code Classroom page furniture — title band, breadcrumb, footer | +| Code Classroom | [Code Editor — Designs, "Dashboard Owner"](https://www.figma.com/design/SrINjD1MCT6VWYf1Yc9fV1/Code-Editor---Designs?node-id=6759-78797) | `EducatorSchoolHome` | | Pi Accounts | Live: classroom.raspberrypi.org/login | `RoleChooser`, `SchoolCodeEntry`, `StudentSignIn` | ## Notes for anyone pulling from these @@ -25,5 +27,22 @@ You need a Figma seat with Dev Mode access to open the Figma links. rather than trying to extract text. - **The blocks editor is in a shadow DOM**, so page-text extraction will not reach it. Screenshot it. +- **The real editor cannot load Scratch from here, and the blocker is not in + this repo.** `editor-ui` embeds fine as a web component and Python runs + locally through Pyodide, but Scratch loads in an iframe keyed to a project + the editor API holds, and `editor-api.raspberrypi.org` answers with + `Access-Control-Allow-Origin: https://projects.raspberrypi.org`. So the + project fetch is refused from localhost and from wherever this deploys. It + would need the editor team to allow this prototype's origin. Until then the + blocks editor is `EditorCanvas`, drawn from a screenshot of the Neil the Seal + starter. - **`MentorSignIn` is not verified** — it is behind a login and was built from the pattern. Correct it if you can see the real thing. +- **The Code Classroom pages are two bands.** A white block carrying the + breadcrumb, title, description and page actions, then the content on + `#fbf9f6` below. Both values come from the Code Editor file + (`editor-color-layer-accent`, `editor-color-theme`) and replace what were + guesses in `surfaces/tokens.css`. +- **The topbar logo is still the words "Raspberry Pi Foundation".** The designs + use the colour wordmark. Nothing in this repo ships an image asset yet, so + this is the one piece of that chrome not reproduced. diff --git a/src/ScreenGallery.tsx b/src/ScreenGallery.tsx index 2a1cb61..98057b6 100644 --- a/src/ScreenGallery.tsx +++ b/src/ScreenGallery.tsx @@ -6,6 +6,7 @@ import { ManageClub, MentorDashboard, EducatorClassPage, + EducatorSchoolHome, EducatorProjectPage, MentorSignIn, ProjectEditor, @@ -20,6 +21,7 @@ import { manageClubMeta, mentorDashboardMeta, educatorClassPageMeta, + educatorSchoolHomeMeta, educatorProjectPageMeta, mentorSignInMeta, projectEditorMeta, @@ -209,6 +211,7 @@ export function ScreenGallery() { classGroup={scratchGroup} projects={classProjects} memberCount={scratchGroup.studentIds.length} + description="Our Thursday after-school Code Club. We work through Scratch projects at our own pace — come along whenever you can." onAddProject={noop} onOpenProject={noop} onCopyLink={noop} @@ -232,6 +235,18 @@ export function ScreenGallery() { /> + + + + = [ + ['Motion', MOTION], + ['Looks', LOOKS], + ['Sound', '#CF63CF'], + ['Events', '#FFBF00'], + ['Control', '#FFAB19'], + ['Sensing', '#5CB1D6'], + ['Operators', '#40BF4A'], + ['Variables', '#FF8C1A'], + ['My Blocks', '#FF6680'], +] + +export function EditorCanvas({ language, label }: Props) { + return ( + + {language === 'Scratch' ? : } + + ) +} + +function BlocksEditor() { + /* The Motion palette, in the order the real editor lists it. Real labels + rather than grey bars: at a glance the difference between "a picture of an + editor" and "a picture of THE editor" is mostly the words. */ + const palette = [ + 'move 10 steps', + 'turn ↻ 15 degrees', + 'turn ↺ 15 degrees', + 'go to random position', + 'go to x: 55 y: -50', + 'glide 1 secs to x: 55', + 'point in direction 90', + 'point towards mouse-pointer', + 'change x by 10', + 'set x to 55', + 'change y by 10', + ] + + /* The stack the instructions panel beside this is asking for. */ + const stack: Array<[string, string]> = [ + ['go to x: 0 y: -40', MOTION], + ['point in direction 90', MOTION], + ['show', LOOKS], + ] + + return ( + <> + + + {/* ---- Code / Costumes / Sounds. */} + + + + Code + + + Costumes + + + Sounds + + + {/* ---- Category rail. */} + + {CATEGORIES.map(([name, colour], i) => ( + + + + {name} + + + ))} + + {/* ---- Palette. */} + + + + Motion + + {palette.map((label, i) => ( + + + + {label} + + + ))} + + {/* ---- Workspace. The widest part of the real editor, and the part that + takes the extra room when the window is wide. */} + + + + + when ⚑ clicked + + {stack.map(([label, colour], i) => ( + + + + {label} + + + ))} + + {/* Zoom controls, bottom right of the workspace as in the real editor. */} + {[0, 1, 2].map((i) => ( + + ))} + + {/* ---- Stage and sprites, moved to the right of the wider workspace. + Translated rather than recoordinated, so the panel stays one + readable block. */} + + {/* ---- Stage and sprites. */} + + + {/* Green flag, stop, and the layout toggles. */} + + + + + + {/* The stage: a road scene under a title card, which is what a Code Club + Scratch starter almost always looks like. */} + + + + + + + + + + + + {/* Selected sprite's x and y, which the real panel shows under the stage. */} + + + Neil + + + x 55 + + + y -50 + + + {/* Sprite tray. The first is selected, so it carries the ring. Named and + drawn rather than left as empty tiles — a Scratch starter always + arrives with its cast already in place, and blank squares read as a + project nobody has set up. */} + + {SPRITES.map(({ name, colour, round }, i) => { + const x = 236 + (i % 3) * 33 + const y = 148 + Math.floor(i / 3) * 34 + return ( + + + {round ? ( + + ) : ( + + )} + + {name} + + + ) + })} + {/* Add sprite / add backdrop. */} + + + + + ) +} + +function TextEditor() { + /* Indentation and token widths are fixed, not random: a picture of code + should look like code, and real code has structure. */ + const lines = [ + [0, [34, 58]], + [0, [24, 44, 26]], + [0, []], + [0, [38, 62]], + [1, [28, 48, 24]], + [1, [44, 32]], + [0, []], + [0, [26, 42]], + [1, [36, 54, 28]], + [2, [44, 26]], + [2, [30, 50]], + [1, [38, 24]], + [0, []], + [0, [28, 46, 22]], + ] as const + + return ( + <> + + + {/* Tab bar, so it reads as an editor rather than a terminal. */} + + + + + {/* Gutter and code. */} + + {lines.map((_, i) => ( + + ))} + {lines.map(([indent, tokens], row) => { + let x = 28 + indent * 13 + return ( + + {tokens.map((width, i) => { + const rect = ( + + ) + x += width + 7 + return rect + })} + + ) + })} + + + {/* Output beside the code, which is where it sits at this width. */} + + + + {[96, 132, 72, 112, 84].map((width, i) => ( + + ))} + + ) +} diff --git a/src/kit/EditorEmbed.tsx b/src/kit/EditorEmbed.tsx new file mode 100644 index 0000000..043259d --- /dev/null +++ b/src/kit/EditorEmbed.tsx @@ -0,0 +1,155 @@ +import { useEffect, useRef, useState } from 'react' +import { EditorCanvas } from './EditorCanvas' + +/** + * The real Raspberry Pi Code Editor, embedded. + * + * `editor-ui` ships as a web component on a CDN rather than an npm package, so + * this is a script tag and a custom element — no dependency added, which is + * what lets us use the actual editor at all. + * + * https://github.com/RaspberryPiFoundation/editor-ui + * + * It runs Python locally through Pyodide, so `preview` mode with no `auth_key` + * gives a genuinely working editor that talks to no API, saves nothing and + * cannot touch a real account. That matters here: the repo's rule against real + * API calls is about not moving real data, and this moves none. + * + * WHAT IT DOES NOT DO: Scratch. The bundle loads Scratch in an iframe keyed to + * a project the editor API already holds (`project_type: code_editor_scratch`) + * — there is no way to hand it a Scratch project inline the way `code` hands + * it Python. Our projects are invented, so there is no identifier to pass. + * Scratch projects therefore fall back to the drawn still, as does any project + * if the CDN is unreachable. + */ + +/* Pinned, not `latest` — `releases/latest` 404s, and a prototype that silently + changes editor version between one review and the next is a bad prototype. + Bump deliberately. */ +const VERSION = 'v0.38.1' +const SCRIPT_URL = `https://editor-static.raspberrypi.org/releases/${VERSION}/web-component.js` + +/* The bundle is ~10MB. Load it once per session, and only when a screen + actually shows an editor — not on the landing page. */ +let loading: Promise | undefined + +function loadEditorScript() { + if (!loading) { + loading = new Promise((resolve, reject) => { + if (customElements.get('editor-wc')) { + resolve() + return + } + const script = document.createElement('script') + script.src = SCRIPT_URL + script.async = true + script.onload = () => resolve() + script.onerror = () => reject(new Error(`Could not load the editor from ${SCRIPT_URL}`)) + document.head.appendChild(script) + }) + } + return loading +} + +interface Props { + language: 'Scratch' | 'Python' | 'HTML' + /** Starter code. Overrides the editor's own empty main.py / index.html. */ + code: string + /** Described to screen readers, and used on the drawn fallback. */ + label: string + /** + * Instruction steps, if the editor should show its own instructions panel. + * Most prototypes here render steps themselves in the left rail, so this is + * usually left off — pass it only if the flow is about the editor's sidebar. + */ + steps?: { title: string; body: string }[] + /** The editor asks the host page to handle sign-in; it does not do it itself. */ + onLogIn?: () => void + onSignUp?: () => void +} + +export function EditorEmbed({ language, code, label, steps, onLogIn, onSignUp }: Props) { + const host = useRef(null) + const [failed, setFailed] = useState(false) + const [ready, setReady] = useState(false) + + /* Scratch can never load inline, so do not even fetch 10MB to find out. */ + const embeddable = language !== 'Scratch' + + /* Serialised here rather than in the effect, so the effect depends on a + string. `steps` is a fresh array on every render; depending on it directly + would tear down and rebuild a 10MB editor on every parent render. */ + const instructions = steps + ? JSON.stringify({ + project: { steps: steps.map((step) => ({ content: `

${step.title}

${step.body}` })) }, + }) + : undefined + + useEffect(() => { + if (!embeddable) return + let cancelled = false + const mount = host.current + + loadEditorScript().then( + () => { + if (cancelled || !mount) return + + /* Built imperatively rather than as JSX. React sets custom element + attributes inconsistently across versions, and every attribute here + has to arrive as a string — getting that wrong silently gives you an + empty editor rather than an error. + + It goes into a node React deliberately leaves empty. Appending into + a node React also renders into gets you a removeChild crash the + moment React tries to tidy up a child we already replaced. */ + const editor = document.createElement('editor-wc') + editor.setAttribute('preview', 'true') + editor.setAttribute('code', code) + editor.setAttribute('aria-label', label) + if (instructions) { + editor.setAttribute('with_sidebar', 'true') + editor.setAttribute('sidebar_options', JSON.stringify(['instructions'])) + editor.setAttribute('instructions', instructions) + } + + mount.replaceChildren(editor) + setReady(true) + }, + () => { + if (!cancelled) setFailed(true) + }, + ) + + return () => { + cancelled = true + mount?.replaceChildren() + } + }, [embeddable, code, label, instructions]) + + /* The editor asks the page to handle sign-in rather than doing it itself, + which is the whole point for the import lane: the prototype decides what + happens, so a variant can send someone somewhere different. */ + useEffect(() => { + if (!onLogIn && !onSignUp) return + const logIn = () => onLogIn?.() + const signUp = () => onSignUp?.() + document.addEventListener('editor-logIn', logIn) + document.addEventListener('editor-signUp', signUp) + return () => { + document.removeEventListener('editor-logIn', logIn) + document.removeEventListener('editor-signUp', signUp) + } + }, [onLogIn, onSignUp]) + + if (!embeddable || failed) { + return + } + + return ( +
+ {!ready &&

Loading the editor…

} + {/* React never renders into this one — the editor is mounted here. */} +
+
+ ) +} diff --git a/src/kit/ProjectThumbnail.tsx b/src/kit/ProjectThumbnail.tsx new file mode 100644 index 0000000..1e37874 --- /dev/null +++ b/src/kit/ProjectThumbnail.tsx @@ -0,0 +1,186 @@ +/** + * Illustrative artwork for a project, drawn in SVG. + * + * The live Projects site gives every project a bespoke illustration. We cannot + * ship those — our projects are invented, so real artwork would attach a real + * project's picture to a made-up title — and a grey box reads as unfinished in + * a stakeholder review. So each project gets generated artwork instead: + * language-themed, deterministic from its id, and obviously drawn rather than + * photographic, which keeps it honest about being a stand-in. + * + * Deterministic matters. The same project must look the same everywhere it + * appears, or a catalogue and a class page will disagree about which project + * is which. + */ + +/* Illustration colours, deliberately NOT --rpf-* tokens. These are Scratch's + and Python's own brand colours plus scene colours for the artwork; they are + not UI colours and there are no design tokens for them. Same reasoning as + the literal hues in surfaces/tokens.css. */ +const PALETTES = { + Scratch: { + sky: '#E8F4FF', + ground: '#9BD35A', + ink: '#4C97FF', + accents: ['#4C97FF', '#9966FF', '#CF63CF', '#FFAB19'], + }, + Python: { + sky: '#1B2A57', + ground: '#2B4171', + ink: '#FFD43B', + accents: ['#4B8BBE', '#FFD43B', '#646464', '#306998'], + }, + HTML: { + sky: '#FFF1E8', + ground: '#F2734C', + ink: '#E44D26', + accents: ['#E44D26', '#F06529', '#EBEBEB', '#264DE4'], + }, +} as const + +export type ThumbnailLanguage = keyof typeof PALETTES + +/** Small stable hash, so one project always gets the same arrangement. */ +function seedOf(key: string) { + let hash = 0 + for (let i = 0; i < key.length; i += 1) { + hash = (hash * 31 + key.charCodeAt(i)) | 0 + } + return Math.abs(hash) +} + +interface Props { + /** Project id — the seed. Two projects with the same id look identical. */ + seed: string + language: ThumbnailLanguage + /** CSS height. A number is pixels. Defaults to filling its container. */ + height?: number | string + /** Decorative by default. Pass a title if the image carries meaning alone. */ + label?: string +} + +export function ProjectThumbnail({ seed, language, height = '100%', label }: Props) { + const palette = PALETTES[language] ?? PALETTES.Scratch + const n = seedOf(seed) + + /* Three motifs, picked by seed, so a catalogue of six projects does not look + like the same picture six times. */ + const motif = n % 3 + + return ( + + + + {/* Rolling ground, offset by seed so the horizon is not always level. */} + + + {/* Soft shapes in the background, placed by seed. */} + {[0, 1, 2].map((i) => ( + > (i * 3)) % 130)} + cy={14 + ((n >> (i * 2)) % 34)} + r={5 + ((n >> i) % 9)} + fill={palette.accents[(n + i) % palette.accents.length]} + opacity="0.35" + /> + ))} + + {motif === 0 && } + {motif === 1 && } + {motif === 2 && } + + ) +} + +type Palette = (typeof PALETTES)[ThumbnailLanguage] + +/** Three snapped-together code blocks, notches and all. */ +function StackedBlocks({ palette, seed }: { palette: Palette; seed: number }) { + return ( + + {[0, 1, 2].map((i) => ( + > i) % 14} + a3 3 0 0 1 3 3 v9 a3 3 0 0 1 -3 3 h-${46 - (seed >> i) % 14} + a3 3 0 0 1 -6 0 h-12 a3 3 0 0 1 -3 -3 v-9 a3 3 0 0 1 3 -3 Z`} + fill={palette.accents[(seed + i) % palette.accents.length]} + /> + ))} + + ) +} + +/** A little app window — stands in for a running project. */ +function Window({ palette, seed }: { palette: Palette; seed: number }) { + return ( + + + + + {[0, 1, 2].map((i) => ( + + ))} + {[0, 1, 2, 3].map((i) => ( + > i) % 44)} + height="5" + rx="2.5" + fill={palette.accents[(seed + i) % palette.accents.length]} + opacity="0.75" + /> + ))} + + ) +} + +/** A character on the stage — the Scratch mental model, roughly. */ +function Sprite({ palette, seed }: { palette: Palette; seed: number }) { + const body = palette.accents[seed % palette.accents.length] + return ( + + {/* Speech bubble — projects are nearly always something talking. */} + + {[0, 1].map((i) => ( + > i) % 22)} + height="4" + rx="2" + fill={palette.ink} + opacity="0.6" + /> + ))} + {/* The character. */} + + + + + + + + ) +} diff --git a/src/kit/index.ts b/src/kit/index.ts index df9b89c..a03e9a0 100644 --- a/src/kit/index.ts +++ b/src/kit/index.ts @@ -18,4 +18,7 @@ export * from '@raspberrypifoundation/design-system-react' // Ours, because the design system has no equivalent. export { Card } from './Card' +export { EditorCanvas } from './EditorCanvas' +export { EditorEmbed } from './EditorEmbed' export { Placeholder } from './Placeholder' +export { ProjectThumbnail } from './ProjectThumbnail' diff --git a/src/screens/README.md b/src/screens/README.md index 758c023..b348b9b 100644 --- a/src/screens/README.md +++ b/src/screens/README.md @@ -32,6 +32,7 @@ chrome. | `SchoolCodeEntry` | Pi Accounts, badged Code Classroom | Yes | Yes | | `StudentSignIn` | Pi Accounts, badged Code Classroom | Yes | Yes | | `MentorSignIn` | Pi Accounts | Yes | **No** — built from the pattern | +| `EducatorSchoolHome` | Code Classroom | Yes | Yes | | `EducatorClassPage` | Code Classroom | Yes | Yes | | `EducatorProjectPage` | Code Classroom | Yes | Yes | | `YoungPersonSchoolHome` | Code Classroom | Yes | Yes | @@ -51,6 +52,18 @@ announced. The screens do not also mark the individual field — if a variant wants that, pass a short message to the input's own `error` prop as well as showing the alert. +## Code Classroom pages are built as two bands + +A white block carrying the breadcrumb, the title, the description and the page +actions, then the content below it on a warm off-white. `EducatorSchoolHome` +and `EducatorClassPage` are built from the Code Editor Figma file and render +`.cc-page-header` and `.cc-page-main` themselves rather than relying on the +surface to pad them. + +If you write another Code Classroom screen, follow the same two blocks — a page +that puts everything on one background will sit next to these in a flow and +look like a different product. + ## Code Classroom has no navigation You move around Code Classroom entirely by breadcrumb — "Your school / diff --git a/src/screens/ccp/ProjectEditor.tsx b/src/screens/ccp/ProjectEditor.tsx index f5eedf0..a37d538 100644 --- a/src/screens/ccp/ProjectEditor.tsx +++ b/src/screens/ccp/ProjectEditor.tsx @@ -1,4 +1,4 @@ -import { Button, Placeholder, Tag } from '../../kit' +import { Button, EditorEmbed, Placeholder, Tag } from '../../kit' import type { Project } from '../../fixtures' import type { ScreenMeta } from '../types' import { Markdown } from './Markdown' @@ -150,9 +150,12 @@ export function ProjectEditor({
-
diff --git a/src/screens/ccp/ProjectPage.tsx b/src/screens/ccp/ProjectPage.tsx index c5f3529..5e317e7 100644 --- a/src/screens/ccp/ProjectPage.tsx +++ b/src/screens/ccp/ProjectPage.tsx @@ -1,4 +1,4 @@ -import { Button, Placeholder } from '../../kit' +import { Button, ProjectThumbnail } from '../../kit' import type { Project } from '../../fixtures' import type { ScreenMeta } from '../types' import { Markdown } from './Markdown' @@ -47,7 +47,7 @@ export function ProjectPage({ project, onStart, onImport }: Props) {
- +
@@ -69,7 +69,7 @@ export function ProjectPage({ project, onStart, onImport }: Props) {
- +
)} diff --git a/src/screens/classroom/ClassroomProjectEditor.tsx b/src/screens/classroom/ClassroomProjectEditor.tsx index c531ed2..3b6203a 100644 --- a/src/screens/classroom/ClassroomProjectEditor.tsx +++ b/src/screens/classroom/ClassroomProjectEditor.tsx @@ -1,4 +1,4 @@ -import { Button, CheckboxInput, Placeholder, Tag } from '../../kit' +import { Button, CheckboxInput, EditorEmbed, Tag } from '../../kit' import type { Project } from '../../fixtures' import { Markdown } from '../ccp/Markdown' import type { ScreenMeta } from '../types' @@ -111,9 +111,10 @@ export function ClassroomProjectEditor({
-
diff --git a/src/screens/classroom/EducatorClassPage.tsx b/src/screens/classroom/EducatorClassPage.tsx index 0290b74..4407dc3 100644 --- a/src/screens/classroom/EducatorClassPage.tsx +++ b/src/screens/classroom/EducatorClassPage.tsx @@ -13,6 +13,12 @@ interface Props { classGroup: ClassGroup projects: Project[] memberCount: number + /** + * The blurb under the class title. Optional because a class need not have + * one — the designs show the space, and an empty class simply has no text + * there rather than a placeholder. + */ + description?: string onAddProject: () => void onOpenProject: (projectId: string) => void onCopyLink: () => void @@ -54,6 +60,7 @@ export function EducatorClassPage({ classGroup, projects, memberCount, + description, onAddProject, onOpenProject, onCopyLink, @@ -63,82 +70,89 @@ export function EducatorClassPage({ onSettings, }: Props) { return ( -
-

{classGroup.name}

- -
- {/* Primary on the live page. Getting young people into the class is - the job a mentor comes here to do. */} -
+ <> +
+
+

{classGroup.name}

+ {description &&

{description}

} +
- -
-
-

Projects

-

- Projects are shared with students and contain starter code created by a teacher. -

-
+
+ {/* Primary on the live page. Getting young people into the class is + the job a mentor comes here to do. */}
+
- {projects.length === 0 ? ( -

No projects yet

- ) : ( -
    - {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 - - )} - - -
  • - ) - })} -
- )} -
-
+
+ +
+
+

Projects

+

+ Projects are shared with students and contain starter code created by a teacher. +

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

No projects yet

+ ) : ( +
    + {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/screens/classroom/EducatorSchoolHome.tsx b/src/screens/classroom/EducatorSchoolHome.tsx new file mode 100644 index 0000000..d96c2b0 --- /dev/null +++ b/src/screens/classroom/EducatorSchoolHome.tsx @@ -0,0 +1,169 @@ +import { Button, Card } from '../../kit' +import type { ClassGroup, School } from '../../fixtures' +import type { ScreenMeta } from '../types' + +export const meta: ScreenMeta = { + surface: 'classroom', + existsToday: true, + verified: true, + note: 'A mentor’s home in Code Classroom: the school, its login code, and the classes under it. The top of the tree — every mentor flow starts here.', +} + +interface Props { + school: School + classes: ClassGroup[] + /** The share link a mentor can hand out instead of the code. */ + joinLink: string + onOpenClass: (classId: string) => void + onCreateClass: () => void + onManageMembers: () => void + onCopyLink: () => void + /** The gear beside "Manage members". Renders whether or not you wire it. */ + onSettings?: () => void + /** The per-row overflow menu. Renders whether or not you wire it. */ + onClassMenu?: (classId: string) => void + /** The back control above the title. Renders whether or not you wire it. */ + onHome?: () => void +} + +/** + * The school page a mentor lands on, built from the Code Editor Figma file + * (node 6759:78797, "Dashboard Owner"). + * + * This screen has existed in the product all along and was never in here — the + * import prototypes each grew their own guess at it, both built by mirroring + * the young person's version and both marked as probably wrong. This is the + * checked one. + * + * Two things worth noticing for Code Club: + * + * - **The login code is the most prominent thing on the page after the title.** + * A mentor reads it out across a room, and the page is built around that. + * The share link beside it is the other route in, and it is the one that + * skips the school-code screen entirely. + * - **"All the classes you manage and in your school"** — a mentor at a school + * that already uses Code Classroom sees classes they had nothing to do with, + * sitting beside their club. There is nothing on a row to tell one from the + * other. + */ +export function EducatorSchoolHome({ + school, + classes, + joinLink, + onOpenClass, + onCreateClass, + onManageMembers, + onCopyLink, + onSettings, + onClassMenu, + onHome, +}: Props) { + return ( + <> +
+ {/* A back control, not a breadcrumb: the school is the top of the tree, + so there is no trail to sit above it. */} +
+
+ + +
+
+
+ +

School login code

+

{school.schoolCode}

+

+ Student will need the school code to log in. Alternatively you can share the log in + link with them. +

+
+ {/* Readonly rather than plain text: it is a thing you copy, and + the live page lets you select it. */} + +
+
+
+ +
+ +
+
+

Classes

+

+ All the classes you manage and in your school. Classes are used to group + students and assign lessons to them. +

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

No classes yet

+ ) : ( +
    + {classes.map((group) => ( +
  • + + + +
  • + ))} +
+ )} +
+
+
+
+ + ) +} diff --git a/src/screens/classroom/YoungPersonClassPage.tsx b/src/screens/classroom/YoungPersonClassPage.tsx index 3d28e3e..7ad75e1 100644 --- a/src/screens/classroom/YoungPersonClassPage.tsx +++ b/src/screens/classroom/YoungPersonClassPage.tsx @@ -37,33 +37,48 @@ interface Props { * adult. There is no "start something of your own" here — which is exactly * the question the Code Classroom FigJam is asking, and exactly what a Code * Club session is often about. + * + * Page furniture inferred from the educator pages, as on + * `YoungPersonSchoolHome` — see the note there. */ export function YoungPersonClassPage({ classGroup, projects, onOpenProject }: Props) { return ( -
-

{classGroup.name}

+ <> +
+
+

{classGroup.name}

+
+
- -

Projects

-

- Projects are shared with students and contain starter code created by a teacher. -

+
+ +
+
+

Projects

+

+ Projects are shared with students and contain starter code created by a teacher. +

+
+
- {projects.length === 0 ? ( -

No projects yet

- ) : ( -
    - {projects.map(({ project, status }) => ( -
  • - - -
  • - ))} -
- )} -
-
+ {projects.length === 0 ? ( +

No projects yet

+ ) : ( +
    + {projects.map(({ project, status }) => ( +
  • + + + + +
  • + ))} +
+ )} +
+
+ ) } diff --git a/src/screens/classroom/YoungPersonSchoolHome.tsx b/src/screens/classroom/YoungPersonSchoolHome.tsx index 780d3ef..b47965e 100644 --- a/src/screens/classroom/YoungPersonSchoolHome.tsx +++ b/src/screens/classroom/YoungPersonSchoolHome.tsx @@ -1,4 +1,5 @@ -import { Card } from '../../kit' +import { useState } from 'react' +import { Card, SelectInput, Tag } from '../../kit' import type { ClassGroup, School } from '../../fixtures' import type { ScreenMeta } from '../types' @@ -9,42 +10,117 @@ export const meta: ScreenMeta = { note: 'Where a young person lands after signing in. The page is titled with the SCHOOL name, which for a community club is the club name.', } +/** Name order is the only sort the design shows. */ +type Sort = 'a-z' | 'z-a' + +const SORT_OPTIONS = [ + { key: 'a-z', value: 'Name (A — Z)' }, + { key: 'z-a', value: 'Name (Z — A)' }, +] + interface Props { school: School classes: ClassGroup[] onOpenClass: (classId: string) => void + /** + * Classes where the mentor has left work for the young person to look at. + * The real page marks these "New feedback", and it is the only thing on a + * row that distinguishes one class from another — so a flow about feedback + * getting back to a young person needs it, and one that is not can leave it + * out. + */ + classesWithFeedback?: string[] } /** * A young person's home in Code Classroom: the list of classes they are in. * - * Worth watching in testing: this is titled with the school name, and at - * St Aidan's a young person in both a lesson and the club sees both listed - * together with nothing distinguishing "school work" from "club". + * Built from the Code Editor Figma file (node 6775:88858, "Dashboard + * Student"), so this is the checked version of the page rather than the + * educator page with the actions taken off. + * + * Three things worth knowing: + * + * - **It is titled with the school name**, and at St Aidan's a young person in + * both a lesson and the club sees both listed together with nothing + * distinguishing "school work" from "club". + * - **There is no breadcrumb and no back control on it.** The student page is + * the top of their tree; a prototype that gives it a crumb trail is adding + * navigation the product does not have. + * - **"New feedback" is the only per-row signal.** No project counts, no + * dates, nothing about whether anything is waiting — one tag, or nothing. */ -export function YoungPersonSchoolHome({ school, classes, onOpenClass }: Props) { +export function YoungPersonSchoolHome({ + school, + classes, + onOpenClass, + classesWithFeedback = [], +}: Props) { + /* Display state, not flow state — the screen still decides nothing about + what comes next, it just orders a list it was handed. */ + const [sort, setSort] = useState('a-z') + + const ordered = [...classes].sort((a, b) => + sort === 'a-z' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name), + ) + return ( -
-

{school.name}

- - -

Your classes

-

Here are all the classes you have been added to.

- - {classes.length === 0 ? ( -

You are not in any classes yet

- ) : ( -
    - {classes.map((group) => ( -
  • - -
  • - ))} -
- )} -
-
+ <> +
+
+

{school.name}

+
+
+ +
+ +
+
+

Your classes

+ {/* The design's wording, kept as it is written there — including + the missing "to". Same rule as "teacher" and "student": the + product's words are the product's words. */} +

+ Here you can see an overview of what classes you have been added. +

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

You are not in any classes yet

+ ) : ( + <> +
+ setSort(event.target.value as Sort)} + /> +
+ +
    + {ordered.map((group) => ( +
  • + + {/* Bold text, not a link — the design gives the row no + other affordance, so the whole name is the control. */} + + + {classesWithFeedback.includes(group.id) && ( + + )} +
  • + ))} +
+ + )} +
+
+ ) } diff --git a/src/screens/index.ts b/src/screens/index.ts index dcd132b..0b96d3d 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -22,6 +22,10 @@ export { meta as educatorProjectPageMeta, type StudentWork, } from './classroom/EducatorProjectPage' +export { + EducatorSchoolHome, + meta as educatorSchoolHomeMeta, +} from './classroom/EducatorSchoolHome' export { YoungPersonSchoolHome, meta as youngPersonSchoolHomeMeta, diff --git a/src/styles/global.css b/src/styles/global.css index 075d918..bb8afcb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -546,6 +546,11 @@ hr.divider { flex: 1 0 auto; padding: var(--space-3); } +/* Code Classroom pages are built as two full-width bands — a white title block + over content on the page colour — so they pad their own bands instead. */ +.surface-classroom > .surface-body { + padding: 0; +} /* Pi Accounts and the Code Classroom sign-in are a coloured page with one centred card. */ .surface-body.centred { @@ -561,17 +566,40 @@ hr.divider { .surface-footer { display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-2); - justify-content: space-between; - padding: var(--space-1) var(--space-2); + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); background: var(--rpf-white); border-top: 1px solid var(--rpf-grey-150); font-size: var(--fs-075); line-height: var(--lh-1); color: var(--rpf-text-secondary); } +.surface-footer-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1) var(--space-2); + justify-content: space-between; +} +.surface-footer-concern { + display: flex; + align-items: center; + gap: var(--space-05); +} +/* The flag sits at body size beside 12px text, so it needs bringing down. */ +.surface-footer-concern .material-symbols-sharp { + font-size: var(--fs-1); +} +.surface-footer-links { + display: flex; + flex-wrap: wrap; + gap: var(--space-05) var(--space-2); +} +.surface-footer-links > span { + font-weight: var(--fw-bold); + text-decoration: underline; +} /* ---------------- Surface gallery ---------------- */ .surface-demo { @@ -704,15 +732,37 @@ hr.divider { display: flex; align-items: center; flex-wrap: wrap; - gap: var(--space-1); padding: var(--space-1) var(--space-2); background: var(--surface-nav); border-bottom: 1px solid var(--surface-nav-border); font-size: var(--fs-075); } +.surface-crumb { + display: flex; + align-items: center; +} +/* Bold and underlined, the weight the Code Editor designs give it. Code + Classroom has no nav bar, so the breadcrumb is the navigation. */ +.surface-breadcrumbs .link-button { + font-weight: var(--fw-bold); + text-decoration: underline; +} +.surface-crumb[aria-current='page'], +.surface-crumb > span[aria-current='page'] { + font-weight: var(--fw-bold); +} .surface-crumb-sep { - color: var(--rpf-grey-300); - margin-right: var(--space-1); + color: var(--rpf-grey-500); + font-size: var(--fs-1); +} + +/* The breadcrumb sits directly above the white title band on Code Classroom, + so the two read as one block rather than two stacked bars. */ +.surface-classroom .surface-breadcrumbs { + padding: var(--space-2) var(--space-3) 0; + background: var(--surface-header); + border-bottom: none; + font-size: var(--fs-1); } .surface-account { color: var(--rpf-text-secondary); @@ -725,12 +775,23 @@ hr.divider { gap: var(--space-1); } +/* Heading and blurb on the left, the section's one action hard right — the + Code Editor designs put them on the same row. The text block needs an + explicit basis of 0: left to size itself it takes the full width and pushes + the button onto its own line however much room there is. */ .cc-section-head { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; flex-wrap: wrap; - gap: var(--space-2); + gap: var(--space-3); +} +.cc-section-head > :first-child { + flex: 1 1 0; + min-width: 200px; +} +.cc-section-head > :last-child { + flex: 0 0 auto; } .cc-list { @@ -761,10 +822,30 @@ hr.divider { border-radius: var(--radius-sm); overflow: hidden; } +/* White rows, not pale ones: the Code Editor designs draw the group as a white + panel divided by hairlines. */ .cc-list-boxed .cc-row { - padding: var(--space-2); - background: var(--rpf-off-white); + padding: var(--space-1) var(--space-1) var(--space-1) var(--space-2); + background: var(--rpf-white); + min-height: 48px; +} +/* The student class list gives its rows more height than the educator one — + 60px against 48px. */ +.cc-list-boxed .cc-row-tall { + min-height: 60px; } + +/* A list's own controls — sorting, filtering — sit right-aligned above it. */ +.cc-list-controls { + display: flex; + justify-content: flex-end; +} +.cc-list-controls .rpf-input-field, +.cc-list-controls .rpf-select-input { + width: 240px; + max-width: 100%; +} + /* Icon and name travel together on the left; the overflow menu stays right. */ .cc-row-main { display: flex; @@ -810,18 +891,63 @@ hr.divider { } /* ---------------- Project editor (two pane) ---------------- */ +/* The editor IS the page, edge to edge and the full height of the window — + the real one has no margin and nothing else on screen beside it. + Selected with :has() rather than a layout prop so it applies wherever an + editor is rendered, including in prototypes this rule cannot reach into. */ +.surface:has(.editor) { + max-width: none; + height: 100vh; + /* Breaks out of whatever column it was placed in — the workbench card, the + gallery list — so the editor is the size of the window there too, not just + in full screen. Without this it inherits an 800px card and the editor gets + a portrait sliver to draw in. */ + width: 100vw; + margin-inline: calc(50% - 50vw); + border-radius: 0; + border-inline: none; +} +.surface:has(.editor) > .surface-body { + display: flex; + padding: 0; + /* flex-basis 0 with min-height 0, or the body refuses to shrink below its + content and the footer is pushed off the bottom. */ + flex: 1 1 0; + min-height: 0; +} +/* Every wrapper between the body and the editor has to stretch too, however + many there are. `:has(.editor)` matches exactly those ancestors and nothing + else. A flex item with no grow shrinks to its content, so a single + unstretched wrapper is enough to leave the editor a fraction of the window — + and there can be more than one: a screen wraps its editor in a + `.section-stack`, and a prototype wraps the screen in another. */ +.surface:has(.editor) > .surface-body :has(.editor) { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} +/* Full screen normally measures the content column; the editor is the one page + that must not be, or it sits in a 1100px strip on a wide monitor. */ +body.full-screen-prototype .surface:has(.editor) > .surface-body { + max-width: none; + margin-inline: 0; +} + .editor { display: grid; /* Stacked on small screens; rail, instructions and editor side by side once there is room, which is how the real editor lays out. */ grid-template-columns: 1fr; gap: var(--space-2); - /* 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); + flex: 1 1 auto; + min-width: 0; + /* A floor for short windows, and nothing more. `100%` here resolved against + the wrapper and then grew past it, pushing the editor taller than the + window so the palette and stage were clipped instead of fitted. Height + comes from the flex parent now. */ + min-height: 420px; } @media (min-width: 860px) { .editor { @@ -911,6 +1037,10 @@ hr.divider { background: var(--rpf-white); border: 1px solid var(--rpf-grey-150); border-radius: var(--radius-sm); + /* Or the grid row grows to fit the canvas instead of the canvas fitting the + row, and the editor spills past the bottom of the window. */ + min-height: 0; + overflow: hidden; } @media (min-width: 860px) { .editor-main { @@ -937,12 +1067,63 @@ hr.divider { } .editor-canvas { flex: 1 1 auto; - padding: var(--space-2); + padding: 0; + /* The drawing is fitted rather than cropped, so this shows through around + it; matching its own base keeps the margin from reading as a gap. */ + background: #f9f9f9; /* 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; } +/* The real editor, when it loads. It brings its own chrome inside a shadow + DOM, so there is nothing to style here beyond giving it the whole pane. */ +.editor-embed { + flex: 1 1 auto; + display: flex; + min-width: 0; + min-height: 0; +} +.editor-embed-host { + flex: 1 1 auto; + display: flex; + min-width: 0; + min-height: 0; +} +.editor-embed editor-wc { + flex: 1 1 auto; + min-width: 0; +} +.editor-embed-loading { + margin: auto; +} + +/* The drawn editor fills its pane and crops rather than letterboxing — a + still of an editor with bars down the side reads as a broken image. */ +/* Taken out of flow, so the drawing fills the pane instead of deciding how + tall it is. An SVG in normal flow reports a height from its own aspect + ratio whenever its parent has no definite one — and `height: 100%` against + an indefinite parent resolves to exactly that — which pushed the pane past + the bottom of the window and clipped the palette and stage. */ +.editor-canvas { + position: relative; +} +.editor-canvas-art { + position: absolute; + top: var(--space-2); + left: var(--space-2); + /* Explicit, not `inset` with `auto` sizes: an SVG has an intrinsic aspect + ratio, so it takes its height from its width and ignores the bottom inset. */ + width: calc(100% - 2 * var(--space-2)); + height: calc(100% - 2 * var(--space-2)); +} + +/* Generated project artwork. Cropped to its box, like a real thumbnail. */ +.project-thumb { + display: block; + border-radius: var(--radius-sm); + object-fit: cover; +} /* Project instructions markdown */ .md { @@ -1331,3 +1512,108 @@ body.full-screen-prototype .surface-footer { color: var(--rpf-grey-500); } +/* ---------------- Code Classroom page header ---------------- */ +/* The white band carrying the title, and the content band below it. */ +.cc-page-header { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-2) var(--space-3) var(--space-4); + background: var(--surface-header); + border-bottom: 1px solid var(--surface-nav-border); +} +.cc-page-main { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-4) var(--space-3); +} + +/* The title band's contents: breadcrumb, then title and description, then the + page actions. Taken from the Code Editor designs — 40px/56 title, 20px + description held to a readable measure, actions on their own row. */ +.cc-page-title { + display: flex; + flex-direction: column; + gap: var(--space-1); +} +.cc-page-title h1 { + font-size: var(--fs-4); + line-height: var(--lh-35); + margin: 0; +} +.cc-page-desc { + margin: 0; + max-width: 800px; + font-size: var(--fs-15); + line-height: var(--lh-15); +} +/* Title on the left, actions hard right on the same row — the school page. + The class page stacks them instead, which is why this is its own class. */ +.cc-page-titlebar { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: space-between; +} +.cc-page-titlebar .cc-page-title { + flex: 1 1 320px; +} + +/* "Code Classroom home" — a back control, not a breadcrumb. The school page is + the top of the tree, so there is nothing to put a trail above. Drawn as a + grey chip in the designs rather than as a bare link. */ +.cc-back-button { + align-self: flex-start; +} +.cc-back-button.rpf-button, +.cc-back-button .rpf-button { + background: var(--rpf-off-white); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-2); + color: var(--rpf-text); +} + +/* Two columns on the school page: the login code card, then the class list. */ +.cc-split { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-4); +} +.cc-split-aside { + flex: 1 1 280px; + max-width: 400px; +} +.cc-split-main { + flex: 3 1 420px; + min-width: 0; +} + +/* The school login code card. The code itself is the largest thing on the + page after the title — a mentor reads it out across a room. */ +.cc-code-value { + margin: 0; + font-size: var(--fs-3); + line-height: var(--lh-25); + font-weight: var(--fw-bold); +} +.cc-code-copy { + display: flex; + align-items: stretch; + gap: var(--space-1); +} +.cc-code-link { + flex: 1 1 auto; + min-width: 0; + border: 2px solid var(--rpf-grey-150); + border-radius: var(--radius-sm); + padding: var(--space-1) 14px; + background: var(--rpf-white); + font: inherit; + color: var(--rpf-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 8ca0adb..f6813a4 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -21,10 +21,14 @@ --fs-15: 20px; --fs-2: 24px; --fs-3: 32px; + /* Code Classroom page titles: font-size-4 / line-height-3-5 in the Code + Editor Figma file. */ + --fs-4: 40px; --lh-1: 16px; --lh-15: 24px; --lh-2: 32px; --lh-25: 40px; + --lh-35: 56px; --fw-regular: 400; --fw-bold: 700; @@ -33,6 +37,7 @@ --space-1: 8px; --space-2: 16px; --space-3: 24px; + --space-4: 32px; --space-5: 40px; --space-7: 56px; diff --git a/src/surfaces/Surface.tsx b/src/surfaces/Surface.tsx index 72c189c..c9457ee 100644 --- a/src/surfaces/Surface.tsx +++ b/src/surfaces/Surface.tsx @@ -64,6 +64,31 @@ export function Surface({ const navItems = nav ?? surface.nav const effectiveLayout = layout ?? surface.layout + const crumbs = breadcrumbs && breadcrumbs.length > 0 && ( + + ) + return (
@@ -79,24 +104,7 @@ export function Surface({ )}
- {breadcrumbs && breadcrumbs.length > 0 && ( - - )} + {crumbs} {effectiveLayout !== 'centred' && navItems.length > 0 && (
- Raspberry Pi Foundation UK registered charity 1129409 - Help · Terms & Conditions · Safeguarding · Privacy + {surface.safeguardingFooter ? ( + <> +
+ + + Do you have a safeguarding concern? Contact us + + + Help + Terms & Conditions + Safeguarding + Accessibility + Privacy + Cookies + +
+ Raspberry Pi Foundation UK registered charity 1129409 + + ) : ( +
+ Raspberry Pi Foundation UK registered charity 1129409 + + Help + Terms & Conditions + Safeguarding + Privacy + +
+ )}
) diff --git a/src/surfaces/surfaces.ts b/src/surfaces/surfaces.ts index 3e3c79a..cba4868 100644 --- a/src/surfaces/surfaces.ts +++ b/src/surfaces/surfaces.ts @@ -33,6 +33,16 @@ export interface SurfaceInfo { layout: 'app' | 'centred' /** One line on what this surface is for, shown in the gallery. */ note: string + /** + * Whether the footer carries the "Do you have a safeguarding concern?" line + * and the full link set, rather than the short one. + * + * On only where a design has been read: Code Classroom, from the Code Editor + * Figma file. The other products very likely have it too, but nobody has + * checked, and a footer invented from a sister product is the kind of detail + * that quietly becomes "what the product looks like". + */ + safeguardingFooter?: boolean } export const SURFACES: Record = { @@ -64,6 +74,7 @@ export const SURFACES: Record = { nav: [], layout: 'app', note: 'Mentor and young-person facing. Student accounts work only here. Navigation is by breadcrumb, not a nav bar.', + safeguardingFooter: true, }, 'pi-accounts': { id: 'pi-accounts', diff --git a/src/surfaces/tokens.css b/src/surfaces/tokens.css index e305cd9..e0d5097 100644 --- a/src/surfaces/tokens.css +++ b/src/surfaces/tokens.css @@ -17,6 +17,7 @@ --surface-bar-text: var(--rpf-text); --surface-accent: var(--rpf-text); --surface-nav: var(--rpf-white); + --surface-header: 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 @@ -43,11 +44,17 @@ --surface-page: var(--rpf-white); } -/* classroom.raspberrypi.org — the sign-in is deep maroon; the signed-in app is - behind a login so this is a guess. */ +/* classroom.raspberrypi.org. The sign-in is deep maroon. The signed-in app was + a guess until the Code Editor Figma file was read for the class and school + pages — these are its values now, not an approximation: + editor-color-theme for the accent, editor-color-layer-accent for the page. + + The pages sit on that warm off-white, with the title block on a white band + above it. `--surface-header` is what paints the band. */ .surface-classroom { - --surface-accent: hsl(340, 78%, 36%); - --surface-page: var(--rpf-off-white); + --surface-accent: #cd2355; + --surface-page: #fbf9f6; + --surface-header: var(--rpf-white); } /* my.raspberrypi.org — raspberry page with a light grey card and a navy