fix(detail): restore the shell's mobile header/nav CSS #85

Merged
tudor merged 1 commits from perf/server-client-split into main 2026-08-06 09:33:34 +00:00
2 changed files with 142 additions and 1 deletions
@@ -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<string> {
return new Set(Array.from(css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g), (m) => m[1]));
}
function classesUsedIn(tsx: string): Set<string> {
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([]);
},
);
});
@@ -667,3 +667,101 @@
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;
}
}