Add Stackwatch example app and phone sidebar (v1.1.0) #1

Merged
Jriyuu merged 2 commits from example-app into main 2026-11-01 16:58:52 +11:00
Owner
  • demo/app: working example app with fake data; every sidebar page works
  • Sidebar slides in from a menu button on narrow screens, with backdrop
  • New bf-table-wrap, bf-code and bf-empty helpers
  • bifrost.js exposes Bifrost.setTheme/getTheme/icons
  • Starter page and gallery use the menu button; docs updated

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

- demo/app: working example app with fake data; every sidebar page works - Sidebar slides in from a menu button on narrow screens, with backdrop - New bf-table-wrap, bf-code and bf-empty helpers - bifrost.js exposes Bifrost.setTheme/getTheme/icons - Starter page and gallery use the menu button; docs updated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Stackwatch example app and phone sidebar (v1.1.0)
All checks were successful
release / check (pull_request) Successful in 4s
release / move-major-tag (pull_request) Has been skipped
release / demo-image (pull_request) Has been skipped
5ff7f6d58f
- demo/app: working example app with fake data; every sidebar page works
- Sidebar slides in from a menu button on narrow screens, with backdrop
- New bf-table-wrap, bf-code and bf-empty helpers
- bifrost.js exposes Bifrost.setTheme/getTheme/icons
- Starter page and gallery use the menu button; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb79iV8pRuqaau9gW3qKNr
Jriyuu scheduled this pull request to auto merge when all checks succeed 2026-11-01 16:40:32 +11:00
Add shared lint and PR review checks from template-default
All checks were successful
checks / lint (pull_request) Successful in 8s
pr-review / ai-review (pull_request) Successful in 13m43s
pr-review / skill-scan (pull_request) Successful in 4s
release / check (pull_request) Successful in 4s
release / move-major-tag (pull_request) Has been skipped
release / demo-image (pull_request) Has been skipped
release / check (push) Successful in 4s
release / move-major-tag (push) Successful in 4s
release / demo-image (push) Successful in 36s
f1f1b4ff69

🤖 AI review for f1f1b4ff

Advisory only - this never blocks the merge. You decide.

