From e098ad4bd1130152705788d4773837b7d1e7112e Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 6 Aug 2026 10:31:12 +0100 Subject: [PATCH] fix(detail): restore the shell's mobile header/nav CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server/client split moved the header and sticky nav markup into SchoolDetailShell, but their responsive rules stayed behind in schoolSections.module.css. CSS Modules hash class names per file, so those rules stopped matching entirely: on mobile the header details block never collapsed ("Show all details" e2e journey failed), the toggle itself never appeared, and the section-nav mobile swap (menu button in, link row out) never applied. Ports the stranded blocks into SchoolDetailShell.module.css verbatim: the 768px header block (stacked header, wrapped meta pills, details toggle + collapse) and the 640px section-nav blocks (scroll-fade mask, bottom-sheet panel, control swap). Adds a jest guard that every class a components/school component references is defined in the stylesheet that component imports — it fails on the three classes that had no rule at all here (headerDetailsOpen, atEnd, sectionNavBackLabel). Co-Authored-By: Claude Opus 5 --- .../__tests__/components/schoolStyles.test.ts | 43 ++++++++ .../school/SchoolDetailShell.module.css | 100 +++++++++++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 nextjs-app/__tests__/components/schoolStyles.test.ts diff --git a/nextjs-app/__tests__/components/schoolStyles.test.ts b/nextjs-app/__tests__/components/schoolStyles.test.ts new file mode 100644 index 0000000..c02ed51 --- /dev/null +++ b/nextjs-app/__tests__/components/schoolStyles.test.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Guards against "stranded" CSS: when markup moves between components, its + * rules must move with it. CSS Modules hash class names per file, so a rule + * left behind in another stylesheet silently stops applying — exactly how the + * header's mobile `.headerDetails { display: none }` (and the section-nav + * mobile swap) stopped working when the detail view was split into a client + * shell plus server sections. + */ + +const SCHOOL_DIR = path.join(__dirname, '..', '..', 'components', 'school'); + +function classesDefinedIn(css: string): Set { + return new Set(Array.from(css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g), (m) => m[1])); +} + +function classesUsedIn(tsx: string): Set { + return new Set(Array.from(tsx.matchAll(/\bstyles\.([_a-zA-Z][\w]*)/g), (m) => m[1])); +} + +const components = fs + .readdirSync(SCHOOL_DIR) + .filter((f) => f.endsWith('.tsx')) + .map((file) => { + const source = fs.readFileSync(path.join(SCHOOL_DIR, file), 'utf8'); + const importMatch = source.match(/import\s+styles\s+from\s+['"](.+?\.module\.css)['"]/); + return { file, source, stylesheet: importMatch?.[1] }; + }) + .filter((c) => c.stylesheet); + +describe('school detail stylesheets', () => { + it.each(components.map((c) => [c.file, c.source, c.stylesheet as string]))( + '%s only uses classes its own stylesheet defines', + (_file, source, stylesheet) => { + const css = fs.readFileSync(path.join(SCHOOL_DIR, stylesheet), 'utf8'); + const defined = classesDefinedIn(css); + const missing = Array.from(classesUsedIn(source)).filter((c) => !defined.has(c)); + expect(missing).toEqual([]); + }, + ); +}); diff --git a/nextjs-app/components/school/SchoolDetailShell.module.css b/nextjs-app/components/school/SchoolDetailShell.module.css index 229b1bf..d1c38fe 100644 --- a/nextjs-app/components/school/SchoolDetailShell.module.css +++ b/nextjs-app/components/school/SchoolDetailShell.module.css @@ -662,8 +662,106 @@ } @media (max-width: 640px) { - + .sectionNavAll { min-height: 36px; } } + +@media (max-width: 768px) { + .headerContent { + flex-direction: column; + gap: 1rem; + } + + .actions { + width: 100%; + } + + .btnAdd, + .btnRemove { + flex: 1; + } + + .schoolName { + font-size: 1.25rem; + word-break: break-word; + } + + /* Pills wrap horizontally instead of stacking — short tokens like + "Manchester" / "Voluntary aided" fit 2 per row instead of 3 full + rows of empty horizontal space. */ + .meta { + flex-direction: row; + flex-wrap: wrap; + gap: 0.375rem; + } + + /* Secondary header info (headteacher, website, pupil count, trust, + contact, area) isn't needed above the fold on phones/tablets, so it's + collapsed by default and revealed on demand via the "Show all details" + link — reclaiming the vertical space so the metrics surface sooner. */ + .detailsToggle { + display: inline-flex; + } + + .headerDetails { + display: none; + } + + .headerDetailsOpen { + display: flex; + flex-direction: column; + gap: 0.375rem; + } +} + +@media (max-width: 640px) { + .sectionNavLinks { + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); + } + + /* When scrolled to the end, drop the fade so the last item isn't dimmed. */ + .sectionNavLinks.atEnd { + -webkit-mask-image: none; + mask-image: none; + } + + .sectionsPanel { + position: fixed; + top: auto; + left: 0; + right: 0; + bottom: 0; + width: auto; + max-height: 74vh; + border-radius: 16px 16px 0 0; + padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom, 0)); + box-shadow: 0 -10px 40px rgba(26, 22, 18, 0.25); + } + + .sectionsItem { + padding: 0.7rem 0.6rem; + font-size: 0.9rem; + } +} + +/* Swap which controls show at the mobile breakpoint. Declared last so these + display rules win over the base (equal-specificity) declarations above. */ +@media (max-width: 640px) { + .sectionNavLinks, + .sectionNavCompare, + .sectionNavAll, + .sectionNavBackLabel { + display: none; + } + + .sectionNavMenu { + display: flex; + } + + .sectionNavCompareIcon { + display: inline-flex; + } +} -- 2.54.0