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