Review output
[ocr] 12 file(s) changed, reviewing 9 in /workspace/jriyuu-labs/bifrost-ui
[ocr] Skipping CHANGELOG.md — filtered by path/extension rules
[ocr] Skipping README.md — filtered by path/extension rules
[ocr] Skipping ui/README.md — filtered by path/extension rules
[ocr] Filtered 3 file(s) by include/exclude rules
[ocr] Skipping plan phase for group "src/bifrost.css,ui/bifrost.css,ui/bifrost.js,ui/starter.html" (4 file(s), max 39 lines, total 96 lines)
[ocr] Skipping plan phase for group "VERSION" (1 file(s), max 2 lines, total 2 lines)
[ocr]   ▶ code_search search_text=1.0.0
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ code_search search_text=version
[ocr]   ✔ code_search (14ms)
[ocr]   ▶ file_read file_path=CHANGELOG.md
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read end_line=60 file_path=README.md start_line=30
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_find query_name=bifrost
[ocr]   ✔ file_find (5ms)
[ocr]   ▶ file_read file_path=src/bifrost.css start_line=1 end_line=110
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ file_find query_name=tokens
[ocr]   ✔ file_find (4ms)
[ocr]   ▶ file_read file_path=ui/tokens.css
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=ui/starter.html
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ code_search search_text=v1\.0|1\.0\.0|v1\.1|ui@\d|bifrost-ui@ use_perl_regexp=true
[ocr]   ✔ code_search (19ms)
[ocr]   ▶ file_find query_name=VERSION
[ocr]   ✔ file_find (4ms)
[ocr] Plan completed for group ".forgejo/workflows/checks.yml,.forgejo/workflows/pr-review.yml"
[ocr]   ▶ code_search file_patterns=[*.py *.md *.json] search_text=bifrost.css
[ocr]   ✔ code_search (11ms)
[ocr]   ▶ code_search search_text=data-bifrost-nav-toggle
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ file_read file_path=ui/bifrost.js
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ file_read_diff path_array=[demo/index.html CHANGELOG.md]
[ocr]   ✔ file_read_diff (0s)
[ocr]   ▶ code_search file_patterns=[.gitignore ui/README.md] search_text=VERSION
[ocr]   ✔ code_search (11ms)
[ocr]   ▶ code_search search_text=version
[ocr]   ✔ code_search (9ms)
[ocr]   ▶ code_search search_text=1\.0\.0 use_perl_regexp=true
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ code_search file_patterns=[README.md ui/README.md CHANGELOG.md] search_text=browser|ES5|legacy|old
[ocr]   ✔ code_search (11ms)
[ocr]   ▶ code_search search_text=setTheme|getTheme|Bifrost\.
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ file_read file_path=.forgejo/workflows/checks.yml
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=.forgejo/workflows/pr-review.yml
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ code_search file_patterns=[ui/README.md] search_text=JavaScript|js
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ file_read file_path=ui/README.md
[ocr]   ✔ file_read (4ms)
[ocr] Round 1/2 added no new findings for group "VERSION"; stopping early
[ocr]   ▶ code_comment
[ocr]   ✔ code_comment (0s)
[ocr] Plan completed for group "demo/app/index.html,demo/index.html"
[ocr]   ▶ code_search
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ code_search
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ file_find query_name=action.yml
[ocr]   ✔ file_find (3ms)
[ocr]   ▶ code_search search_text=lint-all|skillspector|open-code-review
[ocr]   ✔ code_search (15ms)
[ocr]   ▶ file_read file_path=.forgejo/workflows/checks.yml
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=.forgejo/workflows/pr-review.yml
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ file_read end_line=133 file_path=src/bifrost.css start_line=106
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ code_search file_patterns=[*test* *spec*] search_text=.
[ocr]   ✔ code_search (11ms)
[ocr] Round 2/2 added no new findings for group ".forgejo/workflows/checks.yml,.forgejo/workflows/pr-review.yml"; stopping early
[ocr]   ▶ code_search search_text=data-bifrost-nav-toggle file_patterns=[ui/]
[ocr]   ✔ code_search (14ms)
[ocr]   ▶ code_search file_patterns=[*.css] search_text=bf-menu-btn
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ file_find query_name=fonts.css
[ocr]   ✔ file_find (4ms)
[ocr]   ▶ file_find query_name=tokens.css
[ocr]   ✔ file_find (3ms)
[ocr]   ▶ file_find query_name=lucide
[ocr]   ✔ file_find (4ms)
[ocr]   ▶ code_search search_text=getTheme|setTheme|icons use_perl_regexp=true file_patterns=[ui/bifrost.js]
[ocr]   ✔ code_search (9ms)
[ocr]   ▶ code_search use_perl_regexp=true file_patterns=[ui/bifrost.css]
[ocr]   ✔ code_search (9ms)
[ocr]   ▶ file_find query_name=Dockerfile
[ocr]   ✔ file_find (4ms)
[ocr]   ▶ file_read end_line=60 file_path=ui/bifrost.js start_line=25
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=ui/bifrost.css start_line=113 end_line=131
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=docker/Dockerfile start_line=1 end_line=80
[ocr]   ✔ file_read (3ms)
[ocr]   ▶ code_comment
[ocr]   ✔ code_comment (0s)
[ocr]   ▶ code_comment
[ocr]   ✔ code_comment (1ms)
[ocr]   ▶ file_find query_name=bifrost
[ocr]   ✔ file_find (4ms)
[ocr]   ▶ file_read file_path=src/bifrost.css start_line=1 end_line=135
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ file_read file_path=ui/starter.html start_line=1 end_line=80
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_find query_name=tokens
[ocr]   ✔ file_find (5ms)
[ocr]   ▶ file_find query_name=README
[ocr]   ✔ file_find (5ms)
[ocr]   ▶ file_read file_path=ui/bifrost.js start_line=1 end_line=60
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=src/tokens.json start_line=1 end_line=80
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read file_path=ui/README.md start_line=1 end_line=100
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ code_search use_perl_regexp=true file_paths=["ui/tokens.css"]
[ocr]   ✔ code_search (16ms)
[ocr]   ▶ code_search file_paths=["src/tokens.json"] search_text=space-8|topbar-height|sidebar-width|shadow-pop use_perl_regexp=true
[ocr]   ✔ code_search (16ms)
[ocr]   ▶ code_search search_text=bifrost\.js|bifrost\.css use_perl_regexp=true
[ocr]   ✔ code_search (16ms)
[ocr]   ▶ file_read_diff path_array=[demo/index.html]
[ocr]   ✔ file_read_diff (0s)
[ocr]   ▶ file_read file_path=scripts/build.py start_line=1 end_line=90
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ file_read file_path=README.md start_line=1 end_line=60
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ file_read_diff path_array=[ui/bifrost.js ui/bifrost.css ui/starter.html]
[ocr]   ✔ file_read_diff (0s)
[ocr]   ▶ code_search search_text=icons\s*[:=(]|getTheme|setTheme|nav-toggle file_patterns=[ui/bifrost.js]
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ code_search file_paths=["demo/app/index.html", "demo/index.html"] search_text=Bifrost\. use_perl_regexp=true
[ocr]   ✔ code_search (16ms)
[ocr]   ▶ code_search file_paths=["demo/app/index.html"] use_perl_regexp=true
[ocr]   ✔ code_search (17ms)
[ocr]   ▶ code_search file_patterns=[ui/tokens.css] search_text=--topbar-height|--sidebar-width
[ocr]   ✔ code_search (9ms)
[ocr]   ▶ code_search file_patterns=[ui/bifrost.css] search_text=\.bf-main|\.bf-shell
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ file_find query_name=tokens.css
[ocr]   ✔ file_find (5ms)
[ocr]   ▶ file_find query_name=bifrost.css
[ocr]   ✔ file_find (4ms)
[ocr]   ▶ code_search file_patterns=[ui/tokens.css] search_text=topbar
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ code_search search_text=bf-main file_patterns=[ui/bifrost.css]
[ocr]   ✔ code_search (9ms)
[ocr]   ▶ file_read end_line=110 file_path=ui/bifrost.css start_line=70
[ocr]   ✔ file_read (5ms)
[ocr]   ▶ code_search file_patterns=[ui/tokens.css] search_text=sidebar-width
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ code_search file_patterns=[ui/bifrost.css]
[ocr]   ✔ code_search (10ms)
[ocr]   ▶ code_search file_patterns=[ui/bifrost.css] use_perl_regexp=true
[ocr]   ✔ code_search (11ms)
[ocr]   ▶ file_read end_line=40 file_path=ui/bifrost.css start_line=18
[ocr]   ✔ file_read (6ms)
[ocr]   ▶ file_read start_line=104 end_line=133 file_path=ui/bifrost.css
[ocr]   ✔ file_read (4ms)
[ocr]   ▶ code_comment
[ocr]   ✔ code_comment (8.279s)
[ocr]   ▶ code_comment
[ocr]   ✔ code_comment (0s)
[ocr] Summary: 9 file(s) reviewed, 18 comment(s), ~1326758 token(s) used (input: ~1161861, output: ~164897), cache(read: ~646336, write: ~0), 13m31s elapsed
[ocr] Session: a4e6c9c3-f7e3-4fae-81ad-4704a5d3d458
Review complete: 18 finding(s) across 9 selected item(s).

─── .forgejo/workflows/checks.yml:35-35 ───
[style · low] This file ends with a whitespace-only line and no trailing newline at EOF. Standard
conventions (and YAML linters such as yamllint's `trailing-spaces` and `new-line-at-end-of-file`
rules) flag both issues — notably awkward for a lint-workflow template file. Remove the trailing
blank/spaces and end the file with a single newline.

          #   ruff-format-check: "true"


─── .forgejo/workflows/pr-review.yml:51-51 ───
[style · low] This file ends with a whitespace-only line and no trailing newline at EOF. YAML
linters (e.g. yamllint's `trailing-spaces` / `new-line-at-end-of-file` rules) flag both issues.
Remove the trailing blank/spaces and end the file with a single newline.

            llm-model: ${{ vars.CI_SKILLSCAN_MODEL || vars.CI_REVIEW_MODEL }}


─── src/bifrost.css:108-109 ───
[other · low] min-width: 560px applies on every viewport, so a wrapped table with only a few short
columns is forced wider than a phone screen (320–480px) and gains a horizontal scrollbar even though
it would fit naturally. If that's not intended, scope the min-width to the narrow breakpoint (or
lower the value) so small tables can shrink, while wide tables still scroll inside the card.

  .bf-table-wrap { overflow-x: auto; }
+ @media (max-width: 800px) {
- .bf-table-wrap .bf-table { min-width: 560px; }
+   .bf-table-wrap .bf-table { min-width: 560px; }
+ }


─── src/bifrost.css:122-122 ───
[other · low] When closed, the sidebar is only moved off-screen via translateX(-100%): its links
remain in the tab order and the accessibility tree, so keyboard users tab through the invisible nav
links before reaching the page content. Hide it properly while closed — visibility: hidden with a
delayed transition (or the inert attribute toggled from bifrost.js) — and restore it when
.bf-shell--nav-open is set.

-   .bf-shell--sidebar .bf-sidenav { position: fixed; top: var(--topbar-height); left: 0; bottom: 0; width: var(--sidebar-width); z-index: 20; overflow-y: auto; transform: translateX(-100%); transition: transform 0.2s ease; }
+   .bf-shell--sidebar .bf-sidenav { position: fixed; top: var(--topbar-height); left: 0; bottom: 0; width: var(--sidebar-width); z-index: 20; overflow-y: auto; visibility: hidden; transform: translateX(-100%); transition: transform 0.2s ease, visibility 0s 0.2s; }
+   .bf-shell--nav-open .bf-sidenav { transform: none; visibility: visible; transition: transform 0.2s ease; }


─── src/bifrost.css:124-124 ───
[maintainability · low] The backdrop color is hardcoded (rgba(0, 0, 0, 0.45)), which contradicts the
project's own rule in ui/README.md: "Never hard-code a colour... use var(--accent), var(--space-4)
and so on". Add a --scrim token to src/tokens.json (a softer value can then be used for the light
theme) and reference it here. Note: ui/bifrost.css is a build copy of this file, so the fix belongs
in src/.

