basis-design 0.1.0 → 0.1.1

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.
@@ -4,7 +4,7 @@ import { readdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { DEFAULT_DESIGN, parseDesignText, type Design } from "../shared/design.ts";
6
6
  import { fixedColorFindings } from "../shared/fixed-colors.ts";
7
- import type { DesignState, HistoryEntry, Snapshot } from "../shared/protocol.ts";
7
+ import type { DesignState, HistoryEntry, Snapshot, TemplateUpdate } from "../shared/protocol.ts";
8
8
  import { REVIEW_FILE, type ReviewState } from "../shared/review.ts";
9
9
  import { git, lines, typecheck } from "./exec.ts";
10
10
  import { commitToHistory, describeSource, loadHistory, type EntryMeta, type Source } from "./history.ts";
@@ -12,6 +12,7 @@ import { DESIGN_FILE, isSourcePath, PRODUCT_FILE, relativeInside, SOURCE_DIRS }
12
12
  import { reachedSpecimens } from "./reach.ts";
13
13
  import { Reviews } from "./reviews.ts";
14
14
  import { claimWorkspaceFormat, seedWorkspace, writeGeneratedFiles } from "./setup.ts";
15
+ import { recordTemplateVersion, templateUpdate, writeTemplateCopy } from "./template.ts";
15
16
  import { Snapshots } from "./snapshots.ts";
16
17
 
17
18
  const DIFF_LIMIT = 400_000;
@@ -46,6 +47,8 @@ export class Workspace extends EventEmitter<Events> {
46
47
  /** A hand edit being checked and recorded (see onFsEvent); close() waits for it. */
47
48
  private settling: Promise<void> = Promise.resolve();
48
49
  private closed = false;
50
+ /** A newer starter template this workspace has not caught up with (see server/template.ts). */
51
+ private template: TemplateUpdate | null = null;
49
52
  /** True while an agent turn runs; its changes are committed once, when the turn ends. */
50
53
  agentActive = false;
51
54
 
@@ -73,6 +76,8 @@ export class Workspace extends EventEmitter<Events> {
73
76
  const hasCommits = (await git(this.dir, ["rev-list", "-n", "1", "--all"]).catch(() => "")).trim() !== "";
74
77
  await this.commit(hasCommits ? (note ? "upgrade" : "external") : "init", note ? { label: note } : {});
75
78
  this.history = await loadHistory(this.dir);
79
+ this.template = await templateUpdate(this.dir);
80
+ if (this.template) await writeTemplateCopy(this.dir);
76
81
  // Copies of past versions from earlier runs, except the open review's starting point.
77
82
  await this.snapshots.prune(this.reviews.state?.baseline);
78
83
  await this.readReviewRequest();
@@ -101,9 +106,22 @@ export class Workspace extends EventEmitter<Events> {
101
106
  reviewErrors: this.reviews.errors,
102
107
  colorFindings: this.colorFindings,
103
108
  workspace: this.dir,
109
+ templateUpdate: this.template,
104
110
  };
105
111
  }
106
112
 
113
+ get templateUpdate(): TemplateUpdate | null {
114
+ return this.template;
115
+ }
116
+
117
+ /** Records the newer template as caught up with, whether its changes were brought in or skipped. */
118
+ async settleTemplateUpdate(): Promise<void> {
119
+ if (!this.template) return;
120
+ await recordTemplateVersion(this.dir, this.template.to);
121
+ this.template = null;
122
+ this.emit("design", this.state);
123
+ }
124
+
107
125
  get entries(): HistoryEntry[] {
108
126
  return this.history;
109
127
  }
@@ -126,6 +126,17 @@ export interface DesignState {
126
126
  /** Colours in tokens and stylesheets that stay the same in both themes (see shared/fixed-colors.ts). */
127
127
  colorFindings: string[];
128
128
  workspace: string;
129
+ /** A newer starter template whose changes the agent can bring into this workspace; null when caught up. */
130
+ templateUpdate: TemplateUpdate | null;
131
+ }
132
+
133
+ /** A newer starter template the workspace has not caught up with (see server/template.ts). */
134
+ export interface TemplateUpdate {
135
+ /** The template version the workspace last caught up with (0: before versions were recorded). */
136
+ from: number;
137
+ to: number;
138
+ /** Starter files the workspace has changed, which differ from the current template. */
139
+ files: string[];
129
140
  }
130
141
 
131
142
  /** A past version materialised for a review: its tokens, and where the preview can import its source. */
@@ -223,4 +234,8 @@ export type ClientMessage =
223
234
  */
224
235
  | { type: "project-repo"; id: string; repo: string | null; bringIn?: boolean }
225
236
  /** Starts the guided turn that brings the open project's product repo design system into the workspace. */
226
- | { type: "import-design" };
237
+ | { type: "import-design" }
238
+ /** Starts the guided turn that brings the newer starter template's changes into the workspace. */
239
+ | { type: "template-update" }
240
+ /** Marks the newer template as seen without bringing its changes in. */
241
+ | { type: "template-skip" };
package/src/Workbench.tsx CHANGED
@@ -214,9 +214,10 @@ export function Workbench({ state, send }: { state: BridgeState; send: (message:
214
214
  picking={picking}
215
215
  onTogglePick={() => (picking ? finishPicking() : setPicking(true))}
216
216
  reviewing={Boolean(review)}
217
+ resizer={!chatHidden && <ChatResizer width={chatWidth} onResize={setChatWidth} />}
217
218
  />
218
- {!chatHidden && <ChatResizer width={chatWidth} onResize={setChatWidth} />}
219
219
  <main className={styles.main}>
220
+ <h1 className="visually-hidden">Basis</h1>
220
221
  <TopBar
221
222
  chatHidden={chatHidden}
222
223
  onToggleChat={() => setChatHidden((hidden) => !hidden)}
@@ -236,6 +237,7 @@ export function Workbench({ state, send }: { state: BridgeState; send: (message:
236
237
  projects={state.projects}
237
238
  agentBusy={state.agent?.status === "busy"}
238
239
  error={state.projectError}
240
+ templateUpdate={state.design?.templateUpdate ?? null}
239
241
  send={send}
240
242
  />
241
243
  }
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef } from "react";
1
+ import { useLayoutEffect, useRef, type ReactNode } from "react";
2
2
  import type { Annotation } from "../../shared/annotations.ts";
3
3
  import {
4
4
  AGENT_IDS,
@@ -38,6 +38,8 @@ interface ChatPanelProps {
38
38
  onTogglePick: () => void;
39
39
  /** True while a review is open. */
40
40
  reviewing: boolean;
41
+ /** The handle that resizes the panel; inside it, so it belongs to the panel's landmark. */
42
+ resizer?: ReactNode;
41
43
  }
42
44
 
43
45
  const STATUS_TEXT: Record<AgentState["status"], string> = {
@@ -58,6 +60,7 @@ export function ChatPanel({
58
60
  onDraft,
59
61
  send,
60
62
  composerRef,
63
+ resizer,
61
64
  ...composer
62
65
  }: ChatPanelProps) {
63
66
  const scroller = useRef<HTMLDivElement>(null);
@@ -116,9 +119,14 @@ export function ChatPanel({
116
119
  </div>
117
120
  )}
118
121
 
122
+ {/* Focusable, so the conversation scrolls from the keyboard too; not role="log", which would read the
123
+ streamed reply aloud as it arrives. */}
119
124
  <div
120
125
  ref={scroller}
121
126
  className={styles.log}
127
+ role="region"
128
+ aria-label="Conversation"
129
+ tabIndex={0}
122
130
  onScroll={(event) => {
123
131
  const el = event.currentTarget;
124
132
  pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
@@ -213,6 +221,7 @@ export function ChatPanel({
213
221
  }}
214
222
  {...composer}
215
223
  />
224
+ {resizer}
216
225
  </aside>
217
226
  );
218
227
  }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useId, useRef, useState } from "react";
2
- import type { ClientMessage, ProjectEntry } from "../../shared/protocol.ts";
2
+ import type { ClientMessage, ProjectEntry, TemplateUpdate } from "../../shared/protocol.ts";
3
3
  import styles from "./Shell.module.css";
4
4
 
5
5
  interface ProjectMenuProps {
@@ -9,6 +9,8 @@ interface ProjectMenuProps {
9
9
  agentBusy: boolean;
10
10
  /** The server's answer to the last failed project request. */
11
11
  error: { message: string } | null;
12
+ /** A newer starter template the open project can bring in. */
13
+ templateUpdate: TemplateUpdate | null;
12
14
  send: (message: ClientMessage) => void;
13
15
  }
14
16
 
@@ -18,7 +20,7 @@ type Dialog = "create" | "open" | "repo" | null;
18
20
  * The open project, where the brand used to be, with the list of projects:
19
21
  * switch to one, start a new one, or open a workspace folder from elsewhere.
20
22
  */
21
- export function ProjectMenu({ project, projects, agentBusy, error, send }: ProjectMenuProps) {
23
+ export function ProjectMenu({ project, projects, agentBusy, error, templateUpdate, send }: ProjectMenuProps) {
22
24
  const [open, setOpen] = useState(false);
23
25
  const [dialog, setDialog] = useState<Dialog>(null);
24
26
  /** The error when the last request went out; a different one (even with the same text) is the answer to it. */
@@ -66,6 +68,11 @@ export function ProjectMenu({ project, projects, agentBusy, error, send }: Proje
66
68
  >
67
69
  <span className={styles.brandMark} aria-hidden />
68
70
  <span className={styles.projectName}>{project?.name ?? "Basis"}</span>
71
+ {templateUpdate && (
72
+ <span className={styles.updateDot}>
73
+ <span className="visually-hidden">, starter template update available</span>
74
+ </span>
75
+ )}
69
76
  <svg viewBox="0 0 16 16" aria-hidden>
70
77
  <path d="m4.5 6.5 3.5 3.5 3.5-3.5" />
71
78
  </svg>
@@ -154,6 +161,34 @@ export function ProjectMenu({ project, projects, agentBusy, error, send }: Proje
154
161
  </div>
155
162
  </div>
156
163
  )}
164
+ {templateUpdate && (
165
+ <div className={styles.projectRepo}>
166
+ <span className={styles.projectRepoHint}>
167
+ Basis' starter components have improved since this project began. Files nobody changed are already up to
168
+ date; the agent brings the improvements into{" "}
169
+ {templateUpdate.files.length === 1
170
+ ? "the one changed file"
171
+ : `${templateUpdate.files.length} changed files`}
172
+ , in a review.
173
+ </span>
174
+ <div className={styles.projectRepoActions}>
175
+ <button
176
+ type="button"
177
+ className={styles.chromeButton}
178
+ disabled={agentBusy}
179
+ onClick={() => {
180
+ setOpen(false);
181
+ send({ type: "template-update" });
182
+ }}
183
+ >
184
+ Bring them in
185
+ </button>
186
+ <button type="button" className={styles.chromeButton} onClick={() => send({ type: "template-skip" })}>
187
+ Skip
188
+ </button>
189
+ </div>
190
+ </div>
191
+ )}
157
192
  <div className={styles.projectActions}>
158
193
  <button
159
194
  type="button"
@@ -255,7 +290,7 @@ function ProjectDialog({ mode, current, error, onSubmit, onClose }: ProjectDialo
255
290
  <h2>{mode === "create" ? "New project" : mode === "repo" ? "Link a product repo" : "Open a folder"}</h2>
256
291
  <p>
257
292
  {mode === "create"
258
- ? "A design system of its own, with its own history and chat. It lives in Basis' workspaces folder."
293
+ ? "A design system of its own, with its own history and chat. It is saved in ~/.basis/workspaces (or BASIS_HOME)."
259
294
  : mode === "repo"
260
295
  ? `The repo ${current?.name ?? "this project"} designs for. The agent reads it to match the product's look, components and terms; it never writes there.`
261
296
  : "A Basis workspace anywhere on this computer, or an empty or new folder (inside a product repo, say) for Basis to set up."}
@@ -904,6 +904,15 @@ a.primaryButton {
904
904
  background: var(--app-ink-2);
905
905
  }
906
906
 
907
+ /* A newer starter template is waiting in the project menu. */
908
+ .updateDot {
909
+ flex: none;
910
+ width: 7px;
911
+ height: 7px;
912
+ border-radius: 50%;
913
+ background: var(--app-signal);
914
+ }
915
+
907
916
  .projectButton svg {
908
917
  flex: none;
909
918
  width: 14px;
@@ -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
  )}
@@ -0,0 +1,122 @@
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
+ "e04c6fc2a3e85090b7672d229d95ca8c5a42009f6397332230d324357b993169"
58
+ ],
59
+ "patterns/AppShell.module.css": [
60
+ "8327a6a221d3822f31e2413ea2d83978e89dc5ed093ab348efbc3fb74659a76b"
61
+ ],
62
+ "patterns/AppShell.specimen.tsx": [
63
+ "bbf6e40b1eb59ab81faec7d298daedf964721210acd319518370d654828e61b5"
64
+ ],
65
+ "patterns/DataTable.specimen.tsx": [
66
+ "07ddde9be4ff6b5fbe080145b59cb98719ebf17665efea5168ce653e45e52328",
67
+ "fef38e643e27c8515167d03b2f9578c4f490dc3c472f39db5ec8a3f8de5b0405"
68
+ ],
69
+ "patterns/DataTable.tsx": [
70
+ "bee57f1686bbbfeb08670475c687c18f8e33b19b09b804d2e3b857c9575f4227"
71
+ ],
72
+ "patterns/EmptyState.tsx": [
73
+ "7b9f3ad1ad9467b537154426823da185062d99f9b5481329ec87c8e21edefe19"
74
+ ],
75
+ "patterns/FormSection.specimen.tsx": [
76
+ "947b384b3ff7b0874fd7210b2db767623ba980142e33f6daf98261b5c725b363"
77
+ ],
78
+ "patterns/Navbar.module.css": [
79
+ "7a4c07e69235c8b0667424a3794a7ca927fb6c1a852b174fed9c445d3ce31b05",
80
+ "a3d8cdfd7817bee2deca487115863b8e9a904159ccf1a62a4252269f9a00974b"
81
+ ],
82
+ "patterns/Navbar.specimen.tsx": [
83
+ "c5dca4b21abd2fb9e6a3dc6c881dd946691eb4b193030355897cf4837c1fbd33",
84
+ "e0db053892604c3e48fe688481f2d7659179aa37f15f7a1733c65eaf58d2037b",
85
+ "ff2ef3ab16212f5d49952a425d4a55b5c1a23bbf0273032fcbf309ef4cc42425"
86
+ ],
87
+ "patterns/Navbar.tsx": [
88
+ "629e3a72fac39fbddcef20ee2761b20637ac6ecf931e7c40ec497fb5717c76b5",
89
+ "a519ebc213b050332897d49ca3d3f218e0f81657b45dfeabf12ba4851e27ea9d",
90
+ "c30d7c50eca1d5f5403bfe279f82c0fc0892c3ec7c117eba529a2c4d67011e85"
91
+ ],
92
+ "patterns/PageHeader.specimen.tsx": [
93
+ "ed1adf47bc0873aa2888e3f344b4e6d8949cdcbe4d5932a05c014e6552872107"
94
+ ],
95
+ "patterns/SidebarNav.module.css": [
96
+ "c5c61ad5ef76d19fdafdd62c3e7d2762c8f4e65a659d79630679cc77143c6fcf"
97
+ ],
98
+ "patterns/SidebarNav.specimen.tsx": [
99
+ "5ebc259eaec7004cd0836c3f108e6f26e4c209f1722a5102971edd3048bffcb4"
100
+ ],
101
+ "patterns/SidebarNav.tsx": [
102
+ "e11bb61819ea9ff36d963eec2b92f1d7cf2b4e2b14e74edc7a9ab001338a4fc8"
103
+ ],
104
+ "patterns/StatCard.module.css": [
105
+ "30c76bf5527125a41d5237b01aba8d7f3575df6732edc1b0636a1086e4304c45"
106
+ ],
107
+ "patterns/StatCard.specimen.tsx": [
108
+ "1841a58bce6c35b23f8715bb00c04b641381c9c7d829accb9a2900d49bf9709d"
109
+ ],
110
+ "patterns/StatCard.tsx": [
111
+ "55a9c20673d8a8a191ac2ee70fa27a0ec9ac65f4ea1cf8af5a7be654209e31c8"
112
+ ],
113
+ "patterns/icons.tsx": [
114
+ "5a7c631c8ad5195f82b852814c850af74c3807e09aff1dafec465ce92adc364c"
115
+ ],
116
+ "patterns/index.ts": [
117
+ "06b2fe73440972fbd91e9c64d8a3f4a5cf37e1f2d749cc6a821ce59f9b9d8c02"
118
+ ],
119
+ "patterns/specimen-data.ts": [
120
+ "4e355b5dc7eeaed260e6092f88cf85194a9377bce6accce5e9db6e97e22761e3"
121
+ ]
122
+ }
@@ -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">