basis-design 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ import { useState } from "react";
2
+ import type { ClientMessage, VerifyKitResult } from "../../shared/protocol.ts";
3
+ import { BrowseButton } from "./FolderField.tsx";
4
+ import { readPref, writePref } from "./prefs.ts";
5
+ import styles from "./Shell.module.css";
6
+
7
+ export interface VerifyKitProps {
8
+ workspace: string;
9
+ /** The product repo the project designs for: where the kit belongs, unless the user chose a folder before. */
10
+ repo: string | undefined;
11
+ progress: { step: string; done?: number; total?: number } | null;
12
+ result: VerifyKitResult | null;
13
+ send: (message: ClientMessage) => void;
14
+ }
15
+
16
+ /** The product folder the kit was last written to, per project. */
17
+ const targetPref = (workspace: string) => `verify-target:${workspace}`;
18
+
19
+ function plural(count: number, one: string, many = `${one}s`): string {
20
+ return `${count} ${count === 1 ? one : many}`;
21
+ }
22
+
23
+ /**
24
+ * The verification kit tab of the Export dialog: builds the kit (a runnable copy of the design, screenshots of
25
+ * every view's screens and the scripts that check an implementation against them) as a zip or into the product's
26
+ * folder.
27
+ */
28
+ export function VerifyKit({ workspace, repo, progress, result: latest, send }: VerifyKitProps) {
29
+ const [target, setTarget] = useState(() => readPref(targetPref(workspace)) ?? repo ?? "");
30
+ const [browseError, setBrowseError] = useState<string | null>(null);
31
+ // Only show results of kits built while this dialog is open.
32
+ const [stale] = useState(latest);
33
+ const result = latest === stale ? null : latest;
34
+ const busy = progress !== null;
35
+
36
+ const build = (project?: string) => {
37
+ if (project !== undefined) writePref(targetPref(workspace), project);
38
+ send({ type: "verify-kit", project });
39
+ };
40
+
41
+ return (
42
+ <div className={styles.kit}>
43
+ <p className={styles.kitIntro}>
44
+ Proof that a feature was built as designed. The kit holds a runnable copy of the design, screenshots of every
45
+ view's screens at the widths the view asks for, and two scripts: one captures the implementation the same way,
46
+ the other compares the images pixel for pixel. Give it to the agent that builds the feature; the target is 0%
47
+ difference, and anything allowed to differ is a named exception.
48
+ </p>
49
+ <form
50
+ className={styles.kitWrite}
51
+ onSubmit={(event) => {
52
+ event.preventDefault();
53
+ build(target);
54
+ }}
55
+ >
56
+ <label htmlFor="verify-target">Write into the product's folder</label>
57
+ <div className={styles.kitWriteRow}>
58
+ <input
59
+ id="verify-target"
60
+ value={target}
61
+ onChange={(event) => setTarget(event.target.value)}
62
+ placeholder="Browse, or paste the folder's path"
63
+ spellCheck={false}
64
+ />
65
+ <BrowseButton
66
+ title="Choose the product's folder"
67
+ start={target}
68
+ onPick={setTarget}
69
+ onError={setBrowseError}
70
+ />
71
+ <button type="submit" className={styles.chromeButton} disabled={!target.trim() || busy}>
72
+ Write kit
73
+ </button>
74
+ </div>
75
+ <span className={styles.kitNote}>
76
+ The kit goes into <code>basis-verify/</code> there. Writing it again replaces what Basis generated and keeps
77
+ the project's targets, exceptions and captures.
78
+ </span>
79
+ {browseError && <p className={styles.kitError}>{browseError}</p>}
80
+ </form>
81
+ {progress && (
82
+ <p className={styles.kitNote} role="status">
83
+ {progress.step}
84
+ {progress.total ? ` ${progress.done ?? 0} of ${progress.total}` : ""}…
85
+ </p>
86
+ )}
87
+ {result && !result.ok && <p className={styles.kitError}>{result.error}</p>}
88
+ {result?.ok && <Summary result={result} />}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ function Summary({ result }: { result: Extract<VerifyKitResult, { ok: true }> }) {
94
+ const { summary, dir } = result;
95
+ if (summary.views === 0) {
96
+ return (
97
+ <p className={styles.kitWarning}>
98
+ There are no views to capture. Ask the agent for a view of a screen in the product, then build the kit.
99
+ </p>
100
+ );
101
+ }
102
+ return (
103
+ <>
104
+ <p className={styles.kitSuccess}>
105
+ Captured {plural(summary.shots, "shot")} of {plural(summary.screens, "screen")} in{" "}
106
+ {plural(summary.views, "view")} with {summary.browser}.{" "}
107
+ {dir ? (
108
+ <>
109
+ Wrote the kit to <code>{dir}</code>.
110
+ </>
111
+ ) : (
112
+ <a href="/export/verification-kit.zip" download>
113
+ Download the .zip
114
+ </a>
115
+ )}
116
+ </p>
117
+ {summary.failed.length > 0 && (
118
+ <p className={styles.kitWarning}>
119
+ {plural(summary.failed.length, "shot")} of screens that failed to render: {summary.failed.join(", ")}.
120
+ </p>
121
+ )}
122
+ {summary.unstable.length > 0 && (
123
+ <p className={styles.kitWarning}>
124
+ {plural(summary.unstable.length, "shot")} came out differently on a second capture, so nothing can match them
125
+ exactly: {summary.unstable.join(", ")}. Look for clocks, random values or animations in those screens.
126
+ </p>
127
+ )}
128
+ {summary.problems.length > 0 && (
129
+ <p className={styles.kitWarning}>Specimens that could not be loaded: {summary.problems.join("; ")}.</p>
130
+ )}
131
+ </>
132
+ );
133
+ }
@@ -0,0 +1,25 @@
1
+ # Starter template changes
2
+
3
+ What changed in Basis' starter components, patterns and views, by template version. Basis updates the files a
4
+ workspace never changed by itself; the agent brings these changes into the ones it did change, keeping the
5
+ design system's own look.
6
+
7
+ ## 1
8
+
9
+ - Reduced motion in every component and pattern: under `@media (prefers-reduced-motion: reduce)` nothing slides,
10
+ zooms, shimmers or loops (presses do not shrink, cards do not lift, the indeterminate progress bar and the
11
+ skeleton stand still, tooltips, toasts and dialogs only fade, the tab indicator and underlines jump). Spinners
12
+ keep turning, since they are the only sign that work goes on. See the `@media (prefers-reduced-motion: reduce)`
13
+ block at the end of each stylesheet.
14
+ - Field: the label is a `<label for>` above the control, then the hint, then the error, then the control. The hint
15
+ and the error are tied to the control with `aria-describedby`, the control gets `aria-invalid` from the Field's
16
+ error (through a React context, so TextInput, Textarea and Select need no extra props), and the error has an
17
+ icon as well as the danger colour.
18
+ - Navbar responds to its own width, as a size container: from 1024px everything sits in the bar; from 800px the
19
+ search becomes a button that opens the field below the bar, with focus in it; under 800px the links, the search
20
+ and the actions move into a menu behind a menu button. `compact` still forces the narrow layout, and the menu
21
+ keeps its own open state unless `menuOpen` is given.
22
+ - AppShell is a size container named `app-shell`; SidebarNav becomes its icon rail inside a narrow one (under
23
+ 640px). The rail's grid column is pinned so its icons stay centred.
24
+ - Small text that used `textSubtle` (SidebarNav section titles, StatCard hints) uses `textMuted`, since subtle is
25
+ held to 3:1 and small text needs 4.5:1.
@@ -23,9 +23,19 @@
23
23
  }
24
24
 
25
25
  .error {
26
+ display: flex;
27
+ align-items: flex-start;
28
+ gap: var(--space-1);
26
29
  color: var(--color-danger);
27
30
  }
28
31
 
32
+ .errorIcon {
33
+ flex: none;
34
+ width: 1.15em;
35
+ height: 1.15em;
36
+ margin-top: 0.1em;
37
+ }
38
+
29
39
  .input {
30
40
  width: 100%;
31
41
  min-width: 0;
@@ -31,6 +31,12 @@ export function Field({ label, hint, error, children }: FieldProps) {
31
31
  )}
32
32
  {error && (
33
33
  <span id={errorId} className={styles.error}>
34
+ {/* An icon as well as the colour, so the error does not rely on colour alone. */}
35
+ <svg className={styles.errorIcon} viewBox="0 0 16 16" aria-hidden>
36
+ <circle cx="8" cy="8" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
37
+ <path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
38
+ <circle cx="8" cy="11.1" r="0.9" fill="currentColor" />
39
+ </svg>
34
40
  {error}
35
41
  </span>
36
42
  )}
@@ -57,7 +57,30 @@ export type SpecimenBlock =
57
57
  * Whole screens of one flow, for views: the gallery shows one at a time, full width, with a strip to jump
58
58
  * between them. The first screen shows first; each moves on with `go(id)`.
59
59
  */
60
- | { kind: "flow"; title?: string; screens: { id: string; label: string; render: ComponentType<ScreenProps> }[] };
60
+ | {
61
+ kind: "flow";
62
+ title?: string;
63
+ screens: { id: string; label: string; render: ComponentType<ScreenProps> }[];
64
+ /** How the verification kit captures each screen; Basis' defaults when left out (see Capture). */
65
+ capture?: Capture;
66
+ };
67
+
68
+ /**
69
+ * The screenshots a view's screens are checked against during development. Every screen is captured at every
70
+ * width (and theme), and the implementation must match them pixel for pixel, so a screen must render the same
71
+ * every time: no clock, no randomness, no animation that does not settle.
72
+ */
73
+ export interface Capture {
74
+ /** Viewport widths in CSS pixels; Basis' default is [375, 768, 1440]. */
75
+ widths?: number[];
76
+ /**
77
+ * "full" (the default) captures the whole page, which must then also be as tall as the design. A number
78
+ * captures exactly that viewport height, for screens such as app shells that scroll inside.
79
+ */
80
+ height?: "full" | number;
81
+ /** Themes to capture; Basis' default is ["light"]. */
82
+ themes?: ("light" | "dark")[];
83
+ }
61
84
 
62
85
  export interface Specimen {
63
86
  /** Anchor and sidebar id; defaults to the title in kebab-case. */
@@ -0,0 +1,123 @@
1
+ {
2
+ "components/Button.module.css": [
3
+ "2d8464bdd6324a9283902a51948359659223a1d14a14699ec05db3c48a1e6f5e",
4
+ "e1089f131e089edbea8c96cfd932e999b65e5987e7aa46ffa6ec1771bcc58913"
5
+ ],
6
+ "components/Button.specimen.tsx": [
7
+ "78be211c4ebe57be4d3e8fbda178aa1ea4a50874427f50918d5b519b30f1366d"
8
+ ],
9
+ "components/Controls.module.css": [
10
+ "11cd2a14dff9b941386b1e3f73904021501b5d11b92e2c2a5c7db740ccdebf78",
11
+ "6180d0705587ce7c9d721367b3a93968be63620470ca8053842cc9b97416c17f"
12
+ ],
13
+ "components/Controls.specimen.tsx": [
14
+ "44d057760788eafde4648ff245bc88757148aea950d784679a9013929b2fd3f6"
15
+ ],
16
+ "components/Controls.tsx": [
17
+ "bf2dcb69e0d11f2ff9c79be769689c1d2be4425824ecc2aafdbb9d4e916cc7a2"
18
+ ],
19
+ "components/Display.module.css": [
20
+ "5911e1d5ca62edb6baaca2b887a92f7f75e3f71c59d333bc4bffc35d5581481e"
21
+ ],
22
+ "components/Display.specimen.tsx": [
23
+ "4a00e90b3e9e18031132c36b3e9e2f2e338abe59901f99b3418cce9122de72a2",
24
+ "f833c9458080cf9cb05aca5f61b1e314ed2069466f5567f3a36add6c5f33cb53"
25
+ ],
26
+ "components/Display.tsx": [
27
+ "88ad53b91e4faccf1c85d8e60f7f8c5a98cca4b2061ac583ee3a87796ac92661",
28
+ "f676585a048fea80de64e2be1cd2032d63547d6891011deec9cf3540763a039f"
29
+ ],
30
+ "components/Field.module.css": [
31
+ "8a02df76c308cbe1c731cd5cfca4c2c7d5f1ae2492c4266d11a60c24143281d9"
32
+ ],
33
+ "components/Field.tsx": [
34
+ "170d7e50605f5bc342fe03a0f6fcb72e24e7ad4481ddd7b73402fbb62272bb2d",
35
+ "9c278ae0f37efe55fd73b03204c0e3f6997d6c3c7873856798efc8ce95222ef0",
36
+ "ebb3f7ed65f00184d71a0697871181d8f2fb8d723dc28d94171b11e0cc44ebe7"
37
+ ],
38
+ "components/Overlay.module.css": [
39
+ "15f18fe8494bb008e85f06a8ebd2d491826e565ddb16a6a01b270e115977838b",
40
+ "f1bae693a614018f19d7f9cdb2f5d9b950a711016d2639ba0ea03acbe05ef797"
41
+ ],
42
+ "components/Tabs.module.css": [
43
+ "f794ecc0a17aea3390c72851f56a005a16b9a5c532809c57b0bd597f49faf7cc"
44
+ ],
45
+ "components/Tabs.specimen.tsx": [
46
+ "0c871b0745e9206a6301025b0fc2e49b15b6798c8fe34499a04fe212cb0ca8d3"
47
+ ],
48
+ "components/Tabs.tsx": [
49
+ "7a3178fd1fce2697329049a23581ed40a018777b9a8d121e92b6cf0bbb20beb1"
50
+ ],
51
+ "components/index.ts": [
52
+ "c8e48a994b9283dbfba7cf83d15f8ccb327ea4b09f1138f84153dcd206262626"
53
+ ],
54
+ "components/specimen.ts": [
55
+ "03370daf9bba1c4329d3dac7b76199970cab9c11a6ea2d23e1a484ead80d36d6",
56
+ "1f826f8c8f183e311fd20cca0e5e3c138ac2775c4e156862600c24e9dad7cf7d",
57
+ "2f4b9f188c981a690c271b100cf4174339637d4bd8f27b3daa200ac089b6256e",
58
+ "e04c6fc2a3e85090b7672d229d95ca8c5a42009f6397332230d324357b993169"
59
+ ],
60
+ "patterns/AppShell.module.css": [
61
+ "8327a6a221d3822f31e2413ea2d83978e89dc5ed093ab348efbc3fb74659a76b"
62
+ ],
63
+ "patterns/AppShell.specimen.tsx": [
64
+ "bbf6e40b1eb59ab81faec7d298daedf964721210acd319518370d654828e61b5"
65
+ ],
66
+ "patterns/DataTable.specimen.tsx": [
67
+ "07ddde9be4ff6b5fbe080145b59cb98719ebf17665efea5168ce653e45e52328",
68
+ "fef38e643e27c8515167d03b2f9578c4f490dc3c472f39db5ec8a3f8de5b0405"
69
+ ],
70
+ "patterns/DataTable.tsx": [
71
+ "bee57f1686bbbfeb08670475c687c18f8e33b19b09b804d2e3b857c9575f4227"
72
+ ],
73
+ "patterns/EmptyState.tsx": [
74
+ "7b9f3ad1ad9467b537154426823da185062d99f9b5481329ec87c8e21edefe19"
75
+ ],
76
+ "patterns/FormSection.specimen.tsx": [
77
+ "947b384b3ff7b0874fd7210b2db767623ba980142e33f6daf98261b5c725b363"
78
+ ],
79
+ "patterns/Navbar.module.css": [
80
+ "7a4c07e69235c8b0667424a3794a7ca927fb6c1a852b174fed9c445d3ce31b05",
81
+ "a3d8cdfd7817bee2deca487115863b8e9a904159ccf1a62a4252269f9a00974b"
82
+ ],
83
+ "patterns/Navbar.specimen.tsx": [
84
+ "c5dca4b21abd2fb9e6a3dc6c881dd946691eb4b193030355897cf4837c1fbd33",
85
+ "e0db053892604c3e48fe688481f2d7659179aa37f15f7a1733c65eaf58d2037b",
86
+ "ff2ef3ab16212f5d49952a425d4a55b5c1a23bbf0273032fcbf309ef4cc42425"
87
+ ],
88
+ "patterns/Navbar.tsx": [
89
+ "629e3a72fac39fbddcef20ee2761b20637ac6ecf931e7c40ec497fb5717c76b5",
90
+ "a519ebc213b050332897d49ca3d3f218e0f81657b45dfeabf12ba4851e27ea9d",
91
+ "c30d7c50eca1d5f5403bfe279f82c0fc0892c3ec7c117eba529a2c4d67011e85"
92
+ ],
93
+ "patterns/PageHeader.specimen.tsx": [
94
+ "ed1adf47bc0873aa2888e3f344b4e6d8949cdcbe4d5932a05c014e6552872107"
95
+ ],
96
+ "patterns/SidebarNav.module.css": [
97
+ "c5c61ad5ef76d19fdafdd62c3e7d2762c8f4e65a659d79630679cc77143c6fcf"
98
+ ],
99
+ "patterns/SidebarNav.specimen.tsx": [
100
+ "5ebc259eaec7004cd0836c3f108e6f26e4c209f1722a5102971edd3048bffcb4"
101
+ ],
102
+ "patterns/SidebarNav.tsx": [
103
+ "e11bb61819ea9ff36d963eec2b92f1d7cf2b4e2b14e74edc7a9ab001338a4fc8"
104
+ ],
105
+ "patterns/StatCard.module.css": [
106
+ "30c76bf5527125a41d5237b01aba8d7f3575df6732edc1b0636a1086e4304c45"
107
+ ],
108
+ "patterns/StatCard.specimen.tsx": [
109
+ "1841a58bce6c35b23f8715bb00c04b641381c9c7d829accb9a2900d49bf9709d"
110
+ ],
111
+ "patterns/StatCard.tsx": [
112
+ "55a9c20673d8a8a191ac2ee70fa27a0ec9ac65f4ea1cf8af5a7be654209e31c8"
113
+ ],
114
+ "patterns/icons.tsx": [
115
+ "5a7c631c8ad5195f82b852814c850af74c3807e09aff1dafec465ce92adc364c"
116
+ ],
117
+ "patterns/index.ts": [
118
+ "06b2fe73440972fbd91e9c64d8a3f4a5cf37e1f2d749cc6a821ce59f9b9d8c02"
119
+ ],
120
+ "patterns/specimen-data.ts": [
121
+ "4e355b5dc7eeaed260e6092f88cf85194a9377bce6accce5e9db6e97e22761e3"
122
+ ]
123
+ }
@@ -87,7 +87,8 @@
87
87
  .link:focus-visible,
88
88
  .link[data-force="focus"],
89
89
  .menuLink:focus-visible,
90
- .menuButton:focus-visible {
90
+ .menuButton:focus-visible,
91
+ .searchButton:focus-visible {
91
92
  outline: var(--focus-ring-width) solid var(--color-focus-ring);
92
93
  outline-offset: calc(var(--focus-ring-width) * -1);
93
94
  }
@@ -155,7 +156,8 @@
155
156
  display: inline-flex;
156
157
  }
157
158
 
158
- .menuButton {
159
+ .menuButton,
160
+ .searchButton {
159
161
  display: none;
160
162
  flex: none;
161
163
  place-items: center;
@@ -170,7 +172,8 @@
170
172
  transition: background-color var(--duration-fast) var(--ease-standard);
171
173
  }
172
174
 
173
- .menuButton:hover {
175
+ .menuButton:hover,
176
+ .searchButton:hover {
174
177
  background: var(--color-secondary);
175
178
  }
176
179
 
@@ -187,32 +190,55 @@
187
190
  gap: 2px;
188
191
  }
189
192
 
190
- /* Narrow layout, forced by compact or when the bar is under 960px wide: the links, the search and the actions
191
- move into the menu behind the menu button. */
192
- .navbar[data-compact] .menuButton {
193
- display: grid;
193
+ /* Three layouts by the bar's own width. From 1024px everything sits in the bar. From 800px the search becomes a
194
+ button that opens it below the bar. Under 800px, or with compact, the links, the search and the actions move
195
+ into the menu behind the menu button. */
196
+ @container (width < 1024px) {
197
+ .bar .search {
198
+ display: none;
199
+ }
200
+
201
+ .searchButton,
202
+ .menu {
203
+ display: grid;
204
+ }
205
+
206
+ .menu :is(.actions, .menuLinks) {
207
+ display: none;
208
+ }
194
209
  }
195
210
 
196
- .navbar[data-compact] .menu {
211
+ .navbar[data-compact] .menuButton,
212
+ .navbar[data-compact] .menu,
213
+ .navbar[data-compact] .menu .menuLinks {
197
214
  display: grid;
198
215
  }
199
216
 
200
- .navbar[data-compact] .bar :is(.links, .search, .actions) {
217
+ .navbar[data-compact] .menu .actions {
218
+ display: flex;
219
+ }
220
+
221
+ .navbar[data-compact] .bar :is(.links, .search, .actions, .searchButton) {
201
222
  display: none;
202
223
  }
203
224
 
204
- @container (width < 960px) {
225
+ @container (width < 800px) {
205
226
  .bar {
206
227
  gap: var(--space-3);
207
228
  padding-inline: var(--space-3);
208
229
  }
209
230
 
210
231
  .menuButton,
211
- .menu {
232
+ .menu,
233
+ .menu .menuLinks {
212
234
  display: grid;
213
235
  }
214
236
 
215
- .bar :is(.links, .search, .actions) {
237
+ .menu .actions {
238
+ display: flex;
239
+ }
240
+
241
+ .bar :is(.links, .search, .actions, .searchButton) {
216
242
  display: none;
217
243
  }
218
244
  }
@@ -101,7 +101,7 @@ export default defineSpecimen({
101
101
  },
102
102
  {
103
103
  kind: "block",
104
- title: "Narrow · under 960px the links and search move behind the menu",
104
+ title: "Narrow · under 800px the links and search move behind the menu",
105
105
  render: () => (
106
106
  <div style={{ ...frame, maxWidth: 380 }}>
107
107
  <Navbar
@@ -1,4 +1,4 @@
1
- import { useId, useState, type ReactNode } from "react";
1
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
2
2
  import { Avatar } from "../components/index.ts";
3
3
  import { Icon } from "./icons.tsx";
4
4
  import styles from "./Navbar.module.css";
@@ -25,7 +25,7 @@ export interface NavbarProps {
25
25
  elevated?: boolean;
26
26
  /**
27
27
  * Forces the narrow layout, where links, search and actions move behind a menu button. Without it the bar switches by
28
- * itself when it is narrower than 960px.
28
+ * itself: under 1024px the search becomes a button, under 800px everything moves behind the menu button.
29
29
  */
30
30
  compact?: boolean;
31
31
  /** Controls the menu from outside; otherwise the bar keeps its own open state. */
@@ -51,6 +51,18 @@ export function Navbar({
51
51
  const [ownOpen, setOwnOpen] = useState(false);
52
52
  const open = menuOpen ?? ownOpen;
53
53
  const toggle = onMenuToggle ?? (() => setOwnOpen((value) => !value));
54
+ const menu = useRef<HTMLDivElement>(null);
55
+ // The search button opens the panel below the bar; the field there gets focus, so typing can start at once.
56
+ const focusSearch = useRef(false);
57
+ const openSearch = () => {
58
+ focusSearch.current = !open;
59
+ toggle();
60
+ };
61
+ useEffect(() => {
62
+ if (!open || !focusSearch.current) return;
63
+ focusSearch.current = false;
64
+ menu.current?.querySelector("input")?.focus();
65
+ }, [open]);
54
66
 
55
67
  const search = searchPlaceholder && (
56
68
  <label className={styles.search}>
@@ -102,6 +114,18 @@ export function Navbar({
102
114
  </nav>
103
115
  <div className={styles.end}>
104
116
  {search}
117
+ {searchPlaceholder && (
118
+ <button
119
+ type="button"
120
+ className={styles.searchButton}
121
+ aria-expanded={open}
122
+ aria-controls={menuId}
123
+ aria-label="Search"
124
+ onClick={openSearch}
125
+ >
126
+ <Icon name="search" size={18} />
127
+ </button>
128
+ )}
105
129
  {actions && <div className={styles.actions}>{actions}</div>}
106
130
  {user && (
107
131
  <span className={styles.user} title={user.name}>
@@ -111,7 +135,7 @@ export function Navbar({
111
135
  </div>
112
136
  </div>
113
137
  {open && (
114
- <div id={menuId} className={styles.menu}>
138
+ <div id={menuId} ref={menu} className={styles.menu}>
115
139
  {search}
116
140
  {actions && <div className={styles.actions}>{actions}</div>}
117
141
  <nav className={styles.menuLinks} aria-label="Main">