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.
- package/README.md +3 -2
- package/package.json +4 -2
- package/server/agent.ts +33 -12
- package/server/brief.ts +18 -0
- package/server/claude.ts +72 -0
- package/server/index.ts +22 -2
- package/server/project.ts +2 -0
- package/server/setup.ts +201 -219
- package/server/template.ts +168 -0
- package/server/workspace.ts +19 -1
- package/shared/protocol.ts +16 -1
- package/src/Workbench.tsx +3 -1
- package/src/chat/ChatPanel.tsx +10 -1
- package/src/shell/ProjectMenu.tsx +38 -3
- package/src/shell/Shell.module.css +9 -0
- package/template/CHANGES.md +25 -0
- package/template/components/Field.module.css +10 -0
- package/template/components/Field.tsx +6 -0
- package/template/history.json +122 -0
- package/template/patterns/Navbar.module.css +38 -12
- package/template/patterns/Navbar.specimen.tsx +1 -1
- package/template/patterns/Navbar.tsx +27 -3
package/server/workspace.ts
CHANGED
|
@@ -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
|
}
|
package/shared/protocol.ts
CHANGED
|
@@ -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
|
}
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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
|
|
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
|
-
/*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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] .
|
|
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] .
|
|
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 <
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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">
|