feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(webkit): give Menu rows an arrow control split by href, and make Back a button - #952

Open
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button
Open

feat(webkit): give Menu rows an arrow control split by href, and make Back a button#952
gabriel-lisboa-azion wants to merge 4 commits into
mainfrom
feat/menu-split-trigger-and-back-button

Conversation

@gabriel-lisboa-azion

@gabriel-lisboa-aziongabriel-lisboa-azion commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A row that owns children now always carries a trailing transparent IconButtonarrow — the affordance that says the row owns children. href decides what the label beside it does: with one, the label is a real <a> and the arrow alone reveals the children; without one, the label reveals them too, so the whole row does.
  • aria-expanded / aria-controls sit on whichever control expands the children and on exactly one of them; when the label owns them the arrow becomes a redundant pointer target (tabindex="-1" + aria-hidden), adding neither a second tab stop nor a second announced control.
  • Menu.Back is a compact button instead of a full-width row with a centred label, and its text names where it lands (Back, Back to Settings, or label) rather than the level the reader is already in.

How to test

  1. pnpm webkit:test — 2216 pass, including the menu suite's axe checks.
  2. pnpm storybook:devComponents / Navigation / Menu / Types. Neither row has an href, so each reveals its children from the whole row while still showing the arrow. Tab: one stop per row.
  3. Add href="/anything" to a SubTrigger in that story: the label becomes a real link (middle-click opens a tab), the arrow alone reveals, and aria-expanded moves from the label to the arrow.
  4. Menu / Drill — activate the row to push its level; Back appears as a compact left-aligned button reading Back. Push a second level: Back to <parent>. Escape / ArrowLeft / clicking Back pops and returns focus to whichever control pushed.
  5. Menu / Disabled — the disabled trigger suppresses every control of the row, and its arrow paints no fill (IconButton's filled disabled box would otherwise make the arrow the brightest thing on a row whose label had dimmed away).

Notes

  • Not marked breaking: no prop, event, slot or export was removed or renamed, and a row with no href behaves as it did before this PR. The trigger's root is now always a div holding its controls, so a consumer whose tests or CSS target the trigger root as the interactive element should point at …__control / …__reference / …__arrow.
  • New prop: SubTrigger.href (default ''). In data-driven mode it comes from MenuNode.href, which already existed — so the behaviour falls out of the tree with no new concept for consumers, and it follows root-element.md (the element switches on a data prop, never an as string).
  • IconButton now forwards $attrs to its root, per .claude/rules/root-element.md. It declared inheritAttrs: false and never spread them, so every consumer attribute was silently swallowed — that is why the arrow could not carry aria-expanded. class and data-testid stay excluded (already applied explicitly).
  • The unconditional split was wrong on a phone, measured in the sample's docs sheet: the label was 320×32 and navigated away and dismissed the sheet, while the only control that expanded was 28×28 — 7% of the row's area, under the 44px touch guideline.

… and make Back a button
A row that owns children is a destination as well as a container, so it is now
two controls: a label that references wherever the row points, and a trailing
transparent IconButton that reveals its children. Condensed rows expand in
place, drill rows push a level. Reaching the children no longer costs the reader
the destination, and each control has its own surface, focus ring and hit area.
aria-expanded and aria-controls move to the arrow, the control that expands the
children. The arrow keys still work from the label, and focus returns to the
arrow when a drill level is popped.
Menu.Back is a compact button rather than another full-width row, so the one
control that leaves a level does not read as one of the rows inside it, and its
text names where it lands rather than the level the reader is already in.
IconButton now forwards $attrs to its root, per .claude/rules/root-element.md.
It set inheritAttrs: false and never spread them, so every consumer attribute
was silently swallowed -- which is why the arrow could not carry aria-expanded.
@gabriel-lisboa-azion
gabriel-lisboa-azionforce-pushed the feat/menu-split-trigger-and-back-button branch from c544773 to df6a6d1CompareAugust 31, 2026 22:26
… arrow on every row
A row that owns children now always renders its arrow as a transparent IconButton
-- that arrow IS the affordance saying the row owns children, so it must not change
shape according to whether the row also has a destination. What `href` decides is
what the LABEL beside it does, and therefore how much of the row reveals the
children:
- with an `href`, the label is an <a> to that destination and the arrow alone
reveals the children, so reaching them does not cost the reader the destination;
- without one, the label reveals them too, so the whole row does.
The unconditional split was wrong on a phone, measured in the sample's docs sheet:
the label was 320x32 and navigated away AND dismissed the sheet, while the only
control that expanded was 28x28 -- 7% of the row's area, under the 44px touch
guideline. Tapping a chevron row to look inside it therefore threw the reader onto
a page and closed the menu. Every one of the 28 container rows in that tree carries
no href (a container is not a destination there), so all 28 now reveal from their
full width.
aria-expanded / aria-controls sit on whichever control expands the children and on
exactly one of them: the arrow when the label is a link, the label otherwise -- in
which case the arrow becomes a redundant pointer target (tabindex="-1" +
aria-hidden), so it adds neither a second tab stop nor a second announced control.
Focus after a pop returns to whichever control pushed. A disabled row drops its
`href` outright, since an anchor with no destination is not a link.
@gabriel-lisboa-aziongabriel-lisboa-azion changed the title feat(webkit): split a Menu sub-trigger into a reference and an arrow, and make Back a buttonfeat(webkit): give Menu rows an arrow control split by href, and make Back a buttonSep 1, 2026
gabriel-lisboa-azion added a commit that referenced this pull request Sep 1, 2026
… Back button
Merges feat/menu-split-trigger-and-back-button, which carries main up to 11d2bd5
(9 commits) plus the two of the PR. Resolutions, none of them mechanical:
- packages/theme/src/tokens/semantic/texts.data.js — hand-merged. The demo side is a
superset inside the conflict hunk (its own text-amount-* ladder, textWrapStyle on the
heading tokens) and already carried main's regular-weight change there, so the hunk
took ours; text-body-prose-md sat OUTSIDE the hunk, where only main had changed it
light -> normal, so the auto-merge kept main's value. Canaries: 34 text-* entries
(demo 34 / incoming 31), 0 fontWeight.light, 3 text-amount-*, 16 textWrapStyle.
- packages/theme/dist/v4/globals.{css,scss} — generated; rebuilt from the merged tokens
rather than hand-merged. The rebuilt CSS keeps the demo-only utilities and leaves
exactly 3 font-weight:300 declarations, all @font-face for the Light files, matching
the incoming side's count.
- packages/webkit/scripts/authoring-baseline.json — generated; regenerated on the merged
tree (224 entries, the demo's own count).
- .specs/doc-prose.md — only checksum and last_updated conflicted; the body auto-merged,
so neither side's checksum was valid and it was recomputed on the merged body.
- packages/webkit/catalog.json — regenerated on the merged tree (258 exports @ 4.4.0).
apps/webkit-sample never entered the merge: it does not exist at the base or on the
incoming side, so its 579 files pass through untouched.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@gabriel-lisboa-azion@isaque-bock-azion