-   .bf-shell--nav-open::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: rgba(0, 0, 0, 0.45); }
+   .bf-shell--nav-open::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: var(--scrim); }


─── ui/bifrost.js:36-38 ───
[style · low] New declarations use var, which the JavaScript standard for this codebase prohibits
(use const/let). The file's pre-existing code is ES5-style var, so there is a convention conflict:
either start new declarations with const/let per the standard, or, if ES5 is a hard requirement for
the target browsers, get the standard updated to match the file.

-     var navBtn = e.target.closest("[data-bifrost-nav-toggle]");
+     const navBtn = e.target.closest("[data-bifrost-nav-toggle]");
      if (navBtn) {
-       var shell = navBtn.closest(".bf-shell");
+       const shell = navBtn.closest(".bf-shell");


─── ui/bifrost.js:39-39 ───
[bug · low] bf-shell--nav-open is never cleared when the viewport crosses the 800px breakpoint: open
the nav in a narrow window, widen past 800px (the class becomes inert and the sidebar snaps back
into the grid), then narrow again — the sidebar suddenly reopens without user action. Watch the
breakpoint with matchMedia and call closeNav() when it stops matching.

-       if (shell) shell.classList.toggle("bf-shell--nav-open");
+   var mq = window.matchMedia("(max-width: 800px)");
+   var onMqChange = function (e) { if (!e.matches) closeNav(); };
+   if (mq.addEventListener) mq.addEventListener("change", onMqChange);
+   else if (mq.addListener) mq.addListener(onMqChange);


─── ui/bifrost.js:20-20 ───
[maintainability · low] setTheme is now a public API but accepts any string:
Bifrost.setTheme("purle") sets data-theme to an unknown value (visually falling back to the :root
dark tokens) and persists the invalid value to localStorage, where it survives reloads. Validate in
the public wrapper so only "dark" and "light" are applied and stored.

-   window.Bifrost.setTheme = apply;
+   window.Bifrost.setTheme = function (theme) {
+     if (theme === "dark" || theme === "light") apply(theme);
+   };


─── ui/starter.html:17-17 ───
[other · medium] The menu button both opens and closes the sidebar, but it keeps a static
aria-label="Open menu" and exposes no state: assistive-tech users hear "Open menu" even when tapping
it closes the menu. Add aria-expanded="false" (and aria-controls pointing at an id on the nav), then
have the data-bifrost-nav-toggle branch in bifrost.js flip aria-expanded and swap the label ("Close
menu") when open.

-       <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" data-bifrost-nav-toggle><i data-lucide="menu"></i></button>
+       <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" aria-expanded="false" data-bifrost-nav-toggle><i data-lucide="menu"></i></button>


─── demo/app/index.html:419-424 ───
[bug · low] The scheduled restart/start callback isn't cancelled or re-validated: clicking Remove
(after confirm) or "Reset demo data" during the 1500 ms window leaves this setTimeout running, so
the stale callback mutates a service object that is no longer in state and log()s "Restarted <name>"
into the activity list (and after a Reset, into the freshly recreated state). Double-clicking
Restart before the rerender can also stack duplicate timers/logs. Since state is fake this is
cosmetic, but it visibly corrupts the demo (a phantom "Restarted" entry for a removed service).
Guard the callback with a membership check — note Reset's freshState() creates brand-new objects, so
indexOf is a safe discriminator for both cases — and ideally also clearTimeout the pending timer in
the remove/reset handlers.

          rerender();
          setTimeout(function () {
+           if (state.services.indexOf(svc) < 0) return; // removed or reset in the meantime
            setStatus(svc, "running", label, "ok");
            toast("ok", label, svc.name + " is running again.");
            rerender();
          }, 1500);


─── demo/app/index.html:516-517 ───
[bug · low] document.activeElement can be null (it returns null when no element has focus, which
actually happens when the browser window loses focus or during unload), and calling .matches() on
null throws a TypeError inside this interval — killing all future uptime-ticking re-renders with an
uncaught exception until reload. Add a null check before the matches call.

-       var onList = /^#\/(dashboard|services)(\/|$)/.test(location.hash || "#/dashboard");
-       if (onList && !document.querySelector("form") && !document.activeElement.matches("input")) rerender();
+       var active = document.activeElement;
+       if (onList && !document.querySelector("form") && !(active && active.matches("input"))) rerender();


─── demo/app/index.html:229-230 ───
[bug · low] These are filter links, not real tabs: role="tablist"/"tab" is announced without any
tabpanel, has no arrow-key interactivity, and uses aria-current="page" (the link state) instead of
aria-selected, so assistive technology reports a tab model that doesn't exist. The .bf-subtabs
styling in ui/bifrost.css only keys off aria-current="page", so the simplest fix is to drop both
roles and leave plain links.

-         '<div class="sw-toolbar"><div class="bf-subtabs" role="tablist">' + tabs.map(function (t) {
-           return '<a href="#" role="tab" data-filter="' + t[0] + '"' + (filter.status === t[0] ? ' aria-current="page"' : "") + ">" + t[1] + "</a>";
+         '<div class="sw-toolbar"><div class="bf-subtabs">' + tabs.map(function (t) {
+           return '<a href="#" data-filter="' + t[0] + '"' + (filter.status === t[0] ? ' aria-current="page"' : "") + ">" + t[1] + "</a>";


─── demo/app/index.html:72-72 ───
[other · low] tabindex="-1" is the hook for SPA focus management, but route() never calls
main.focus(), so after every hash navigation keyboard users are left wherever focus was (often
dropped back to <body>), losing their position. Either wire it up — main.focus({ preventScroll: true
}) after the render, when focus is already outside main, taking care not to steal focus while the
user is typing in the top-bar global search (which also triggers hashchange) — or delete the
misleading attribute.

      <main class="bf-main" id="main" tabindex="-1"></main>
+     // In route(), after main.scrollTop = 0:
+     // if (nav && !main.contains(document.activeElement)) main.focus({ preventScroll: true });


─── demo/app/index.html:484-486 ───
[bug · low] Name validation only checks format and uniqueness, but "new" and "attention" are virtual
routes consumed by pages.services before the service lookup (`if (arg === "new") return
pages.newService();` / `arg === "attention"` sets the filter). A service created with these names
can never be opened: #/services/attention shows the filtered list, and #/services/new re-shows the
add form. For "new" it's worse — after submit, `location.hash = "#/services/" + name` produces the
same hash the form is already on, so no hashchange fires and the user stays on the (now stale) form
thinking navigation failed. Reject reserved ids here.

- var ok = setError(form, "name", !name ? "Give the service a name." :
+         ok = setError(form, "name", !name ? "Give the service a name." :
            !/^[a-z0-9][a-z0-9_.-]*$/.test(name) ? "Use lowercase letters, numbers, dots, dashes or underscores." :
-           find(name) ? "There's already a service called " + name + "." : "");
+           find(name) ? "There's already a service called " + name + "." :
+           name === "new" || name === "attention" ? "That name is reserved by the Services menu." : "");


─── demo/app/index.html:369-370 ───
[bug · low] `pages[page]` walks the prototype chain: a hash like #/__proto__ resolves to
Object.prototype (truthy but not a function), throwing a TypeError inside the hashchange listener
and leaving the router stuck on the old view; #/constructor, #/valueOf, #/hasOwnProperty resolve to
inherited functions and render junk like "[object Object]". Restrict the lookup to the page's own
map.

-       var render = pages[page];
+       var render = Object.prototype.hasOwnProperty.call(pages, page) ? pages[page] : null;
        main.innerHTML = render ? render(arg) : notFound();


─── demo/app/index.html:49-49 ───
[other · low] The shared bifrost.js nav toggle only flips the `bf-shell--nav-open` class; it never
updates this button's state. The button is permanently labelled "Open menu" with no aria-expanded,
so screen-reader and keyboard users can't tell whether the drawer is open, and closing happens only
via outside-click/Escape without any announced change. Declare the initial state in markup and have
the toggle handler in ui/bifrost.js update it (same issue applies to the identical button markup
added in demo/index.html and ui/starter.html).

-       <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" data-bifrost-nav-toggle><i data-lucide="menu"></i></button>
+       <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" aria-expanded="false" aria-controls="nav" data-bifrost-nav-toggle><i data-lucide="menu"></i></button>


─── ui/bifrost.js:46-48 ───
[bug · medium] Closing the drawer with Escape slides it off-screen but doesn't touch focus: if the
user opened the drawer with the menu button and tabbed into its links, focus is left on a link that
is now invisible (36px-tall hit target at x = -width). Escape should restore focus to the menu
button (or a public close helper) so keyboard/AT users don't get stranded. This complements the a11y
work on the closed state's tab order, which is CSS-side only.

    document.addEventListener("keydown", function (e) {
-     if (e.key === "Escape") closeNav();
+     if (e.key !== "Escape") return;
+     // Put keyboard focus back on the menu button before the drawer slides away.
+     const openNav = document.querySelector(".bf-shell--nav-open .bf-sidenav");
+     if (openNav && openNav.contains(document.activeElement)) {
+       const toggle = document.querySelector(".bf-shell--nav-open [data-bifrost-nav-toggle]");
+       if (toggle) toggle.focus();
+     }
+     closeNav();
    });


─── src/bifrost.css:123-124 ───
[other · low] The scrim is scoped inside `.bf-shell--nav-open`, so it pops in fully opaque the frame
the class is added and is torn down in a single frame on close, while the drawer itself animates
over 0.2s — open/close look abrupt out of sync with the panel. Render the ::after on the shell in
both states and fade opacity instead, so the scrim animates with the drawer. Keep pointer-events:
none while closed so the underlying page stays clickable/selectable; when open, the pseudo is the
topmost hit target and clicks on it still resolve to the shell, so tap-outside-to-close keeps
working. Remember to also exempt this new transition in the prefers-reduced-motion block.

-   .bf-shell--nav-open .bf-sidenav { transform: none; box-shadow: var(--shadow-pop); }
-   .bf-shell--nav-open::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: rgba(0, 0, 0, 0.45); }
+   .bf-shell--sidebar::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: var(--scrim); opacity: 0; pointer-events: none; transition: opacity 0.2s ease; }
+   .bf-shell--nav-open::after { opacity: 1; pointer-events: auto; }

Open Code Review via ci-actions · model ci-reviewer · job log

<!-- ci-actions:open-code-review --> ### 🤖 AI review for `f1f1b4ff` Advisory only - this never blocks the merge. You decide. <details open><summary>Review output</summary> ```text [ocr] 12 file(s) changed, reviewing 9 in /workspace/jriyuu-labs/bifrost-ui [ocr] Skipping CHANGELOG.md — filtered by path/extension rules [ocr] Skipping README.md — filtered by path/extension rules [ocr] Skipping ui/README.md — filtered by path/extension rules [ocr] Filtered 3 file(s) by include/exclude rules [ocr] Skipping plan phase for group "src/bifrost.css,ui/bifrost.css,ui/bifrost.js,ui/starter.html" (4 file(s), max 39 lines, total 96 lines) [ocr] Skipping plan phase for group "VERSION" (1 file(s), max 2 lines, total 2 lines) [ocr] ▶ code_search search_text=1.0.0 [ocr] ✔ code_search (15ms) [ocr] ▶ code_search search_text=version [ocr] ✔ code_search (14ms) [ocr] ▶ file_read file_path=CHANGELOG.md [ocr] ✔ file_read (5ms) [ocr] ▶ file_read end_line=60 file_path=README.md start_line=30 [ocr] ✔ file_read (5ms) [ocr] ▶ file_find query_name=bifrost [ocr] ✔ file_find (5ms) [ocr] ▶ file_read file_path=src/bifrost.css start_line=1 end_line=110 [ocr] ✔ file_read (4ms) [ocr] ▶ file_find query_name=tokens [ocr] ✔ file_find (4ms) [ocr] ▶ file_read file_path=ui/tokens.css [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=ui/starter.html [ocr] ✔ file_read (5ms) [ocr] ▶ code_search search_text=v1\.0|1\.0\.0|v1\.1|ui@\d|bifrost-ui@ use_perl_regexp=true [ocr] ✔ code_search (19ms) [ocr] ▶ file_find query_name=VERSION [ocr] ✔ file_find (4ms) [ocr] Plan completed for group ".forgejo/workflows/checks.yml,.forgejo/workflows/pr-review.yml" [ocr] ▶ code_search file_patterns=[*.py *.md *.json] search_text=bifrost.css [ocr] ✔ code_search (11ms) [ocr] ▶ code_search search_text=data-bifrost-nav-toggle [ocr] ✔ code_search (15ms) [ocr] ▶ file_read file_path=ui/bifrost.js [ocr] ✔ file_read (4ms) [ocr] ▶ file_read_diff path_array=[demo/index.html CHANGELOG.md] [ocr] ✔ file_read_diff (0s) [ocr] ▶ code_search file_patterns=[.gitignore ui/README.md] search_text=VERSION [ocr] ✔ code_search (11ms) [ocr] ▶ code_search search_text=version [ocr] ✔ code_search (9ms) [ocr] ▶ code_search search_text=1\.0\.0 use_perl_regexp=true [ocr] ✔ code_search (15ms) [ocr] ▶ code_search file_patterns=[README.md ui/README.md CHANGELOG.md] search_text=browser|ES5|legacy|old [ocr] ✔ code_search (11ms) [ocr] ▶ code_search search_text=setTheme|getTheme|Bifrost\. [ocr] ✔ code_search (15ms) [ocr] ▶ file_read file_path=.forgejo/workflows/checks.yml [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=.forgejo/workflows/pr-review.yml [ocr] ✔ file_read (5ms) [ocr] ▶ code_search file_patterns=[ui/README.md] search_text=JavaScript|js [ocr] ✔ code_search (10ms) [ocr] ▶ file_read file_path=ui/README.md [ocr] ✔ file_read (4ms) [ocr] Round 1/2 added no new findings for group "VERSION"; stopping early [ocr] ▶ code_comment [ocr] ✔ code_comment (0s) [ocr] Plan completed for group "demo/app/index.html,demo/index.html" [ocr] ▶ code_search [ocr] ✔ code_search (15ms) [ocr] ▶ code_search [ocr] ✔ code_search (15ms) [ocr] ▶ file_find query_name=action.yml [ocr] ✔ file_find (3ms) [ocr] ▶ code_search search_text=lint-all|skillspector|open-code-review [ocr] ✔ code_search (15ms) [ocr] ▶ file_read file_path=.forgejo/workflows/checks.yml [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=.forgejo/workflows/pr-review.yml [ocr] ✔ file_read (4ms) [ocr] ▶ file_read end_line=133 file_path=src/bifrost.css start_line=106 [ocr] ✔ file_read (5ms) [ocr] ▶ code_search file_patterns=[*test* *spec*] search_text=. [ocr] ✔ code_search (11ms) [ocr] Round 2/2 added no new findings for group ".forgejo/workflows/checks.yml,.forgejo/workflows/pr-review.yml"; stopping early [ocr] ▶ code_search search_text=data-bifrost-nav-toggle file_patterns=[ui/] [ocr] ✔ code_search (14ms) [ocr] ▶ code_search file_patterns=[*.css] search_text=bf-menu-btn [ocr] ✔ code_search (10ms) [ocr] ▶ file_find query_name=fonts.css [ocr] ✔ file_find (4ms) [ocr] ▶ file_find query_name=tokens.css [ocr] ✔ file_find (3ms) [ocr] ▶ file_find query_name=lucide [ocr] ✔ file_find (4ms) [ocr] ▶ code_search search_text=getTheme|setTheme|icons use_perl_regexp=true file_patterns=[ui/bifrost.js] [ocr] ✔ code_search (9ms) [ocr] ▶ code_search use_perl_regexp=true file_patterns=[ui/bifrost.css] [ocr] ✔ code_search (9ms) [ocr] ▶ file_find query_name=Dockerfile [ocr] ✔ file_find (4ms) [ocr] ▶ file_read end_line=60 file_path=ui/bifrost.js start_line=25 [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=ui/bifrost.css start_line=113 end_line=131 [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=docker/Dockerfile start_line=1 end_line=80 [ocr] ✔ file_read (3ms) [ocr] ▶ code_comment [ocr] ✔ code_comment (0s) [ocr] ▶ code_comment [ocr] ✔ code_comment (1ms) [ocr] ▶ file_find query_name=bifrost [ocr] ✔ file_find (4ms) [ocr] ▶ file_read file_path=src/bifrost.css start_line=1 end_line=135 [ocr] ✔ file_read (4ms) [ocr] ▶ file_read file_path=ui/starter.html start_line=1 end_line=80 [ocr] ✔ file_read (5ms) [ocr] ▶ file_find query_name=tokens [ocr] ✔ file_find (5ms) [ocr] ▶ file_find query_name=README [ocr] ✔ file_find (5ms) [ocr] ▶ file_read file_path=ui/bifrost.js start_line=1 end_line=60 [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=src/tokens.json start_line=1 end_line=80 [ocr] ✔ file_read (5ms) [ocr] ▶ file_read file_path=ui/README.md start_line=1 end_line=100 [ocr] ✔ file_read (4ms) [ocr] ▶ code_search use_perl_regexp=true file_paths=["ui/tokens.css"] [ocr] ✔ code_search (16ms) [ocr] ▶ code_search file_paths=["src/tokens.json"] search_text=space-8|topbar-height|sidebar-width|shadow-pop use_perl_regexp=true [ocr] ✔ code_search (16ms) [ocr] ▶ code_search search_text=bifrost\.js|bifrost\.css use_perl_regexp=true [ocr] ✔ code_search (16ms) [ocr] ▶ file_read_diff path_array=[demo/index.html] [ocr] ✔ file_read_diff (0s) [ocr] ▶ file_read file_path=scripts/build.py start_line=1 end_line=90 [ocr] ✔ file_read (4ms) [ocr] ▶ file_read file_path=README.md start_line=1 end_line=60 [ocr] ✔ file_read (5ms) [ocr] ▶ file_read_diff path_array=[ui/bifrost.js ui/bifrost.css ui/starter.html] [ocr] ✔ file_read_diff (0s) [ocr] ▶ code_search search_text=icons\s*[:=(]|getTheme|setTheme|nav-toggle file_patterns=[ui/bifrost.js] [ocr] ✔ code_search (10ms) [ocr] ▶ code_search file_paths=["demo/app/index.html", "demo/index.html"] search_text=Bifrost\. use_perl_regexp=true [ocr] ✔ code_search (16ms) [ocr] ▶ code_search file_paths=["demo/app/index.html"] use_perl_regexp=true [ocr] ✔ code_search (17ms) [ocr] ▶ code_search file_patterns=[ui/tokens.css] search_text=--topbar-height|--sidebar-width [ocr] ✔ code_search (9ms) [ocr] ▶ code_search file_patterns=[ui/bifrost.css] search_text=\.bf-main|\.bf-shell [ocr] ✔ code_search (10ms) [ocr] ▶ file_find query_name=tokens.css [ocr] ✔ file_find (5ms) [ocr] ▶ file_find query_name=bifrost.css [ocr] ✔ file_find (4ms) [ocr] ▶ code_search file_patterns=[ui/tokens.css] search_text=topbar [ocr] ✔ code_search (10ms) [ocr] ▶ code_search search_text=bf-main file_patterns=[ui/bifrost.css] [ocr] ✔ code_search (9ms) [ocr] ▶ file_read end_line=110 file_path=ui/bifrost.css start_line=70 [ocr] ✔ file_read (5ms) [ocr] ▶ code_search file_patterns=[ui/tokens.css] search_text=sidebar-width [ocr] ✔ code_search (10ms) [ocr] ▶ code_search file_patterns=[ui/bifrost.css] [ocr] ✔ code_search (10ms) [ocr] ▶ code_search file_patterns=[ui/bifrost.css] use_perl_regexp=true [ocr] ✔ code_search (11ms) [ocr] ▶ file_read end_line=40 file_path=ui/bifrost.css start_line=18 [ocr] ✔ file_read (6ms) [ocr] ▶ file_read start_line=104 end_line=133 file_path=ui/bifrost.css [ocr] ✔ file_read (4ms) [ocr] ▶ code_comment [ocr] ✔ code_comment (8.279s) [ocr] ▶ code_comment [ocr] ✔ code_comment (0s) [ocr] Summary: 9 file(s) reviewed, 18 comment(s), ~1326758 token(s) used (input: ~1161861, output: ~164897), cache(read: ~646336, write: ~0), 13m31s elapsed [ocr] Session: a4e6c9c3-f7e3-4fae-81ad-4704a5d3d458 Review complete: 18 finding(s) across 9 selected item(s). ─── .forgejo/workflows/checks.yml:35-35 ─── [style · low] This file ends with a whitespace-only line and no trailing newline at EOF. Standard conventions (and YAML linters such as yamllint's `trailing-spaces` and `new-line-at-end-of-file` rules) flag both issues — notably awkward for a lint-workflow template file. Remove the trailing blank/spaces and end the file with a single newline. # ruff-format-check: "true" ─── .forgejo/workflows/pr-review.yml:51-51 ─── [style · low] This file ends with a whitespace-only line and no trailing newline at EOF. YAML linters (e.g. yamllint's `trailing-spaces` / `new-line-at-end-of-file` rules) flag both issues. Remove the trailing blank/spaces and end the file with a single newline. llm-model: ${{ vars.CI_SKILLSCAN_MODEL || vars.CI_REVIEW_MODEL }} ─── src/bifrost.css:108-109 ─── [other · low] min-width: 560px applies on every viewport, so a wrapped table with only a few short columns is forced wider than a phone screen (320–480px) and gains a horizontal scrollbar even though it would fit naturally. If that's not intended, scope the min-width to the narrow breakpoint (or lower the value) so small tables can shrink, while wide tables still scroll inside the card. .bf-table-wrap { overflow-x: auto; } + @media (max-width: 800px) { - .bf-table-wrap .bf-table { min-width: 560px; } + .bf-table-wrap .bf-table { min-width: 560px; } + } ─── src/bifrost.css:122-122 ─── [other · low] When closed, the sidebar is only moved off-screen via translateX(-100%): its links remain in the tab order and the accessibility tree, so keyboard users tab through the invisible nav links before reaching the page content. Hide it properly while closed — visibility: hidden with a delayed transition (or the inert attribute toggled from bifrost.js) — and restore it when .bf-shell--nav-open is set. - .bf-shell--sidebar .bf-sidenav { position: fixed; top: var(--topbar-height); left: 0; bottom: 0; width: var(--sidebar-width); z-index: 20; overflow-y: auto; transform: translateX(-100%); transition: transform 0.2s ease; } + .bf-shell--sidebar .bf-sidenav { position: fixed; top: var(--topbar-height); left: 0; bottom: 0; width: var(--sidebar-width); z-index: 20; overflow-y: auto; visibility: hidden; transform: translateX(-100%); transition: transform 0.2s ease, visibility 0s 0.2s; } + .bf-shell--nav-open .bf-sidenav { transform: none; visibility: visible; transition: transform 0.2s ease; } ─── src/bifrost.css:124-124 ─── [maintainability · low] The backdrop color is hardcoded (rgba(0, 0, 0, 0.45)), which contradicts the project's own rule in ui/README.md: "Never hard-code a colour... use var(--accent), var(--space-4) and so on". Add a --scrim token to src/tokens.json (a softer value can then be used for the light theme) and reference it here. Note: ui/bifrost.css is a build copy of this file, so the fix belongs in src/. - .bf-shell--nav-open::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: rgba(0, 0, 0, 0.45); } + .bf-shell--nav-open::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: var(--scrim); } ─── ui/bifrost.js:36-38 ─── [style · low] New declarations use var, which the JavaScript standard for this codebase prohibits (use const/let). The file's pre-existing code is ES5-style var, so there is a convention conflict: either start new declarations with const/let per the standard, or, if ES5 is a hard requirement for the target browsers, get the standard updated to match the file. - var navBtn = e.target.closest("[data-bifrost-nav-toggle]"); + const navBtn = e.target.closest("[data-bifrost-nav-toggle]"); if (navBtn) { - var shell = navBtn.closest(".bf-shell"); + const shell = navBtn.closest(".bf-shell"); ─── ui/bifrost.js:39-39 ─── [bug · low] bf-shell--nav-open is never cleared when the viewport crosses the 800px breakpoint: open the nav in a narrow window, widen past 800px (the class becomes inert and the sidebar snaps back into the grid), then narrow again — the sidebar suddenly reopens without user action. Watch the breakpoint with matchMedia and call closeNav() when it stops matching. - if (shell) shell.classList.toggle("bf-shell--nav-open"); + var mq = window.matchMedia("(max-width: 800px)"); + var onMqChange = function (e) { if (!e.matches) closeNav(); }; + if (mq.addEventListener) mq.addEventListener("change", onMqChange); + else if (mq.addListener) mq.addListener(onMqChange); ─── ui/bifrost.js:20-20 ─── [maintainability · low] setTheme is now a public API but accepts any string: Bifrost.setTheme("purle") sets data-theme to an unknown value (visually falling back to the :root dark tokens) and persists the invalid value to localStorage, where it survives reloads. Validate in the public wrapper so only "dark" and "light" are applied and stored. - window.Bifrost.setTheme = apply; + window.Bifrost.setTheme = function (theme) { + if (theme === "dark" || theme === "light") apply(theme); + }; ─── ui/starter.html:17-17 ─── [other · medium] The menu button both opens and closes the sidebar, but it keeps a static aria-label="Open menu" and exposes no state: assistive-tech users hear "Open menu" even when tapping it closes the menu. Add aria-expanded="false" (and aria-controls pointing at an id on the nav), then have the data-bifrost-nav-toggle branch in bifrost.js flip aria-expanded and swap the label ("Close menu") when open. - <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" data-bifrost-nav-toggle><i data-lucide="menu"></i></button> + <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" aria-expanded="false" data-bifrost-nav-toggle><i data-lucide="menu"></i></button> ─── demo/app/index.html:419-424 ─── [bug · low] The scheduled restart/start callback isn't cancelled or re-validated: clicking Remove (after confirm) or "Reset demo data" during the 1500 ms window leaves this setTimeout running, so the stale callback mutates a service object that is no longer in state and log()s "Restarted <name>" into the activity list (and after a Reset, into the freshly recreated state). Double-clicking Restart before the rerender can also stack duplicate timers/logs. Since state is fake this is cosmetic, but it visibly corrupts the demo (a phantom "Restarted" entry for a removed service). Guard the callback with a membership check — note Reset's freshState() creates brand-new objects, so indexOf is a safe discriminator for both cases — and ideally also clearTimeout the pending timer in the remove/reset handlers. rerender(); setTimeout(function () { + if (state.services.indexOf(svc) < 0) return; // removed or reset in the meantime setStatus(svc, "running", label, "ok"); toast("ok", label, svc.name + " is running again."); rerender(); }, 1500); ─── demo/app/index.html:516-517 ─── [bug · low] document.activeElement can be null (it returns null when no element has focus, which actually happens when the browser window loses focus or during unload), and calling .matches() on null throws a TypeError inside this interval — killing all future uptime-ticking re-renders with an uncaught exception until reload. Add a null check before the matches call. - var onList = /^#\/(dashboard|services)(\/|$)/.test(location.hash || "#/dashboard"); - if (onList && !document.querySelector("form") && !document.activeElement.matches("input")) rerender(); + var active = document.activeElement; + if (onList && !document.querySelector("form") && !(active && active.matches("input"))) rerender(); ─── demo/app/index.html:229-230 ─── [bug · low] These are filter links, not real tabs: role="tablist"/"tab" is announced without any tabpanel, has no arrow-key interactivity, and uses aria-current="page" (the link state) instead of aria-selected, so assistive technology reports a tab model that doesn't exist. The .bf-subtabs styling in ui/bifrost.css only keys off aria-current="page", so the simplest fix is to drop both roles and leave plain links. - '<div class="sw-toolbar"><div class="bf-subtabs" role="tablist">' + tabs.map(function (t) { - return '<a href="#" role="tab" data-filter="' + t[0] + '"' + (filter.status === t[0] ? ' aria-current="page"' : "") + ">" + t[1] + "</a>"; + '<div class="sw-toolbar"><div class="bf-subtabs">' + tabs.map(function (t) { + return '<a href="#" data-filter="' + t[0] + '"' + (filter.status === t[0] ? ' aria-current="page"' : "") + ">" + t[1] + "</a>"; ─── demo/app/index.html:72-72 ─── [other · low] tabindex="-1" is the hook for SPA focus management, but route() never calls main.focus(), so after every hash navigation keyboard users are left wherever focus was (often dropped back to <body>), losing their position. Either wire it up — main.focus({ preventScroll: true }) after the render, when focus is already outside main, taking care not to steal focus while the user is typing in the top-bar global search (which also triggers hashchange) — or delete the misleading attribute. <main class="bf-main" id="main" tabindex="-1"></main> + // In route(), after main.scrollTop = 0: + // if (nav && !main.contains(document.activeElement)) main.focus({ preventScroll: true }); ─── demo/app/index.html:484-486 ─── [bug · low] Name validation only checks format and uniqueness, but "new" and "attention" are virtual routes consumed by pages.services before the service lookup (`if (arg === "new") return pages.newService();` / `arg === "attention"` sets the filter). A service created with these names can never be opened: #/services/attention shows the filtered list, and #/services/new re-shows the add form. For "new" it's worse — after submit, `location.hash = "#/services/" + name` produces the same hash the form is already on, so no hashchange fires and the user stays on the (now stale) form thinking navigation failed. Reject reserved ids here. - var ok = setError(form, "name", !name ? "Give the service a name." : + ok = setError(form, "name", !name ? "Give the service a name." : !/^[a-z0-9][a-z0-9_.-]*$/.test(name) ? "Use lowercase letters, numbers, dots, dashes or underscores." : - find(name) ? "There's already a service called " + name + "." : ""); + find(name) ? "There's already a service called " + name + "." : + name === "new" || name === "attention" ? "That name is reserved by the Services menu." : ""); ─── demo/app/index.html:369-370 ─── [bug · low] `pages[page]` walks the prototype chain: a hash like #/__proto__ resolves to Object.prototype (truthy but not a function), throwing a TypeError inside the hashchange listener and leaving the router stuck on the old view; #/constructor, #/valueOf, #/hasOwnProperty resolve to inherited functions and render junk like "[object Object]". Restrict the lookup to the page's own map. - var render = pages[page]; + var render = Object.prototype.hasOwnProperty.call(pages, page) ? pages[page] : null; main.innerHTML = render ? render(arg) : notFound(); ─── demo/app/index.html:49-49 ─── [other · low] The shared bifrost.js nav toggle only flips the `bf-shell--nav-open` class; it never updates this button's state. The button is permanently labelled "Open menu" with no aria-expanded, so screen-reader and keyboard users can't tell whether the drawer is open, and closing happens only via outside-click/Escape without any announced change. Declare the initial state in markup and have the toggle handler in ui/bifrost.js update it (same issue applies to the identical button markup added in demo/index.html and ui/starter.html). - <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" data-bifrost-nav-toggle><i data-lucide="menu"></i></button> + <button class="bf-btn bf-btn--ghost bf-btn--icon bf-menu-btn" aria-label="Open menu" aria-expanded="false" aria-controls="nav" data-bifrost-nav-toggle><i data-lucide="menu"></i></button> ─── ui/bifrost.js:46-48 ─── [bug · medium] Closing the drawer with Escape slides it off-screen but doesn't touch focus: if the user opened the drawer with the menu button and tabbed into its links, focus is left on a link that is now invisible (36px-tall hit target at x = -width). Escape should restore focus to the menu button (or a public close helper) so keyboard/AT users don't get stranded. This complements the a11y work on the closed state's tab order, which is CSS-side only. document.addEventListener("keydown", function (e) { - if (e.key === "Escape") closeNav(); + if (e.key !== "Escape") return; + // Put keyboard focus back on the menu button before the drawer slides away. + const openNav = document.querySelector(".bf-shell--nav-open .bf-sidenav"); + if (openNav && openNav.contains(document.activeElement)) { + const toggle = document.querySelector(".bf-shell--nav-open [data-bifrost-nav-toggle]"); + if (toggle) toggle.focus(); + } + closeNav(); }); ─── src/bifrost.css:123-124 ─── [other · low] The scrim is scoped inside `.bf-shell--nav-open`, so it pops in fully opaque the frame the class is added and is torn down in a single frame on close, while the drawer itself animates over 0.2s — open/close look abrupt out of sync with the panel. Render the ::after on the shell in both states and fade opacity instead, so the scrim animates with the drawer. Keep pointer-events: none while closed so the underlying page stays clickable/selectable; when open, the pseudo is the topmost hit target and clicks on it still resolve to the shell, so tap-outside-to-close keeps working. Remember to also exempt this new transition in the prefers-reduced-motion block. - .bf-shell--nav-open .bf-sidenav { transform: none; box-shadow: var(--shadow-pop); } - .bf-shell--nav-open::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: rgba(0, 0, 0, 0.45); } + .bf-shell--sidebar::after { content: ""; position: fixed; inset: var(--topbar-height) 0 0 0; z-index: 15; background: var(--scrim); opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } + .bf-shell--nav-open::after { opacity: 1; pointer-events: auto; } ``` </details> <sub>Open Code Review via ci-actions · model `ci-reviewer` · [job log](https://nidavellir.jriyuu.io/jriyuu-labs/bifrost-ui/actions/runs/7)</sub>
Jriyuu merged commit 4b1971bd23 into main 2026-11-01 16:58:52 +11:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jriyuu-labs/bifrost-ui!1
No description provided.