@micha.bigler/ui-core-micha 2.37.0 → 2.38.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.
@@ -816,6 +816,207 @@ export function reportOffPaletteColours(
816
816
  return { findings };
817
817
  }
818
818
 
819
+ function balancedBraceRegion(text, start) {
820
+ let depth = 0;
821
+ let quote = null;
822
+ for (let index = start; index < text.length; index += 1) {
823
+ const character = text[index];
824
+ if (quote) {
825
+ if (character === quote && text[index - 1] !== '\\') quote = null;
826
+ continue;
827
+ }
828
+ if (character === '"' || character === "'" || character === '`') {
829
+ quote = character;
830
+ continue;
831
+ }
832
+ if (character === '{') depth += 1;
833
+ else if (character === '}') {
834
+ depth -= 1;
835
+ if (depth === 0) return text.slice(start, index + 1);
836
+ }
837
+ }
838
+ return text.slice(start);
839
+ }
840
+
841
+ function topLevelObjectEntries(objectText) {
842
+ const inner = objectText.slice(1, -1);
843
+ const entries = [];
844
+ let depth = 0;
845
+ let quote = null;
846
+ let entryStart = 0;
847
+ const push = (raw) => {
848
+ const trimmed = raw.trim();
849
+ if (trimmed) entries.push(trimmed);
850
+ };
851
+ for (let index = 0; index < inner.length; index += 1) {
852
+ const character = inner[index];
853
+ if (quote) {
854
+ if (character === quote && inner[index - 1] !== '\\') quote = null;
855
+ continue;
856
+ }
857
+ if (character === '"' || character === "'" || character === '`') {
858
+ quote = character;
859
+ continue;
860
+ }
861
+ if (character === '{' || character === '[' || character === '(') depth += 1;
862
+ else if (character === '}' || character === ']' || character === ')') depth -= 1;
863
+ else if (character === ',' && depth === 0) {
864
+ push(inner.slice(entryStart, index));
865
+ entryStart = index + 1;
866
+ }
867
+ }
868
+ push(inner.slice(entryStart));
869
+ return entries;
870
+ }
871
+
872
+ function splitSxEntry(entry) {
873
+ let depth = 0;
874
+ let quote = null;
875
+ for (let index = 0; index < entry.length; index += 1) {
876
+ const character = entry[index];
877
+ if (quote) {
878
+ if (character === quote && entry[index - 1] !== '\\') quote = null;
879
+ continue;
880
+ }
881
+ if (character === '"' || character === "'" || character === '`') {
882
+ quote = character;
883
+ continue;
884
+ }
885
+ if (character === '{' || character === '[' || character === '(') depth += 1;
886
+ else if (character === '}' || character === ']' || character === ')') depth -= 1;
887
+ else if (character === ':' && depth === 0) {
888
+ return { rawKey: entry.slice(0, index).trim(), rawValue: entry.slice(index + 1).trim() };
889
+ }
890
+ }
891
+ return null;
892
+ }
893
+
894
+ const SIMPLE_IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/;
895
+ const QUOTED_STRING_PATTERN = /^(['"])((?:\\.|(?!\1)[\s\S])*)\1$/;
896
+ const NUMERIC_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
897
+
898
+ /**
899
+ * Reads a top-level `sx={{ ... }}` object literal on a baseline-styled tag
900
+ * and returns its simple `{ property, value }` entries: identifier keys only
901
+ * (a quoted key is a nested selector like `'&:hover'` and is skipped, not a
902
+ * property), values that are a plain quoted string or bare number only. A
903
+ * template literal, identifier reference, spread, or function call is out of
904
+ * scope by design -- this is a lower bound, not a JS evaluator.
905
+ */
906
+ function literalSxEntries(sxValueText) {
907
+ if (!/^\{\s*\{/.test(sxValueText)) return [];
908
+ const objectText = balancedBraceRegion(sxValueText, sxValueText.indexOf('{', 1));
909
+ const entries = [];
910
+ for (const rawEntry of topLevelObjectEntries(objectText)) {
911
+ const split = splitSxEntry(rawEntry);
912
+ if (!split) continue;
913
+ const { rawKey, rawValue } = split;
914
+ if (!SIMPLE_IDENTIFIER_PATTERN.test(rawKey)) continue;
915
+
916
+ const quoted = rawValue.match(QUOTED_STRING_PATTERN);
917
+ if (quoted) {
918
+ entries.push({ property: rawKey, value: quoted[2] });
919
+ continue;
920
+ }
921
+ if (NUMERIC_LITERAL_PATTERN.test(rawValue)) {
922
+ entries.push({ property: rawKey, value: Number(rawValue) });
923
+ }
924
+ }
925
+ return entries;
926
+ }
927
+
928
+ // MUI's sx system multiplies a bare NUMBER by a theme scale factor for these
929
+ // properties (`theme.shape.borderRadius` for borderRadius, `theme.spacing()`
930
+ // for every spacing alias) before it ever reaches the DOM -- a raw
931
+ // `styleOverrides.root` value is already-resolved CSS, unscaled. Comparing
932
+ // the two numbers directly would be a false positive (e.g. `borderRadius: 8`
933
+ // in sx renders as `theme.shape.borderRadius * 8`, not `8`), and false
934
+ // positives are the failure mode this whole check exists to avoid. Numeric
935
+ // literals on these properties are therefore skipped, not compared -- a
936
+ // false negative (this check's accepted lower bound) is the safe direction
937
+ // to err in, a false positive is not.
938
+ const MUI_NUMERIC_SCALE_PROPERTIES = new Set([
939
+ 'borderRadius',
940
+ ...Object.keys(SPACING_SHORTHAND_LONGHANDS).filter((key) => key !== 'bgcolor' && key !== 'typography'),
941
+ ]);
942
+
943
+ /**
944
+ * Resolves what an sx value would actually render as, for comparison against
945
+ * a theme styleOverride value already resolved to a real CSS value. A string
946
+ * sx value is tried as a `theme.palette.<value>` token path first (MUI's own
947
+ * resolution for palette-aware sx properties, e.g. `borderColor: 'divider'`
948
+ * -> `theme.palette.divider`); if that path is undefined, the raw string is
949
+ * used as-is (a plain CSS keyword like `'none'` is not a token path).
950
+ */
951
+ function resolveSxValue(theme, value) {
952
+ if (typeof value !== 'string') return value;
953
+ const paletteValue = getPath(theme, `palette.${value}`);
954
+ return paletteValue !== undefined ? paletteValue : value;
955
+ }
956
+
957
+ /**
958
+ * Reports an app's own `sx` value that already equals what its theme
959
+ * resolves for that MUI component and property -- not "you overrode the
960
+ * baseline" (in an app, that is the intended mechanism), but "you re-stated
961
+ * a value you already had", which silently diverges the next time the token
962
+ * changes. Attribution is by JSX tag name against the same
963
+ * `BASELINE_STYLED_MUI_COMPONENTS` list `reportKitSxBypasses` uses, checked
964
+ * against `theme.components.Mui<Tag>.styleOverrides.root.<property>` only
965
+ * (no other slot). Report-only; there is no exemption arm yet -- each
966
+ * adopting app makes this a hard assertion in its own follow-up WO once it
967
+ * has acted on the findings.
968
+ *
969
+ * This is a lower bound, and the bound is open, not enumerated: a `styled()`
970
+ * component, a component rendered through `component={...}` (so its JSX tag
971
+ * name is not the MUI component it becomes), an aliased import
972
+ * (`import { Paper as Surface }`), array/callback/conditional `sx`, and a
973
+ * template-literal or identifier-reference value are all unattributed or
974
+ * unresolved by this method. A numeric `sx` value on a property MUI scales
975
+ * by a theme factor (`borderRadius`, every spacing alias) is deliberately
976
+ * SKIPPED rather than compared -- see `MUI_NUMERIC_SCALE_PROPERTIES` -- since
977
+ * comparing the raw numbers would be a false positive, not a missed finding.
978
+ * A clean result means "nothing found by this method", not "no redundant
979
+ * value exists".
980
+ */
981
+ export function reportRedundantThemeValues(sources = [], { theme } = {}) {
982
+ const findings = [];
983
+ if (!theme) return { findings };
984
+ const componentPattern = BASELINE_STYLED_MUI_COMPONENTS.join('|');
985
+ const tagPattern = new RegExp(`<(${componentPattern})\\b`, 'g');
986
+
987
+ for (const { path, source } of normalizedSources(sources)) {
988
+ const scanSource = maskComments(maskQrPrintDocument(source, path.replaceAll('\\', '/')));
989
+
990
+ for (const match of scanSource.matchAll(tagPattern)) {
991
+ const tagName = match[1];
992
+ const tag = jsxOpeningTag(scanSource, match.index);
993
+ const sxValueText = topLevelSxValue(tag);
994
+ const line = source.slice(0, match.index).split(/\r?\n/).length;
995
+
996
+ for (const { property, value } of literalSxEntries(sxValueText)) {
997
+ if (typeof value === 'number' && MUI_NUMERIC_SCALE_PROPERTIES.has(property)) continue;
998
+ const [canonicalProperty] = expandShorthandProperty(property);
999
+ const lookupProperty = expandShorthandProperty(property).length === 1
1000
+ ? canonicalProperty
1001
+ : property;
1002
+ const themeValue = getPath(
1003
+ theme,
1004
+ `components.Mui${tagName}.styleOverrides.root.${lookupProperty}`,
1005
+ );
1006
+ if (themeValue === undefined) continue;
1007
+ if (sameValue(resolveSxValue(theme, value), themeValue)) {
1008
+ findings.push({
1009
+ surface: `${path}:${line}.Mui${tagName}.${property}`,
1010
+ reason: `Mui${tagName}'s sx sets "${property}" to a value the theme already resolves to the same thing.`,
1011
+ });
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ return { findings };
1018
+ }
1019
+
819
1020
  /** Reports adoption signals as numbers; it never changes completeness. */
820
1021
  export function reportThemeAdoption(sources = []) {
821
1022
  const normalized = sources.map((entry, index) =>
@@ -147,6 +147,7 @@ describe('SectionNav', () => {
147
147
  renderNav({ mode: 'mobile', onSelect });
148
148
 
149
149
  const trigger = screen.getByRole('button', { name: /Section Profile/i });
150
+ expect(trigger).toBeTruthy();
150
151
  expect(trigger.getAttribute('aria-expanded')).toBe('false');
151
152
  fireEvent.click(trigger);
152
153
  expect(trigger.getAttribute('aria-expanded')).toBe('true');
@@ -164,6 +165,7 @@ describe('SectionNav', () => {
164
165
  const { rerender } = renderNav({ mode: 'mobile', open: false, onOpen, onClose });
165
166
 
166
167
  const trigger = screen.getByRole('button', { name: /Section Profile/i });
168
+ expect(trigger).toBeTruthy();
167
169
  fireEvent.click(trigger);
168
170
 
169
171
  expect(onOpen).toHaveBeenCalledOnce();
@@ -189,6 +191,53 @@ describe('SectionNav', () => {
189
191
  expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
190
192
  });
191
193
 
194
+ it('omits a dead controlled trigger while keeping children and the caller-driven drawer', () => {
195
+ const { rerender } = renderNav({ mode: 'mobile', open: false });
196
+
197
+ expect(screen.queryByRole('button', { name: /Section Profile/i })).toBeNull();
198
+ expect(screen.getByTestId('section-content')).toBeTruthy();
199
+ expect(screen.queryByRole('dialog', { name: 'Switch section' })).toBeNull();
200
+
201
+ rerender(
202
+ <ThemeProvider theme={theme}>
203
+ <SectionNav
204
+ mode="mobile"
205
+ groups={groups}
206
+ activeKey="profile"
207
+ onSelect={vi.fn()}
208
+ open
209
+ >
210
+ <div data-testid="section-content">Section content</div>
211
+ </SectionNav>
212
+ </ThemeProvider>,
213
+ );
214
+
215
+ expect(screen.queryByRole('button', { name: /Section Profile/i })).toBeNull();
216
+ expect(screen.getByTestId('section-content')).toBeTruthy();
217
+ expect(screen.getByRole('dialog', { name: 'Switch section' })).toBeTruthy();
218
+ expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
219
+ });
220
+
221
+ it('keeps the desktop sidebar and children for every open/onOpen combination', () => {
222
+ const propCombinations = [
223
+ {},
224
+ { open: false, onOpen: vi.fn() },
225
+ { open: false },
226
+ ];
227
+
228
+ propCombinations.forEach((props) => {
229
+ const { container } = renderNav(props);
230
+
231
+ expect(container.querySelector('[data-section-nav-mode="desktop"]')).toBeTruthy();
232
+ expect(container.querySelector('nav')).toBeTruthy();
233
+ expect(screen.getByTestId('section-content')).toBeTruthy();
234
+ expect(screen.queryByRole('button', { name: /Section Profile/i })).toBeNull();
235
+ expect(document.querySelector('.MuiDrawer-root')).toBeNull();
236
+
237
+ cleanup();
238
+ });
239
+ });
240
+
192
241
  it('renders the desktop sidebar and children in a two-column grid, while mobile renders a trigger and no sidebar', () => {
193
242
  const { container } = renderNav({ sidebarWidth: 312 });
194
243
  const desktopRoot = container.querySelector('[data-section-nav-mode="desktop"]');
@@ -12,6 +12,7 @@ import {
12
12
  KIT_COMPONENT_SX_REGISTRY,
13
13
  reportOffPaletteColours,
14
14
  reportKitSxBypasses,
15
+ reportRedundantThemeValues,
15
16
  THEME_COMPLETENESS_SURFACES,
16
17
  } from '../src/theme';
17
18
  import { BASELINE_PALETTE } from '../src/theme/tokens';
@@ -505,3 +506,90 @@ describe('off-palette colour reporting', () => {
505
506
  expect(result.findings.some(({ reason }) => reason.includes('grey.50'))).toBe(true);
506
507
  });
507
508
  });
509
+
510
+ describe('redundant app-side theme values', () => {
511
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
512
+
513
+ it('flags an sx value that already equals the theme, naming the file, line, component and property', () => {
514
+ const result = reportRedundantThemeValues(
515
+ [{
516
+ path: 'src/components/Example.jsx',
517
+ source: "export function Example() {\n return <Paper sx={{ borderColor: 'divider' }} />;\n}",
518
+ }],
519
+ { theme },
520
+ );
521
+
522
+ expect(result.findings).toEqual([{
523
+ surface: 'src/components/Example.jsx:2.MuiPaper.borderColor',
524
+ reason: expect.stringMatching(/MuiPaper.*borderColor.*theme already resolves/),
525
+ }]);
526
+ });
527
+
528
+ it('does not flag a genuinely different value', () => {
529
+ const result = reportRedundantThemeValues(
530
+ [{
531
+ path: 'src/components/Example.jsx',
532
+ source: "<Paper sx={{ borderColor: 'error.main' }} />",
533
+ }],
534
+ { theme },
535
+ );
536
+
537
+ expect(result.findings).toEqual([]);
538
+ });
539
+
540
+ it('does not flag a component tag outside the attribution list at all', () => {
541
+ const result = reportRedundantThemeValues(
542
+ [{
543
+ path: 'src/components/Example.jsx',
544
+ source: "<Box sx={{ borderColor: 'divider' }} />",
545
+ }],
546
+ { theme },
547
+ );
548
+
549
+ expect(result.findings).toEqual([]);
550
+ });
551
+
552
+ it('does not flag a property the theme does not style on an attributable component', () => {
553
+ // MuiTextField's baseline styleOverrides.root has no borderColor, so this
554
+ // must fall through the themeValue === undefined branch, not match.
555
+ const result = reportRedundantThemeValues(
556
+ [{
557
+ path: 'src/components/Example.jsx',
558
+ source: "<TextField sx={{ borderColor: 'divider' }} />",
559
+ }],
560
+ { theme },
561
+ );
562
+
563
+ expect(result.findings).toEqual([]);
564
+ });
565
+
566
+ it('skips a numeric borderRadius rather than false-positive on MUI\'s theme-scale multiplier', () => {
567
+ // sx={{ borderRadius: 8 }} on a Card actually renders as
568
+ // theme.shape.borderRadius * 8, not the raw baseline styleOverride value
569
+ // -- comparing the two numbers directly would be a false positive.
570
+ const result = reportRedundantThemeValues(
571
+ [{
572
+ path: 'src/components/Example.jsx',
573
+ source: `<Card sx={{ borderRadius: ${theme.components.MuiCard.styleOverrides.root.borderRadius} }} />`,
574
+ }],
575
+ { theme },
576
+ );
577
+
578
+ expect(result.findings).toEqual([]);
579
+ });
580
+
581
+ it('matches the finding shape of its sibling checks', () => {
582
+ const result = reportRedundantThemeValues(
583
+ [{ path: 'a.jsx', source: "<Paper sx={{ borderColor: 'divider' }} />" }],
584
+ { theme },
585
+ );
586
+
587
+ expect(result).toEqual({ findings: expect.any(Array) });
588
+ expect(result.findings[0]).toEqual({ surface: expect.any(String), reason: expect.any(String) });
589
+ });
590
+
591
+ it('returns no findings and requires a theme', () => {
592
+ expect(reportRedundantThemeValues([{ path: 'a.jsx', source: "<Paper sx={{ borderColor: 'divider' }} />" }]))
593
+ .toEqual({ findings: [] });
594
+ });
595
+ });
@@ -0,0 +1,210 @@
1
+ # SHELL-6 — A controlled `SectionNav` with no way to open must not render a trigger
2
+
3
+ **Target repo:** `ui-core-micha` (branch `main` — this repo has no `develop`)
4
+ **Tier:** 3 (shared core)
5
+ **Review:** independent `reviewer` **and** `ui_reviewer` — both mandatory, concurrent
6
+ **Version target:** the next **patch** after the published version. A defect fix, no API change, no
7
+ consumer relying on the current behaviour. Read the published version at implementation time.
8
+ **Unblocks:** `jg-ferien/NAV-37`, currently `blocked` (`c627d7b`)
9
+ **Follows:** `SHELL-5` (`2.36.0`)
10
+
11
+ ---
12
+
13
+ ## A. Envelope
14
+
15
+ ### Goal
16
+
17
+ In `mode="mobile"`, `SectionNav` renders its built-in trigger **unconditionally**
18
+ (`src/layout/SectionNav.jsx:246`). In controlled mode `handleTriggerClick` calls `onOpen?.()`
19
+ (`:200-206`), so a consumer that drives `open` itself and passes no `onOpen` gets a button that is
20
+ **visible, focusable and does nothing**.
21
+
22
+ Make the trigger render only when it can actually open the drawer.
23
+
24
+ ### This is a defect, not a missing configuration
25
+
26
+ A control that receives focus and performs no action is wrong on its own terms — it is an
27
+ accessibility defect independent of any consumer's wishes. That is why the fix is the **semantics**
28
+ rather than a `showTrigger` prop: a prop would make the dead button the default for every controlled
29
+ consumer who has not heard of the prop, turning a defect into a configuration option.
30
+
31
+ **Nothing today relies on the current behaviour.** `AccountPage`, the only shipped consumer, is
32
+ **uncontrolled** (it passes no `open`), so its trigger keeps rendering unchanged. `jg-ferien` is the
33
+ consumer that hit this and is blocked on it.
34
+
35
+ ### How it shipped, and the rule that follows
36
+
37
+ `SHELL-5`'s Envelope specified that the component *has* a trigger and never said *when it renders*.
38
+ The implementation made it unconditional — correctly, given the spec. `NAV-37` then assumed that
39
+ omitting `onOpen` suppresses it; that assumption appears nowhere in `SHELL-5`.
40
+
41
+ **The uncovered cell and the untested cell were the same cell.** Every existing mobile test is either
42
+ uncontrolled (`:192`, `:210`) or controlled *with* `onOpen` (`:161`) — verified. So this fix breaks no
43
+ existing test, which is the point: the case was never specified, therefore never tested, therefore
44
+ shipped.
45
+
46
+ **Hence the second deliverable below: write the render matrix down.** A prop table plus a scope
47
+ narrative reads complete and is not.
48
+
49
+ ### Scope
50
+
51
+ **1. The fix.** In the mobile return path, render the `<ButtonBase>` trigger only when a click can do
52
+ something: uncontrolled (the component owns `internalOpen`), or controlled **and** `onOpen` supplied.
53
+ `children` and the `<Drawer>` keep rendering in every case — `jg-ferien` needs exactly that: its own
54
+ triggers, the shared drawer, its content.
55
+
56
+ **2. The render matrix, in the component's own doc comment.** This is not optional documentation; it
57
+ is the artefact whose absence caused the defect.
58
+
59
+ | `mode` | `open` | `onOpen` | trigger | sidebar | `children` | drawer |
60
+ |---|---|---|---|---|---|---|
61
+ | `desktop` | ignored | ignored | — | yes | yes, in the grid | — |
62
+ | `mobile` | absent (uncontrolled) | ignored | **yes** | — | yes | yes, internal state |
63
+ | `mobile` | given | given | **yes** | — | yes | caller-driven |
64
+ | `mobile` | given | **absent** | **no** ← this WO | — | yes | caller-driven |
65
+
66
+ Plus the optional-part cells, all already correct and worth recording so they stay that way:
67
+ `overviewItem` absent → no overview entry and no empty `Paper`; `rememberedKey` absent → no secondary
68
+ line; `title` absent → `t('SectionNav.TITLE')`; `triggerEyebrow` absent → `t('SectionNav.TRIGGER_EYEBROW')`.
69
+
70
+ ### Non-goals / do not touch
71
+
72
+ - **No new prop.** Not `showTrigger`, not `hideTrigger`. The operator considered and rejected both:
73
+ they would leave the dead button as the default. A consumer wanting "controlled **with** `onOpen` but
74
+ my own trigger too" has no representation after this WO — that case has no consumer today and adding
75
+ a prop for it is the speculative generality the YAGNI guardrail rejects.
76
+ - **The desktop path** — untouched, including the grid, `sidebarWidth` and `headerOffset`.
77
+ - The drawer, its `zIndex` resolution, its safe-area padding, `SectionNavList`, and every label or
78
+ i18n key.
79
+ - `AccountPage` — no change; it is uncontrolled and keeps its trigger.
80
+ - No baseline token change, no `kitSxRegistry` change. `SECTION_NAV_TRIGGER_SX` stays registered:
81
+ `THEME-4`'s check is about key disjointness, not about whether an element renders.
82
+
83
+ ### Risks
84
+
85
+ - **It is a behaviour change to a shipped component**, which is why "no consumer relies on it" is
86
+ stated above rather than assumed. If a consumer outside this repo passes `open` without `onOpen` and
87
+ wants the trigger, it breaks for them — verified as nobody today.
88
+ - **No staging net.** A version bump on `main` publishes; the two reviews are the only gate.
89
+ - **The condition must be `onOpen` supplied, not `open` supplied.** Keying it on the wrong prop would
90
+ remove the trigger from the uncontrolled case as well, which is `AccountPage`'s only affordance.
91
+ Test 3 exists for that.
92
+
93
+ ### Required tests to WRITE
94
+
95
+ Extend `tests/SectionNav.test.jsx`.
96
+
97
+ 1. **The fix, with non-vacuity.** `mode="mobile"` + `open` + **no** `onOpen` → **no** trigger button
98
+ in the DOM, while `children` still render and the drawer still responds to `open`. **This test must
99
+ fail against the current published version** — say so in the WO's landing note, since a fix whose
100
+ test passes before the fix is not a fix.
101
+ 2. Controlled **with** `onOpen` → trigger renders, clicking calls `onOpen`. (The existing `:161`
102
+ already covers the call; assert the rendering explicitly.)
103
+ 3. **Uncontrolled** → trigger renders and clicking opens the internal drawer. This is the guard against
104
+ keying the condition on the wrong prop.
105
+ 4. Desktop is unchanged in all three prop combinations.
106
+ 5. `THEME-4`'s shadowing check still returns no findings.
107
+
108
+ Plus `tsc -p tsconfig.build.json --noEmit` clean. **No full-suite run** — affected set is
109
+ `tests/SectionNav.test.jsx` and the theme spec touched by test 5.
110
+
111
+ ### Verification
112
+
113
+ The change is the **absence** of an element in one prop combination, so the rendered gate has little to
114
+ add beyond the tests: assert the DOM, and confirm `AccountPage`'s own trigger still renders at 375 px.
115
+ If capture is unavailable, DOM inspection is the declared substitution — and here it is not a
116
+ fallback but the appropriate instrument, because "no button" is a DOM fact, not an appearance.
117
+
118
+ ### Parity guardrail
119
+
120
+ `AccountPage` renders identically. The only intended change is that a controlled consumer without
121
+ `onOpen` no longer gets a dead button.
122
+
123
+ ---
124
+
125
+ ## B. Implementation map
126
+
127
+ *Filled by the Orchestrator on `git pull` — see `AGENTS.md` → "Work Order".*
128
+
129
+ ### Execution directive (read this first)
130
+
131
+ > **If you are the implementer reading this work order as your own specification: this section is
132
+ > NOT addressed to you.** It tells the Orchestrator how to invoke you. **You ARE that invocation —
133
+ > do NOT shell out to `codex exec`.**
134
+ >
135
+ > Implement through `codex exec` in the background — invoked directly via Bash (never the
136
+ > `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
137
+ > `--dangerously-bypass-approvals-and-sandbox`. **Pass the WO via stdin, not as a positional
138
+ > argument.** Fallback to direct Claude implementation only on Codex quota/rate-limit/non-zero exit —
139
+ > and that flips authorship, so both reviewers stay mandatory either way.
140
+ >
141
+ > **Do NOT edit `WORK_ORDERS.md`**, and before your own review read `git log origin/main..HEAD` plus
142
+ > `git status`. Check the working tree first: `NAV-37`'s rejected CSS workaround lives uncommitted in
143
+ > `jg-ferien`, and other sessions have been active in this repo throughout this strand.
144
+
145
+ ### Context package
146
+
147
+ **Precondition confirmed:** `SHELL-5` landed and published (`b283c33`/`ada72be`); `I18N-1` also landed
148
+ independently in the interim (`a6d322d`). Clean starting tree, no implementer commit on top.
149
+
150
+ **Current file state** (`src/layout/SectionNav.jsx`, verified — line numbers drifted a little from
151
+ the WO's own `:246`/`:200-206` estimate since `SHELL-5`'s own review-fix pass, but the structure is
152
+ identical):
153
+ ```js
154
+ // :188-190
155
+ const [internalOpen, setInternalOpen] = useState(false);
156
+ const isControlled = open !== undefined;
157
+ const drawerOpen = isControlled ? open : internalOpen;
158
+ // :200-206
159
+ const handleTriggerClick = () => {
160
+ if (isControlled) {
161
+ onOpen?.();
162
+ } else {
163
+ setInternalOpen(true);
164
+ }
165
+ };
166
+ // :244-266, the mobile return path
167
+ return (
168
+ <>
169
+ <ButtonBase sx={SECTION_NAV_TRIGGER_SX} onClick={handleTriggerClick} ...>
170
+ ...
171
+ </ButtonBase>
172
+ <Box sx={SECTION_NAV_CONTENT_SX}>{children}</Box>
173
+ <Drawer ...>...</Drawer>
174
+ </>
175
+ );
176
+ ```
177
+
178
+ **The fix, precisely:** add `const canOpenTrigger = !isControlled || Boolean(onOpen);` and wrap only
179
+ the `<ButtonBase>` block in `{canOpenTrigger && (...)}`. `children` and `<Drawer>` stay unconditional
180
+ — do not touch their rendering. Do not key the condition on `open` (the WO's own Risks section flags
181
+ this exact mistake as the guard test 3 exists for) — the condition is about whether a click can do
182
+ anything, which depends on `isControlled`/`onOpen`, never on the current `open` value itself.
183
+
184
+ **The render-matrix doc comment (scope item 2):** add a JSDoc-style comment directly above
185
+ `export function SectionNav(...)` reproducing the table from the Envelope (`mode`/`open`/`onOpen` →
186
+ trigger/sidebar/children/drawer), plus the "optional-part cells" paragraph (`overviewItem` absent →
187
+ no overview entry; `rememberedKey` absent → no secondary line; `title`/`triggerEyebrow` absent →
188
+ their `t()` fallbacks). This is a required deliverable per the WO's own framing ("not optional
189
+ documentation; it is the artefact whose absence caused the defect"), not a nice-to-have.
190
+
191
+ **Test file:** `tests/SectionNav.test.jsx` already has a controlled-mode test from `SHELL-5`'s own
192
+ review-fix round (`'defers to the caller in controlled mode: the trigger calls onOpen, not internal
193
+ state'`, roughly `:145-175` now) — that test passes `onOpen`, so it exercises the WO's "controlled
194
+ with `onOpen`" cell, not the new "controlled without `onOpen`" cell this WO fixes. Add the four new
195
+ tests as siblings of it, following its existing render helper (`renderNav`) and prop-passing style.
196
+ Required test 1 (non-vacuity) needs to be run against a build BEFORE the fix lands to confirm it
197
+ fails — since this is a live working tree with the pre-fix code already checked out, this is simply:
198
+ write the test first, run it once to confirm it fails, then apply the one-line fix and confirm it
199
+ passes. State this sequence happened in the register note.
200
+
201
+ **Invariants:** no new prop (`showTrigger`/`hideTrigger` explicitly rejected by the WO); desktop path,
202
+ `Drawer`, `zIndex`, safe-area padding, `SectionNavList`, all i18n keys, `AccountPage` itself, and
203
+ `kitSxRegistry.js` all untouched. `SECTION_NAV_TRIGGER_SX` stays registered even though the element
204
+ it styles now sometimes doesn't render — `THEME-4`'s check is about key disjointness, not element
205
+ presence, so this is a non-issue, not a discrepancy to resolve.
206
+
207
+ ### Mini-handover
208
+
209
+ Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), branch `main`.
210
+ WO: `work-orders/SHELL-6.md`. Unblocks `jg-ferien/NAV-37`. Follow `orchestrate-codex`.