@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.
- package/WORK_ORDERS.md +4 -2
- package/dist/index.js +1 -1
- package/dist/layout/SectionNav.js +16 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themeCompleteness.js +202 -0
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/layout/SectionNav.jsx +38 -21
- package/src/theme/index.js +1 -0
- package/src/theme/themeCompleteness.js +201 -0
- package/tests/SectionNav.test.jsx +49 -0
- package/tests/themeCompleteness.test.js +88 -0
- package/work-orders/SHELL-6.md +210 -0
- package/work-orders/THEME-6.md +247 -0
|
@@ -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`.
|