Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d1d5c1090 |
No files matched your search
+3
-22
@@ -206,30 +206,11 @@ registry is orphaned and nothing reads it.
|
||||
variable, and set `UNLEASH_URL` to `http://<UNLEASH_IP>:4242/api`.
|
||||
5. Redeploy the application stacks.
|
||||
|
||||
### Adding a flag to Unleash
|
||||
|
||||
**Unleash does not create flags by itself.** The SDK reads definitions from the
|
||||
server and never registers anything, and metrics for a flag the server has
|
||||
never heard of are discarded. So a flag declared in `backend/flags.py` will be
|
||||
evaluated on every request, stay `False` forever, and never appear in the UI
|
||||
until someone creates it there by hand.
|
||||
|
||||
For each flag in the registry, create one in Unleash with:
|
||||
|
||||
- **Name** — character for character what `backend/flags.py` declares.
|
||||
snake_case, no hyphens or spaces. A typo produces a flag that looks correct
|
||||
in the UI and is read by nothing.
|
||||
- **Type** — Release. No strategies, constraints or variants: these are plain
|
||||
on/off switches, by design.
|
||||
|
||||
### Turning a feature on
|
||||
|
||||
Toggle the flag in the environment matching the stack you mean: **development**
|
||||
for staging, **production** for prod. The token in each stack is scoped to one
|
||||
environment, so toggling the other one has no visible effect.
|
||||
|
||||
The SDK refreshes every 15 seconds, so the API reflects the change almost at
|
||||
once; the pages follow on their own schedule, below.
|
||||
Toggle the flag in the environment you want. Flags appear in the Unleash UI
|
||||
after the backend has evaluated them once, so a newly declared flag shows up
|
||||
shortly after the deploy that introduced it.
|
||||
|
||||
A flip reaches school pages within about five minutes and place pages within
|
||||
the hour. Next's ISR does the propagating — it revalidates a route at the
|
||||
|
||||
@@ -2163,6 +2163,41 @@ test('typing a school name suggests it, and choosing it opens that school', asyn
|
||||
await expect(page).toHaveURL(/\/school\/\d+/);
|
||||
});
|
||||
|
||||
test('the whole dropdown is reachable, not clipped by the hero', async ({ page }) => {
|
||||
/*
|
||||
* The hero panel had overflow: hidden to clip its artwork to the rounded
|
||||
* corners, and it clipped the dropdown too — 320px of list against 145px of
|
||||
* panel below the input, so roughly half was cut off with nothing to say so.
|
||||
*
|
||||
* toBeVisible() does not catch this: it checks the box is non-empty and not
|
||||
* visibility:hidden, and an ancestor's overflow clips neither. The invariant
|
||||
* that does catch it is that the LAST option is the thing actually painted
|
||||
* at its own coordinates — which fails for clipping and for occlusion alike.
|
||||
*/
|
||||
test.skip(!(await autosuggestIsOn(page)),
|
||||
'the school_autosuggest flag is off in this environment');
|
||||
|
||||
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
|
||||
test.skip(!schools?.length, 'no schools in this environment');
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('combobox').first().fill(
|
||||
(schools[0].school_name as string).slice(0, 6));
|
||||
|
||||
const options = page.getByRole('option');
|
||||
await expect(options.first()).toBeVisible();
|
||||
const count = await options.count();
|
||||
|
||||
const painted = await options.nth(count - 1).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return { inside: el.contains(hit) || el === hit, bottom: Math.round(r.bottom) };
|
||||
});
|
||||
expect(painted.inside,
|
||||
`the last option is not painted at its own coordinates (bottom ${painted.bottom}) `
|
||||
+ '— an ancestor is clipping or covering the dropdown').toBeTruthy();
|
||||
});
|
||||
|
||||
test('with autosuggest off, the search box is a plain input', async ({ page }) => {
|
||||
test.skip(await autosuggestIsOn(page),
|
||||
'the school_autosuggest flag is on in this environment');
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Guards against light-theme-only CSS.
|
||||
*
|
||||
* The site themes entirely through tokens redefined under
|
||||
* `@media (prefers-color-scheme: dark)`. A hardcoded colour therefore does not
|
||||
* fail loudly — it renders perfectly in the theme it was written for and
|
||||
* quietly wrongly in the other, which nobody sees unless they happen to be in
|
||||
* dark mode when they look.
|
||||
*
|
||||
* Both rules below are drawn from real defects in SchoolHeroMap.module.css,
|
||||
* found by eye rather than by any test:
|
||||
*
|
||||
* - the map's fade to the header ramped through hardcoded white and landed on
|
||||
* `var(--bg-card)`. Invisible in light; a bright band across the full width
|
||||
* of a near-black card in dark.
|
||||
* - the controls floating over the map paired a hardcoded white background
|
||||
* with `color: var(--text-primary)`, which resolves to #E9EEF0 in dark —
|
||||
* near-white text on a near-white button.
|
||||
*/
|
||||
|
||||
const COMPONENTS = path.join(__dirname, '..', '..', 'components');
|
||||
|
||||
function stylesheets(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return stylesheets(full);
|
||||
return entry.name.endsWith('.module.css') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Innermost `selector { body }` pairs. Nested at-rules never match as rules,
|
||||
* because their body contains braces. */
|
||||
function rules(css: string): Array<{ selector: string; body: string }> {
|
||||
return Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
|
||||
selector: m[1].trim().split('\n').pop()!.trim(),
|
||||
body: m[2],
|
||||
}));
|
||||
}
|
||||
|
||||
const HARDCODED_WHITE_BG = /background[^;]*(?:255,\s*255,\s*255|#fff\b|#ffffff\b)/i;
|
||||
const THEMED_COLOR = /(?:^|[^-])color:\s*var\(--/;
|
||||
|
||||
const files = stylesheets(COMPONENTS);
|
||||
|
||||
describe('dark-theme safety', () => {
|
||||
it('finds stylesheets to check', () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never pairs a hardcoded white background with a themed text colour', () => {
|
||||
const offenders = files.flatMap((file) =>
|
||||
rules(fs.readFileSync(file, 'utf8'))
|
||||
.filter((r) => HARDCODED_WHITE_BG.test(r.body) && THEMED_COLOR.test(r.body))
|
||||
.map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`));
|
||||
|
||||
// Either the surface follows the theme and so should the text, or it does
|
||||
// not and the text must be literal too. Mixing them is how near-white text
|
||||
// ends up on a near-white button.
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('never fades to a themed colour through a hardcoded one', () => {
|
||||
const offenders = files.flatMap((file) =>
|
||||
rules(fs.readFileSync(file, 'utf8'))
|
||||
.filter((r) => /linear-gradient/.test(r.body)
|
||||
&& /var\(--bg-(card|primary|secondary)\)/.test(r.body)
|
||||
&& /255,\s*255,\s*255|#fff\b/i.test(r.body))
|
||||
.map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`));
|
||||
|
||||
// A gradient that lands on a token has to be made of that token, or the
|
||||
// ramp and its destination disagree in one theme. Use the matching
|
||||
// `--*-rgb` token for the transparent stops.
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -28,9 +28,6 @@
|
||||
--bg-primary: #FAFAF8; /* Warm White */
|
||||
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||
--bg-card: #FFFFFF;
|
||||
/* For gradients that have to fade to the card colour. A hardcoded white
|
||||
ramp reads as a bright band against a dark card. */
|
||||
--bg-card-rgb: 255, 255, 255;
|
||||
--surface-inverse: #0F766E;
|
||||
|
||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||
@@ -237,7 +234,6 @@
|
||||
--bg-primary: #111A20;
|
||||
--bg-secondary: #16222A;
|
||||
--bg-card: #18242C;
|
||||
--bg-card-rgb: 24, 36, 44;
|
||||
--surface-inverse: #E9EEF0;
|
||||
|
||||
--text-primary: #E9EEF0;
|
||||
|
||||
@@ -90,7 +90,18 @@
|
||||
isolation: isolate;
|
||||
background: var(--hero-ground);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
/*
|
||||
* Deliberately NOT overflow: hidden.
|
||||
*
|
||||
* It used to be, to clip the artwork and the scrim to the rounded corners —
|
||||
* and it also clipped the search box's suggestion dropdown, which is 320px
|
||||
* tall against 145px of panel below the input. Roughly half the list was cut
|
||||
* off with no indication anything was missing.
|
||||
*
|
||||
* The two things that actually needed clipping round themselves instead, so
|
||||
* the panel can let a dropdown out. Anything absolutely positioned inside
|
||||
* this panel and taller than the space below it depends on this.
|
||||
*/
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
@@ -107,6 +118,10 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
/* Rounds itself, because the panel no longer clips it. inset: 0 makes this
|
||||
exactly the panel's own corners. */
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroArt picture,
|
||||
@@ -143,6 +158,9 @@
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
/* Same reason as .heroArt: the panel stopped clipping, so the scrim keeps
|
||||
its own corners rather than squaring off over the panel's. */
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--hero-ground) 0%,
|
||||
@@ -331,6 +349,10 @@
|
||||
position: static;
|
||||
order: -1;
|
||||
height: 13rem;
|
||||
/* Top corners only. Here the artwork is a band flush with the top of the
|
||||
panel, not a layer covering it — inheriting all four would leave it
|
||||
floating with rounded bottom corners against the copy below. */
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
}
|
||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
||||
|
||||
@@ -47,13 +47,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
/* Sweeps toward the card colour, which is a shade lighter than this
|
||||
ground in both themes. Hardcoded white was a bright flash across a
|
||||
dark page every 1.4s while the tiles loaded. */
|
||||
linear-gradient(100deg,
|
||||
rgba(var(--bg-card-rgb), 0) 40%,
|
||||
rgba(var(--bg-card-rgb), .5) 50%,
|
||||
rgba(var(--bg-card-rgb), 0) 60%) var(--bg-secondary);
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@@ -82,15 +76,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* Controls that float ON the map.
|
||||
*
|
||||
* The map tiles are light in both themes, so these deliberately do NOT follow
|
||||
* the theme — they follow the map. The literal ink below is the point: paired
|
||||
* with a hardcoded white background, `color: var(--text-primary)` resolved to
|
||||
* #E9EEF0 in the dark theme and put near-white text on a near-white button.
|
||||
* A themed token is the wrong tool for a surface that never changes.
|
||||
*/
|
||||
.openHint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -100,8 +85,7 @@
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
/* See "Controls that float ON the map" above. */
|
||||
color: #1C2731;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, .85);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -129,18 +113,11 @@
|
||||
on top of the blend. */
|
||||
z-index: 450;
|
||||
pointer-events: none;
|
||||
/* The card colour, not white.
|
||||
This ramp was hardcoded white and ended at var(--bg-card). In the light
|
||||
theme that is white into white and invisible, as intended. In the dark
|
||||
theme it climbed to 95% WHITE and then met a near-black card — a bright
|
||||
band across the full width, right where the map is supposed to dissolve
|
||||
into the header. Fading to the same colour the gradient lands on is the
|
||||
whole trick, and it only works if that colour is a token. */
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(var(--bg-card-rgb), 0) 0%,
|
||||
rgba(var(--bg-card-rgb), .35) 35%,
|
||||
rgba(var(--bg-card-rgb), .75) 62%,
|
||||
rgba(var(--bg-card-rgb), .95) 82%,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, .35) 35%,
|
||||
rgba(255, 255, 255, .75) 62%,
|
||||
rgba(255, 255, 255, .95) 82%,
|
||||
var(--bg-card) 100%);
|
||||
}
|
||||
|
||||
@@ -157,8 +134,7 @@
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
/* See "Controls that float ON the map" above. */
|
||||
color: #1C2731;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user