Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0804566736 | ||
|
|
0b15497c09 | ||
|
|
d55f6cce23 | ||
|
|
d5a6db289d |
No files matched your search
@@ -2163,6 +2163,41 @@ test('typing a school name suggests it, and choosing it opens that school', asyn
|
|||||||
await expect(page).toHaveURL(/\/school\/\d+/);
|
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('with autosuggest off, the search box is a plain input', async ({ page }) => {
|
||||||
test.skip(await autosuggestIsOn(page),
|
test.skip(await autosuggestIsOn(page),
|
||||||
'the school_autosuggest flag is on in this environment');
|
'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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hero search and the results filter bar are the same component in two
|
||||||
|
* costumes. `.filterBar` is the card — background, border, shadow, padding —
|
||||||
|
* and `.heroMode` strips all of it so the search sits directly on the hero
|
||||||
|
* panel.
|
||||||
|
*
|
||||||
|
* Both selectors have specificity (0,1,0), so **source order decides**, and
|
||||||
|
* `.heroMode` only wins because it is declared immediately after. Any later
|
||||||
|
* bare `.filterBar` rule — which in practice means one inside a media query —
|
||||||
|
* silently wins instead, and the hero grows a card's padding back.
|
||||||
|
*
|
||||||
|
* That is exactly what happened: `@media (max-width: 768px) { .filterBar {
|
||||||
|
* padding: 0.875rem } }` re-added 14px in hero mode, indenting the search box,
|
||||||
|
* the hint and the location link 14px past the headline above them and costing
|
||||||
|
* the search field 28px of width on a 390px screen. The two rules directly
|
||||||
|
* below it in the same block were correctly written as
|
||||||
|
* `.filterBar:not(.heroMode)`; this one was missed, and nothing caught it
|
||||||
|
* because the result is a plausible-looking layout rather than a broken one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CSS = path.join(__dirname, '..', '..', 'components', 'FilterBar.module.css');
|
||||||
|
|
||||||
|
/** Properties `.heroMode` resets. A later bare `.filterBar` rule setting any
|
||||||
|
* of these puts the card back on the hero. */
|
||||||
|
const RESET_BY_HERO_MODE = [
|
||||||
|
'background', 'border', 'border-radius', 'box-shadow', 'padding',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comments are stripped before anything is parsed.
|
||||||
|
*
|
||||||
|
* A `{` or `}` inside a comment would otherwise desynchronise the brace walk
|
||||||
|
* below and the rule regex alike, and the selector text captured for each rule
|
||||||
|
* would carry the preceding comment along with it.
|
||||||
|
*/
|
||||||
|
function withoutComments(css: string): string {
|
||||||
|
return css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The individual selectors in a rule's prelude.
|
||||||
|
*
|
||||||
|
* Split on commas, because a selector list is a list: `.filterBar, .other { }`
|
||||||
|
* applies to `.filterBar` just as surely as `.filterBar { }` does, and an
|
||||||
|
* earlier version of this guard compared the whole prelude against the literal
|
||||||
|
* string '.filterBar' — so writing the regression as a comma list, or across
|
||||||
|
* two lines, would have walked straight past it.
|
||||||
|
*/
|
||||||
|
function selectorsOf(prelude: string): string[] {
|
||||||
|
return prelude.split(',').map((sel) => sel.trim().replace(/\s+/g, ' '))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaQueryBodies(css: string): string[] {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
const re = /@media[^{]*\{/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(css)) !== null) {
|
||||||
|
// Walk braces from the opening one to find this at-rule's whole body.
|
||||||
|
let depth = 1;
|
||||||
|
let i = m.index + m[0].length;
|
||||||
|
const start = i;
|
||||||
|
while (i < css.length && depth > 0) {
|
||||||
|
if (css[i] === '{') depth++;
|
||||||
|
else if (css[i] === '}') depth--;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
bodies.push(css.slice(start, i - 1));
|
||||||
|
}
|
||||||
|
return bodies;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FilterBar hero-mode scoping', () => {
|
||||||
|
const css = withoutComments(fs.readFileSync(CSS, 'utf8'));
|
||||||
|
|
||||||
|
it('confirms heroMode still resets the card, which is what makes this matter', () => {
|
||||||
|
const hero = css.match(/\.heroMode\s*\{([^}]*)\}/);
|
||||||
|
expect(hero).not.toBeNull();
|
||||||
|
expect(hero![1]).toMatch(/padding:\s*0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never re-applies card styling to the hero from inside a media query', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const body of mediaQueryBodies(css)) {
|
||||||
|
for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||||
|
// Only a *bare* .filterBar is dangerous, and it is dangerous wherever
|
||||||
|
// it appears in a selector list. Scoped variants
|
||||||
|
// (`.filterBar:not(.heroMode)`) and descendants are fine.
|
||||||
|
const selectors = selectorsOf(rule[1]);
|
||||||
|
if (!selectors.includes('.filterBar')) continue;
|
||||||
|
|
||||||
|
for (const prop of RESET_BY_HERO_MODE) {
|
||||||
|
if (new RegExp(`(^|[;\\s])${prop}\\s*:`).test(rule[2])) {
|
||||||
|
offenders.push(`${rule[1].trim()} sets ${prop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix by scoping the rule as `.filterBar:not(.heroMode)`, the way the
|
||||||
|
// neighbouring rules in the same block already are.
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,9 +28,6 @@
|
|||||||
--bg-primary: #FAFAF8; /* Warm White */
|
--bg-primary: #FAFAF8; /* Warm White */
|
||||||
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||||
--bg-card: #FFFFFF;
|
--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;
|
--surface-inverse: #0F766E;
|
||||||
|
|
||||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||||
@@ -237,7 +234,6 @@
|
|||||||
--bg-primary: #111A20;
|
--bg-primary: #111A20;
|
||||||
--bg-secondary: #16222A;
|
--bg-secondary: #16222A;
|
||||||
--bg-card: #18242C;
|
--bg-card: #18242C;
|
||||||
--bg-card-rgb: 24, 36, 44;
|
|
||||||
--surface-inverse: #E9EEF0;
|
--surface-inverse: #E9EEF0;
|
||||||
|
|
||||||
--text-primary: #E9EEF0;
|
--text-primary: #E9EEF0;
|
||||||
|
|||||||
@@ -413,7 +413,17 @@
|
|||||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.filterBar {
|
/*
|
||||||
|
* Scoped, like the two rules below it.
|
||||||
|
*
|
||||||
|
* The results filter bar is a card — background, border, shadow — and needs
|
||||||
|
* inner padding. The hero's search is not a card: .heroMode zeroes the
|
||||||
|
* padding, border and background so the search sits directly on the panel.
|
||||||
|
* Unscoped, this rule put 14px back, which indented the search box, the hint
|
||||||
|
* and the location link 14px past the headline they sit under, and cost the
|
||||||
|
* search field 28px of width on a 390px screen.
|
||||||
|
*/
|
||||||
|
.filterBar:not(.heroMode) {
|
||||||
padding: 0.875rem;
|
padding: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +467,14 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Optical alignment: the button's own 6px of padding is what makes its
|
||||||
|
label start further right than the hint above it, even once both boxes
|
||||||
|
share a left edge. Pulling the padding back off lines the text up while
|
||||||
|
keeping the tap target. */
|
||||||
|
.heroMode .nearMeBtn {
|
||||||
|
margin-left: -0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.geoError {
|
.geoError {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,18 @@
|
|||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
background: var(--hero-ground);
|
background: var(--hero-ground);
|
||||||
border-radius: var(--radius-xl);
|
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 {
|
.heroContent {
|
||||||
@@ -107,6 +118,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 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,
|
.heroArt picture,
|
||||||
@@ -143,6 +158,9 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
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(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
var(--hero-ground) 0%,
|
var(--hero-ground) 0%,
|
||||||
@@ -331,6 +349,10 @@
|
|||||||
position: static;
|
position: static;
|
||||||
order: -1;
|
order: -1;
|
||||||
height: 13rem;
|
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
|
/* The band crop puts the schoolhouse at 73% across — reported by
|
||||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
scripts/build-hero-images.js, which derives it from the crop box rather
|
||||||
|
|||||||
@@ -47,13 +47,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background:
|
||||||
/* Sweeps toward the card colour, which is a shade lighter than this
|
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
|
||||||
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);
|
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -82,15 +76,6 @@
|
|||||||
justify-content: center;
|
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 {
|
.openHint {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -100,8 +85,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
/* See "Controls that float ON the map" above. */
|
color: var(--text-primary);
|
||||||
color: #1C2731;
|
|
||||||
background: rgba(255, 255, 255, .85);
|
background: rgba(255, 255, 255, .85);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
@@ -129,18 +113,11 @@
|
|||||||
on top of the blend. */
|
on top of the blend. */
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
pointer-events: none;
|
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,
|
background: linear-gradient(to bottom,
|
||||||
rgba(var(--bg-card-rgb), 0) 0%,
|
rgba(255, 255, 255, 0) 0%,
|
||||||
rgba(var(--bg-card-rgb), .35) 35%,
|
rgba(255, 255, 255, .35) 35%,
|
||||||
rgba(var(--bg-card-rgb), .75) 62%,
|
rgba(255, 255, 255, .75) 62%,
|
||||||
rgba(var(--bg-card-rgb), .95) 82%,
|
rgba(255, 255, 255, .95) 82%,
|
||||||
var(--bg-card) 100%);
|
var(--bg-card) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +134,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, .92);
|
background: rgba(255, 255, 255, .92);
|
||||||
/* See "Controls that float ON the map" above. */
|
color: var(--text-primary);
|
||||||
color: #1C2731;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user