@stonedogcode/style 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/package.json +5 -5
- package/src/components/StyledFieldHelp.tsx +248 -0
- package/src/components/StyledPage.tsx +22 -0
- package/src/components/StyledTooltip.tsx +308 -39
- package/src/config/font-size.ts +25 -0
- package/src/index.ts +13 -0
- package/src/preset/index.ts +21 -0
- package/src/preset/recipes/button.ts +34 -4
- package/src/preset/recipes/form.ts +6 -1
- package/src/preset/recipes/icon-button.ts +36 -5
- package/src/preset/recipes/input-bool.ts +19 -3
- package/src/preset/recipes/input-radio.ts +11 -3
- package/src/preset/recipes/input-surface.ts +33 -6
- package/src/preset/recipes/list.ts +7 -1
- package/src/preset/recipes/tooltip.ts +30 -8
- package/src/preset/semantic-variables.ts +22 -5
- package/src/preset/z-layers.ts +101 -0
package/README.md
CHANGED
|
@@ -629,6 +629,55 @@ Scroll mode needs a height to scroll inside: `StyledScrollbar` is
|
|
|
629
629
|
height (`display: flex; flex-direction: column; height: …`). Unconstrained, the
|
|
630
630
|
rail simply grows — which is correct, and is not a bug.
|
|
631
631
|
|
|
632
|
+
## Form help — `StyledFieldHelp`
|
|
633
|
+
|
|
634
|
+
Permanent explanatory text for a field: below the label, above the control,
|
|
635
|
+
always visible.
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
<StyledFormLabel htmlFor="dose">Dose</StyledFormLabel>
|
|
639
|
+
<StyledFieldHelp htmlFor="dose">
|
|
640
|
+
Milligrams per tablet, as printed on the bottle.
|
|
641
|
+
</StyledFieldHelp>
|
|
642
|
+
<StyledInputText id="dose" />
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
It is **text, and nothing else** — no trigger, no disclosure, no popover, no
|
|
646
|
+
preference. That is the design rather than a simplification, and it is what the
|
|
647
|
+
guarantees below rest on.
|
|
648
|
+
|
|
649
|
+
### What it guarantees
|
|
650
|
+
|
|
651
|
+
- **Zero tab stops.** Nothing here is focusable, so explaining a hundred fields
|
|
652
|
+
costs the keyboard nothing. The pattern this replaces put a help *button*
|
|
653
|
+
beside every explained control, which roughly doubled keyboard traversal on a
|
|
654
|
+
busy form and cannot be fixed while a per-control control remains: taking the
|
|
655
|
+
buttons out of the tab order loses the help for sighted keyboard users
|
|
656
|
+
instead.
|
|
657
|
+
- **The control's `aria-describedby` points at it**, so a screen reader
|
|
658
|
+
announces the words as the field's description rather than reading them as
|
|
659
|
+
stray prose further down the page. The component sets the attribute itself,
|
|
660
|
+
merging with any description the control already had and removing only its own
|
|
661
|
+
id when it unmounts — so a call site that forgets still gets the association.
|
|
662
|
+
`fieldHelpId("dose")` is `"dose-help"`, exported so a host can put the
|
|
663
|
+
attribute in server-rendered markup instead; the component notices and stands
|
|
664
|
+
down.
|
|
665
|
+
- **No pointer is involved**, which sidesteps WCAG 1.4.13 (Content on Hover or
|
|
666
|
+
Focus) rather than trying to satisfy it. Touch, mouse and keyboard all get the
|
|
667
|
+
same words with no gesture and no setting.
|
|
668
|
+
- **One tier below the app-wide text size, and never below the smallest tier**
|
|
669
|
+
the host offers. A reader who has already turned their text down is the one
|
|
670
|
+
with the least room to spare.
|
|
671
|
+
- **Contrast is measured, not assumed.** The colour is the emphasis token
|
|
672
|
+
`textMuted`, which is `currentColor`-relative, so it de-emphasises correctly
|
|
673
|
+
on a light theme and a dark one. The component tests composite the whole
|
|
674
|
+
ancestor chain — every translucent layer, not the page background — and assert
|
|
675
|
+
WCAG 1.4.3 AA against the surface the text really paints on. Measuring against
|
|
676
|
+
the page is how text on a tinted chip gets a confident, wrong pass.
|
|
677
|
+
|
|
678
|
+
`children` is typed `ReactNode` for formatting — a unit, a `<strong>`, a line
|
|
679
|
+
break. Putting a control in there defeats the only promise the component makes.
|
|
680
|
+
|
|
632
681
|
## Adopting a component as it is migrated
|
|
633
682
|
|
|
634
683
|
Components move out of HopperGuard into this package one at a time.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonedogcode/style",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "StoneDogCode L.L.C.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/stonedog-code/stonedog-style.git"
|
|
10
10
|
},
|
|
11
|
-
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer
|
|
11
|
+
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer \u2014 which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./preset": "./src/preset/index.ts",
|
|
21
21
|
"./package.json": "./package.json"
|
|
22
22
|
},
|
|
23
|
-
"//files": "The package ships SOURCE, so `src` has to go out whole
|
|
23
|
+
"//files": "The package ships SOURCE, so `src` has to go out whole \u2014 but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
|
|
24
24
|
"files": [
|
|
25
25
|
"src",
|
|
26
26
|
"!src/**/__tests__/**",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"pretype-check": "npm run panda:prepare",
|
|
39
39
|
"type-check": "tsc --noEmit",
|
|
40
40
|
"lint": "eslint . --ext ts,tsx",
|
|
41
|
-
"//pretest": "panda:build, not panda:prepare
|
|
41
|
+
"//pretest": "panda:build, not panda:prepare \u2014 the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
|
|
42
42
|
"pretest": "npm run panda:build",
|
|
43
43
|
"test": "jest",
|
|
44
44
|
"pretest:ct": "npm run panda:build",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"test:ct:ui": "playwright test --config playwright-ct.config.ts --ui",
|
|
47
47
|
"test:coverage": "jest --coverage",
|
|
48
48
|
"gate": "npm run type-check && npm run lint && npm run test",
|
|
49
|
-
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively
|
|
49
|
+
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively \u2014 npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
|
|
50
50
|
"publish:stonedog-style": "bash scripts/publish-package.sh",
|
|
51
51
|
"version:bump:patch": "npm version patch --no-git-tag-version",
|
|
52
52
|
"version:bump:minor": "npm version minor --no-git-tag-version",
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from "react";
|
|
4
|
+
import { styled } from "styled-system/jsx";
|
|
5
|
+
import type { HTMLStyledProps } from "styled-system/types";
|
|
6
|
+
import { log } from "../config/logger";
|
|
7
|
+
import { useFontSizeProfile } from "../config/style-config";
|
|
8
|
+
import { fontSizeMap, stepDownFontSize } from "../config/font-size";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Permanent help text for a form control: below the label, above the control,
|
|
12
|
+
* always visible, never interactive.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <StyledFormLabel htmlFor="dose">Dose</StyledFormLabel>
|
|
16
|
+
* <StyledFieldHelp htmlFor="dose">
|
|
17
|
+
* Milligrams per tablet, as printed on the bottle.
|
|
18
|
+
* </StyledFieldHelp>
|
|
19
|
+
* <StyledInputText id="dose" />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ## Why this exists rather than another tooltip (PRD-0037, NEH-972)
|
|
23
|
+
*
|
|
24
|
+
* The pattern this replaces is `StyledTooltip` plus its `HelpTrigger`, and the
|
|
25
|
+
* problem with it is not placement — NEH-769 fixed the placement and the
|
|
26
|
+
* pattern was still wrong. Four things fail at once:
|
|
27
|
+
*
|
|
28
|
+
* - **Hover-only help excludes touch entirely**, and is actively hostile to a
|
|
29
|
+
* reader with a tremor: they open tooltips by accident and cannot reliably
|
|
30
|
+
* move a pointer *into* one before it closes.
|
|
31
|
+
* - **`HelpTrigger` is a `<button>`, so every instance is a tab stop.** A
|
|
32
|
+
* screenshot of one production screen showed roughly twenty of them; ~160
|
|
33
|
+
* across the app. That roughly doubles keyboard traversal, and it cannot be
|
|
34
|
+
* fixed by taking them out of the tab order — a sighted keyboard user not
|
|
35
|
+
* running a screen reader would lose the help altogether. There is no fix
|
|
36
|
+
* for the tab-stop tax that keeps a per-control control.
|
|
37
|
+
* - **Twenty identical glyphs are not twenty helps, they are noise.** Older
|
|
38
|
+
* readers have measurably low tolerance for hidden information behind an
|
|
39
|
+
* abstract icon, so the pattern penalised exactly the audience it was for.
|
|
40
|
+
* - It needed a **preference** (`accessibility.clickForTooltips`) to be usable
|
|
41
|
+
* on a touch device, and a preference is a thing to get wrong.
|
|
42
|
+
*
|
|
43
|
+
* So this component has **no trigger, no state, no preference and no
|
|
44
|
+
* interaction**. It is text. That is the entire design, and every constraint
|
|
45
|
+
* below follows from it:
|
|
46
|
+
*
|
|
47
|
+
* - it adds **zero tab stops** — no `tabindex`, no focusable element, nothing
|
|
48
|
+
* Tab can land on;
|
|
49
|
+
* - it needs **no pointer**, so it sidesteps WCAG 1.4.13 (Content on Hover or
|
|
50
|
+
* Focus) rather than trying to satisfy dismissible/hoverable/persistent;
|
|
51
|
+
* - it is in the DOM from first paint, so a touch reader, a keyboard reader and
|
|
52
|
+
* a screen-reader user all get the same words with no gesture at all.
|
|
53
|
+
*
|
|
54
|
+
* ## It wires `aria-describedby` itself, and that is deliberate
|
|
55
|
+
*
|
|
56
|
+
* Text sitting near a control is not a description of it. Without
|
|
57
|
+
* `aria-describedby` a screen reader announces "Dose, edit text" and the help
|
|
58
|
+
* is stray prose somewhere else in the reading order — which is how a field
|
|
59
|
+
* ends up *looking* explained and being unexplained.
|
|
60
|
+
*
|
|
61
|
+
* Two things make the association hard to get wrong, because this pattern is
|
|
62
|
+
* about to be applied at well over a hundred call sites and the one that gets
|
|
63
|
+
* skipped is the one nobody notices:
|
|
64
|
+
*
|
|
65
|
+
* 1. **The id is derived, not generated.** `fieldHelpId("dose")` is
|
|
66
|
+
* `"dose-help"` — deterministic from the control's own id, so both sides can
|
|
67
|
+
* name it without passing a generated value around, and it is stable across
|
|
68
|
+
* server and client render.
|
|
69
|
+
* 2. **The component sets the attribute on the control** in an effect, merging
|
|
70
|
+
* with anything already there. A call site that forgets still gets the
|
|
71
|
+
* association.
|
|
72
|
+
*
|
|
73
|
+
* Set imperatively rather than by cloning the child, for the reason
|
|
74
|
+
* `StyledTooltip` records: `cloneElement` depends on every child component
|
|
75
|
+
* forwarding the prop, and a child that quietly drops it fails invisibly. It is
|
|
76
|
+
* not a wrapper for the same reason — a wrapper would have to own the control's
|
|
77
|
+
* markup, and this has to drop into a form whose markup already exists.
|
|
78
|
+
*
|
|
79
|
+
* `useEffect` rather than `useLayoutEffect`: nothing here affects layout, and
|
|
80
|
+
* the accessibility tree is read after hydration. A host may still write
|
|
81
|
+
* `aria-describedby={fieldHelpId("dose")}` on the control itself if it wants
|
|
82
|
+
* the association present in server-rendered HTML; the effect sees it is
|
|
83
|
+
* already there and leaves it alone.
|
|
84
|
+
*
|
|
85
|
+
* ## Size and colour
|
|
86
|
+
*
|
|
87
|
+
* **One tier below the app-wide text size, never below `xs`.** The size is an
|
|
88
|
+
* inline style rather than a Panda prop because Panda extracts styles by
|
|
89
|
+
* parsing source at BUILD time: a prop whose value is only known at runtime
|
|
90
|
+
* yields a class name with no rule behind it, and nothing errors.
|
|
91
|
+
* `StyledFormLabel` and `StyledText` reach for an inline style for exactly this
|
|
92
|
+
* reason. Reading the profile also matters — plain inheritance would pin the
|
|
93
|
+
* help to whatever the browser default is, which in a product whose body text
|
|
94
|
+
* is 1.375rem makes the help less than two-thirds the size of the text it
|
|
95
|
+
* explains.
|
|
96
|
+
*
|
|
97
|
+
* **Colour is `textMuted`**, the emphasis axis, which resolves relative to
|
|
98
|
+
* `currentColor` — so it de-emphasises against the surface it is actually on,
|
|
99
|
+
* light theme or dark, rather than picking a grey that is right on one of them.
|
|
100
|
+
* `StyledFieldHelp.contrast.ct.tsx` measures the rendered result against the
|
|
101
|
+
* **composited** background — every ancestor layer, not the page — and asserts
|
|
102
|
+
* WCAG 1.4.3 AA. Measuring against the page background is how a confidently
|
|
103
|
+
* wrong pass gets produced for text that sits on a tinted chip.
|
|
104
|
+
*
|
|
105
|
+
* The size step and the colour step are two signals, not one, so the help still
|
|
106
|
+
* reads as secondary for anyone who cannot see the colour difference.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
const PandaFieldHelp = styled("p", {
|
|
110
|
+
base: {
|
|
111
|
+
display: "block",
|
|
112
|
+
// Longhands, never the `margin` shorthand. Panda emits atomic rules, and a
|
|
113
|
+
// shorthand competing with a longhand for the same box is decided by
|
|
114
|
+
// stylesheet order rather than by what was written.
|
|
115
|
+
marginTop: "0",
|
|
116
|
+
marginInline: "0",
|
|
117
|
+
// The gap before the control. `StyledFormLabel` supplies the gap above.
|
|
118
|
+
marginBottom: "0.5rem",
|
|
119
|
+
color: "textMuted",
|
|
120
|
+
// Prose, and prose that is being read carefully — a little more leading
|
|
121
|
+
// than the label above it.
|
|
122
|
+
lineHeight: "1.4",
|
|
123
|
+
fontWeight: "normal",
|
|
124
|
+
// No `fontSize`: it is resolved at runtime from the profile. See above.
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The `id` this component gives its help text, derived from the control's id.
|
|
130
|
+
*
|
|
131
|
+
* Exported so a call site can put the association in server-rendered HTML —
|
|
132
|
+
* `aria-describedby={fieldHelpId("dose")}` — and so a test can name the element
|
|
133
|
+
* without reaching into the DOM for it. Deterministic on purpose: a generated
|
|
134
|
+
* id (`useId`) cannot be named by the other half of the pair without threading
|
|
135
|
+
* a value between two siblings, and threading is what gets skipped.
|
|
136
|
+
*/
|
|
137
|
+
export function fieldHelpId(controlId: string): string {
|
|
138
|
+
return `${controlId}-help`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Split an `aria-describedby` attribute into its id tokens. */
|
|
142
|
+
function idTokens(value: string | null): string[] {
|
|
143
|
+
return value ? value.split(/\s+/).filter(Boolean) : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface StyledFieldHelpProps
|
|
147
|
+
extends Omit<HTMLStyledProps<"p">, "children"> {
|
|
148
|
+
/**
|
|
149
|
+
* The `id` of the control this describes.
|
|
150
|
+
*
|
|
151
|
+
* Required, and it is the whole point: without it this is prose near a
|
|
152
|
+
* control rather than the control's description. Named `htmlFor` to match
|
|
153
|
+
* `StyledFormLabel`, so the pair reads the same at a call site.
|
|
154
|
+
*/
|
|
155
|
+
htmlFor: string;
|
|
156
|
+
/**
|
|
157
|
+
* The help itself. **Text.** Anything focusable put in here defeats the one
|
|
158
|
+
* guarantee this component makes, so it is typed as `ReactNode` for
|
|
159
|
+
* formatting (`<strong>`, a unit, a line break) rather than for controls.
|
|
160
|
+
*/
|
|
161
|
+
children: React.ReactNode;
|
|
162
|
+
/** Override the derived id. Rarely wanted — see `fieldHelpId`. */
|
|
163
|
+
id?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const StyledFieldHelp: React.FC<StyledFieldHelpProps> = ({
|
|
167
|
+
htmlFor,
|
|
168
|
+
children,
|
|
169
|
+
id,
|
|
170
|
+
style,
|
|
171
|
+
fontSize,
|
|
172
|
+
...props
|
|
173
|
+
}) => {
|
|
174
|
+
// Unconditional and at the top: folding this into the expression below reads
|
|
175
|
+
// fine and is a hooks-order violation the moment `fontSize` is passed.
|
|
176
|
+
const profile = useFontSizeProfile();
|
|
177
|
+
const ref = useRef<HTMLParagraphElement | null>(null);
|
|
178
|
+
|
|
179
|
+
const helpId = id ?? fieldHelpId(htmlFor);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const node = ref.current;
|
|
183
|
+
if (!node) return;
|
|
184
|
+
|
|
185
|
+
// The element's own document, not the global one: a component test mounts
|
|
186
|
+
// inside an iframe, and a host may portal into another window.
|
|
187
|
+
const control = node.ownerDocument.getElementById(htmlFor);
|
|
188
|
+
if (!control) {
|
|
189
|
+
// Not thrown. A missing control is a call-site bug, but the help text is
|
|
190
|
+
// still readable on screen and throwing would take the whole form down
|
|
191
|
+
// over an attribute. The host hears about it through its own logger.
|
|
192
|
+
log.warn(
|
|
193
|
+
"StyledFieldHelp: no element has this id, so the help is not announced as the field's description",
|
|
194
|
+
{ htmlFor, helpId },
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tokens = idTokens(control.getAttribute("aria-describedby"));
|
|
200
|
+
// Already named — the call site wired it statically. Leave it be, or the
|
|
201
|
+
// id lands twice and a screen reader reads the description twice.
|
|
202
|
+
if (tokens.includes(helpId)) return;
|
|
203
|
+
|
|
204
|
+
control.setAttribute("aria-describedby", [...tokens, helpId].join(" "));
|
|
205
|
+
|
|
206
|
+
return () => {
|
|
207
|
+
// Read the attribute again rather than restoring the value captured
|
|
208
|
+
// above. Something else may have added its own id in the meantime — an
|
|
209
|
+
// error summary is the obvious one — and restoring a stale string would
|
|
210
|
+
// silently drop it.
|
|
211
|
+
const remaining = idTokens(
|
|
212
|
+
control.getAttribute("aria-describedby"),
|
|
213
|
+
).filter((token) => token !== helpId);
|
|
214
|
+
if (remaining.length > 0) {
|
|
215
|
+
control.setAttribute("aria-describedby", remaining.join(" "));
|
|
216
|
+
} else {
|
|
217
|
+
control.removeAttribute("aria-describedby");
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}, [htmlFor, helpId]);
|
|
221
|
+
|
|
222
|
+
// Applied only when the caller named no size, so their Panda `fontSize` class
|
|
223
|
+
// is not beaten by an inline declaration.
|
|
224
|
+
const sized = fontSize
|
|
225
|
+
? undefined
|
|
226
|
+
: fontSizeMap[stepDownFontSize(profile)] ?? fontSizeMap.sm;
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<PandaFieldHelp
|
|
230
|
+
ref={ref}
|
|
231
|
+
id={helpId}
|
|
232
|
+
fontSize={fontSize}
|
|
233
|
+
// A stable hook for the app's own end-to-end assertion that help is in
|
|
234
|
+
// the DOM with no pointer interaction (PRD-0037's success criteria), and
|
|
235
|
+
// for finding the call sites during the migration.
|
|
236
|
+
data-field-help="true"
|
|
237
|
+
style={{ ...(sized ? { fontSize: sized } : {}), ...style }}
|
|
238
|
+
{...props}
|
|
239
|
+
>
|
|
240
|
+
{children}
|
|
241
|
+
</PandaFieldHelp>
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
StyledFieldHelp.displayName = "StyledFieldHelp";
|
|
246
|
+
|
|
247
|
+
export default StyledFieldHelp;
|
|
248
|
+
export { StyledFieldHelp };
|
|
@@ -173,6 +173,28 @@ export const StyledPage = React.forwardRef<HTMLDivElement, StyledPageProps>(
|
|
|
173
173
|
flexDirection: "column",
|
|
174
174
|
flex: "1",
|
|
175
175
|
width: "100%",
|
|
176
|
+
// BOTH `flex: 1` and `height: 100%`, and the second is not
|
|
177
|
+
// redundant (NEH-802).
|
|
178
|
+
//
|
|
179
|
+
// `flex: 1` covers the common case — the page is a flex item in a
|
|
180
|
+
// column, and takes the space the column offers. That is what the
|
|
181
|
+
// app shell does, so it is easy to conclude this is the only case
|
|
182
|
+
// and drop the height. It was dropped, and it broke a production
|
|
183
|
+
// dashboard.
|
|
184
|
+
//
|
|
185
|
+
// `flex: 1` is INERT inside a block-level parent. HopperGuard's
|
|
186
|
+
// `/dashboard` nests one page inside another through plain
|
|
187
|
+
// `display: block` wrappers, and there the page's height came
|
|
188
|
+
// entirely from `height: 100%`. Without it the inner page fell
|
|
189
|
+
// back to CONTENT height — 138px, of which a widget header took
|
|
190
|
+
// ~128, leaving its `1fr` body 10px of padding and the grid zero.
|
|
191
|
+
// The header rendered; every tile was clipped to nothing.
|
|
192
|
+
//
|
|
193
|
+
// The failure is silent in the worst way: nothing errors, nothing
|
|
194
|
+
// is unstyled, and the page looks deliberately empty. Only a
|
|
195
|
+
// computed-height walk up the DOM shows it, which is why no unit
|
|
196
|
+
// test or type-check can stand in for the assertion below.
|
|
197
|
+
height: "100%",
|
|
176
198
|
// See the contract note above: without this the overflow escapes
|
|
177
199
|
// the row rather than scrolling inside it.
|
|
178
200
|
minHeight: "0",
|