konpeki 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.
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
2
2
  import type { CompositionComponent } from "../../composition/types.ts";
3
3
  import { componentLabels } from "../lib/model.ts";
4
4
  import mark from "../assets/konpeki-mark.png";
5
+ import { BuildOrb } from "./BuildOrb.tsx";
5
6
 
6
7
  const dockItems: {
7
8
  kind: CompositionComponent["kind"];
@@ -65,6 +66,7 @@ export function WorkspaceChrome({
65
66
  onPresent,
66
67
  fileStatus,
67
68
  building,
69
+ buildState,
68
70
  onBuild,
69
71
  onSelectTool,
70
72
  onComponentTool,
@@ -84,6 +86,7 @@ export function WorkspaceChrome({
84
86
  onPresent: () => void;
85
87
  fileStatus?: "loading" | "saved" | "saving" | "conflict" | "error";
86
88
  building: boolean;
89
+ buildState?: "ready" | "working";
87
90
  onBuild?: () => void;
88
91
  onSelectTool: () => void;
89
92
  onComponentTool: (kind: CompositionComponent["kind"]) => void;
@@ -100,6 +103,7 @@ export function WorkspaceChrome({
100
103
  aria-describedby={titleError ? "title-error" : undefined}
101
104
  name="composition-title"
102
105
  autoComplete="off"
106
+ disabled={building}
103
107
  value={title}
104
108
  onChange={(event) => onTitle(event.target.value, "title")}
105
109
  onBlur={onEditEnd}
@@ -140,7 +144,7 @@ export function WorkspaceChrome({
140
144
  className="icon-only"
141
145
  aria-label="Export PNG"
142
146
  title="Export active page as PNG"
143
- disabled={exporting}
147
+ disabled={exporting || building}
144
148
  onClick={onExportPNG}
145
149
  >
146
150
  <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -148,29 +152,27 @@ export function WorkspaceChrome({
148
152
  <path d="m7 14 3.5-4 3 3 2-2 2.5 3M12 16v5m-3-3 3 3 3-3" />
149
153
  </svg>
150
154
  </button>
151
- <button type="button" onClick={onPresent}>
155
+ <button type="button" className="icon-only" aria-label="Present" title="Present" onClick={onPresent} disabled={building}>
152
156
  <svg viewBox="0 0 24 24" aria-hidden="true">
153
157
  <rect x="3" y="4" width="18" height="12" rx="2" />
154
158
  <path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
155
159
  </svg>
156
- Present
157
160
  </button>
158
161
  {onBuild && (
159
162
  <button
160
163
  type="button"
161
- className="primary"
164
+ className={`primary build-button ${building ? "building" : ""}`}
162
165
  disabled={fileStatus !== "saved" || building}
166
+ aria-busy={building}
163
167
  onClick={onBuild}
164
168
  >
165
- <svg viewBox="0 0 24 24" aria-hidden="true">
166
- <path d="m5 19 10-10 3 3L8 22Zm10-10 2-2 3 3-2 2M5 3v6M2 6h6m10-5v4m-2-2h4" />
167
- </svg>
168
- {building ? "Building…" : "Build it"}
169
+ <BuildOrb active={building} />
170
+ <span className="build-label">{buildState === "ready" ? "Request ready" : buildState === "working" ? "Agent working" : "Build it"}</span>
169
171
  </button>
170
172
  )}
171
173
  </div>
172
174
 
173
- <nav className="component-dock" aria-label="Canvas tools">
175
+ <nav className="component-dock" aria-label="Canvas tools" inert={building}>
174
176
  <button
175
177
  type="button"
176
178
  className={!selectedKind ? "active" : ""}
@@ -1,5 +1,5 @@
1
1
  {
2
- "schema": "konpeki-composition/v14",
2
+ "schema": "konpeki-composition/v1",
3
3
  "title": "Parcel Relay — migrated React deck",
4
4
  "theme": {
5
5
  "id": "plex",
@@ -48,7 +48,7 @@ export async function exportPagePNG(
48
48
  });
49
49
  clone
50
50
  .querySelectorAll(
51
- ".resize-handle, .guide, .vector-handles, .inline-intent-editor",
51
+ ".resize-handle, .guide, .vector-handles, .inline-intent-editor, .revision-pin",
52
52
  )
53
53
  .forEach((node) => node.remove());
54
54
  const rect = source.getBoundingClientRect();
@@ -56,6 +56,9 @@ export async function exportPagePNG(
56
56
  clone.style.height = `${rect.height}px`;
57
57
  clone.style.margin = "0";
58
58
  clone.style.overflow = "hidden";
59
+ // Loading feedback belongs to the editor, never to the exported artwork.
60
+ clone.style.filter = "none";
61
+ clone.style.opacity = "1";
59
62
  const fontRules = Array.from(document.styleSheets).flatMap((sheet) =>
60
63
  Array.from(sheet.cssRules).filter(
61
64
  (rule): rule is CSSFontFaceRule => rule instanceof CSSFontFaceRule,
@@ -1,9 +1,11 @@
1
1
  import type { CompositionDocument } from "../../composition/types.ts";
2
+ import type { ReviewState, ReviewTarget } from "./review.ts";
2
3
 
3
4
  export type FileSessionDocument = {
4
5
  document: CompositionDocument;
5
6
  revision: string;
6
7
  name: string;
8
+ review: ReviewState;
7
9
  };
8
10
 
9
11
  export class FileSessionError extends Error {
@@ -65,10 +67,21 @@ export function sendBuildRequest(
65
67
  instruction: string;
66
68
  slideId: string;
67
69
  componentId?: string;
70
+ elementId?: string;
68
71
  },
69
72
  ) {
70
- return request<{ ok: true }>(token, "/build", {
73
+ return request<ReviewState>(token, "/build", {
71
74
  method: "POST",
72
75
  body: JSON.stringify(requestBody),
73
76
  });
74
77
  }
78
+
79
+ export function addNote(token: string, target: ReviewTarget, text: string) {
80
+ return request<ReviewState>(token, "/notes", { method: "POST", body: JSON.stringify({ ...target, text }) });
81
+ }
82
+ export function removeNote(token: string, id: string) {
83
+ return request<ReviewState>(token, "/notes", { method: "DELETE", body: JSON.stringify({ id }) });
84
+ }
85
+ export function cancelRequest(token: string, id: string) {
86
+ return request<ReviewState>(token, "/cancel", { method: "POST", body: JSON.stringify({ id }) });
87
+ }
@@ -0,0 +1,26 @@
1
+ export type ReviewTarget = { slideId: string; componentId?: string; elementId?: string };
2
+ export type RevisionNote = ReviewTarget & {
3
+ id: string;
4
+ text: string;
5
+ resolved: boolean;
6
+ };
7
+ export type BuildRequest = ReviewTarget & {
8
+ schema: "konpeki-build-request/v2";
9
+ id: string;
10
+ compositionPath: string;
11
+ revision: string;
12
+ instruction: string;
13
+ notes: RevisionNote[];
14
+ status: "submitted" | "working" | "done" | "needs-clarification" | "failed";
15
+ message?: string;
16
+ resultRevision?: string;
17
+ };
18
+ export type ReviewState = {
19
+ schema: "konpeki-review/v1";
20
+ version: number;
21
+ notes: RevisionNote[];
22
+ request?: BuildRequest;
23
+ };
24
+ export const emptyReview = (): ReviewState => ({ schema: "konpeki-review/v1", version: 0, notes: [] });
25
+ export const activeRequest = (request?: BuildRequest) =>
26
+ request?.status === "submitted" || request?.status === "working";
@@ -2,7 +2,11 @@ import { useEffect, useRef, useState } from "react";
2
2
  import { canonicalJSON } from "../../composition/compile.ts";
3
3
  import { validateComposition } from "../../composition/validate.ts";
4
4
  import type { Draft } from "./model.ts";
5
+ import { activeRequest, emptyReview, type ReviewState, type ReviewTarget } from "./review.ts";
5
6
  import {
7
+ addNote,
8
+ removeNote,
9
+ cancelRequest,
6
10
  FileSessionError,
7
11
  fileSessionToken,
8
12
  loadFileSession,
@@ -15,7 +19,7 @@ export type FileStatus = "loading" | "saved" | "saving" | "conflict" | "error";
15
19
  type Callbacks = {
16
20
  onOpen: (draft: Draft, name: string) => void;
17
21
  onExternalChange: (draft: Draft) => void;
18
- onNotice: (message: string) => void;
22
+ onNotice: (message: string, tone?: "neutral" | "error") => void;
19
23
  onError: (message: string) => void;
20
24
  };
21
25
 
@@ -26,6 +30,11 @@ export function useFileSession(
26
30
  ) {
27
31
  const token = fileSessionToken();
28
32
  const [status, setStatus] = useState<FileStatus>(enabled ? "loading" : "saved");
33
+ const [building, setBuilding] = useState(false);
34
+ const [review, setReview] = useState<ReviewState>(emptyReview);
35
+ const reviewRef = useRef(review);
36
+ const buildPending = useRef(false);
37
+ const submittingBuild = useRef(false);
29
38
  const callbacksRef = useRef(callbacks);
30
39
  callbacksRef.current = callbacks;
31
40
  const draftRef = useRef(draft);
@@ -49,6 +58,7 @@ export function useFileSession(
49
58
  savedJSON: canonicalJSON(session.document),
50
59
  };
51
60
  setStatus("saved");
61
+ receiveReview(session.review);
52
62
  callbacksRef.current.onOpen(session.document, session.name);
53
63
  }).catch((error) => {
54
64
  if (cancelled) return;
@@ -72,13 +82,18 @@ export function useFileSession(
72
82
  if (!enabled || !token) return;
73
83
  let cancelled = false;
74
84
  const timer = window.setInterval(() => {
75
- if (!state.current.ready || state.current.drain) return;
85
+ if (!state.current.ready || state.current.drain || submittingBuild.current) return;
86
+ const revision = state.current.revision;
76
87
  void loadFileSession(token).then((remote) => {
77
- if (cancelled || remote.revision === state.current.revision) return;
88
+ if (cancelled || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
89
+ receiveReview(remote.review);
90
+ setStatus(current => current === "error" ? "saved" : current);
91
+ if (remote.revision === revision) return;
78
92
  if (canonicalJSON(draftRef.current) !== state.current.savedJSON) {
79
93
  setStatus("conflict");
80
94
  callbacksRef.current.onNotice(
81
95
  "The file changed elsewhere. Neither version was overwritten.",
96
+ "error",
82
97
  );
83
98
  return;
84
99
  }
@@ -90,7 +105,11 @@ export function useFileSession(
90
105
  setStatus("saved");
91
106
  callbacksRef.current.onExternalChange(remote.document);
92
107
  }).catch(() => {
93
- if (!cancelled) setStatus("error");
108
+ if (cancelled || submittingBuild.current || revision !== state.current.revision) return;
109
+ setStatus("error");
110
+ if (buildPending.current) {
111
+ callbacksRef.current.onNotice("Could not load the agent result. Check the local file service.", "error");
112
+ }
94
113
  });
95
114
  }, 1000);
96
115
  return () => {
@@ -99,8 +118,30 @@ export function useFileSession(
99
118
  };
100
119
  }, [enabled, token]);
101
120
 
121
+ function finishBuild() {
122
+ buildPending.current = false;
123
+ setBuilding(false);
124
+ }
125
+
126
+ function receiveReview(next: ReviewState) {
127
+ if (next.version < reviewRef.current.version) return;
128
+ reviewRef.current = next;
129
+ setReview(next);
130
+ buildPending.current = activeRequest(next.request);
131
+ setBuilding(buildPending.current);
132
+ }
133
+
134
+ async function noteAction(action: () => Promise<ReviewState>, saveFirst = false) {
135
+ if (submittingBuild.current) throw new Error("Wait for the current save before submitting.");
136
+ submittingBuild.current = true;
137
+ try {
138
+ if (saveFirst && !await save(draftRef.current)) throw new Error("Save the composition before adding a note.");
139
+ receiveReview(await action());
140
+ } finally { submittingBuild.current = false; }
141
+ }
142
+
102
143
  function save(next: Draft): Promise<string | undefined> {
103
- if (!token || !state.current.ready) return Promise.resolve(undefined);
144
+ if (!token || !state.current.ready || activeRequest(reviewRef.current.request)) return Promise.resolve(undefined);
104
145
  const serialized = canonicalJSON(next);
105
146
  if (serialized === state.current.savedJSON && !state.current.drain)
106
147
  return Promise.resolve(state.current.revision);
@@ -133,9 +174,12 @@ export function useFileSession(
133
174
  state.current.pending = undefined;
134
175
  const conflict = error instanceof FileSessionError && error.status === 409;
135
176
  setStatus(conflict ? "conflict" : "error");
136
- callbacksRef.current.onNotice(conflict
137
- ? "The composition changed outside this browser. Your edits were not overwritten."
138
- : error instanceof Error ? error.message : "Could not save the composition file.");
177
+ callbacksRef.current.onNotice(
178
+ conflict
179
+ ? "The composition changed outside this browser. Your edits were not overwritten."
180
+ : error instanceof Error ? error.message : "Could not save the composition file.",
181
+ "error",
182
+ );
139
183
  return;
140
184
  }
141
185
  }
@@ -145,18 +189,39 @@ export function useFileSession(
145
189
  instruction: string,
146
190
  slideId: string,
147
191
  componentId?: string,
192
+ elementId?: string,
148
193
  ) {
149
- if (!token) return false;
150
- const revision = await save(draftRef.current);
151
- if (!revision) return false;
152
- await sendBuildRequest(token, {
153
- revision,
154
- instruction,
155
- slideId,
156
- ...(componentId ? { componentId } : {}),
157
- });
158
- return true;
194
+ if (!token || buildPending.current) return false;
195
+ if (submittingBuild.current) throw new Error("Wait for your note to finish saving before building.");
196
+ buildPending.current = true;
197
+ submittingBuild.current = true;
198
+ setBuilding(true);
199
+ try {
200
+ const revision = await save(draftRef.current);
201
+ if (!revision) {
202
+ finishBuild();
203
+ return false;
204
+ }
205
+ receiveReview(await sendBuildRequest(token, {
206
+ revision,
207
+ instruction,
208
+ slideId,
209
+ ...(componentId ? { componentId } : {}),
210
+ ...(elementId ? { elementId } : {}),
211
+ }));
212
+ return true;
213
+ } catch (error) {
214
+ finishBuild();
215
+ throw error;
216
+ } finally {
217
+ submittingBuild.current = false;
218
+ }
159
219
  }
160
220
 
161
- return { status, build };
221
+ return {
222
+ status, build, building, review,
223
+ addNote: (target: ReviewTarget, text: string) => noteAction(() => addNote(token!, target, text), true),
224
+ removeNote: (id: string) => noteAction(() => removeNote(token!, id)),
225
+ cancel: () => noteAction(() => cancelRequest(token!, reviewRef.current.request!.id)),
226
+ };
162
227
  }
@@ -3,6 +3,8 @@
3
3
  color: #202020;
4
4
  background: #f4f4f4;
5
5
  font-synthesis: none;
6
+ -webkit-font-smoothing: antialiased;
7
+ -moz-osx-font-smoothing: grayscale;
6
8
  --accent: #007bbb;
7
9
  --muted: #626262;
8
10
  --ui-bg: #f4f4f4;
@@ -39,6 +41,8 @@ button {
39
41
  color: inherit;
40
42
  background: #fff;
41
43
  padding: 7px 10px;
44
+ transition: background-color 120ms ease-out, border-color 120ms ease-out,
45
+ color 120ms ease-out, box-shadow 120ms ease-out, transform 120ms ease-out;
42
46
  }
43
47
  button:hover:not(:disabled) {
44
48
  background: #f1f1f1;
@@ -62,6 +66,8 @@ select {
62
66
  background: #fff;
63
67
  color: var(--ui-text);
64
68
  padding: 7px 9px;
69
+ transition: background-color 120ms ease-out, border-color 120ms ease-out,
70
+ box-shadow 120ms ease-out;
65
71
  }
66
72
  input:hover,
67
73
  textarea:hover,
@@ -92,6 +98,16 @@ h2 {
92
98
  background: #383838;
93
99
  border-color: #383838;
94
100
  }
101
+ .panel-toggle:active:not(:disabled),
102
+ .action-island button:active:not(:disabled),
103
+ .component-dock button:active:not(:disabled),
104
+ .segmented-options button:active:not(:disabled),
105
+ .theme-options button:active:not(:disabled),
106
+ .appearance-options button:active:not(:disabled),
107
+ .revision-note-submit button:active:not(:disabled),
108
+ .revision-note-remove:active:not(:disabled) {
109
+ transform: scale(0.96);
110
+ }
95
111
  .sr-only {
96
112
  position: absolute;
97
113
  width: 1px;
@@ -192,8 +192,8 @@
192
192
  }
193
193
  .resize-handle {
194
194
  position: absolute;
195
- width: 24px;
196
- height: 24px;
195
+ width: 40px;
196
+ height: 40px;
197
197
  padding: 0;
198
198
  border: 0;
199
199
  background: transparent;
@@ -202,8 +202,8 @@
202
202
  }
203
203
  .resize-handle::after {
204
204
  position: absolute;
205
- top: 9px;
206
- left: 9px;
205
+ top: 17px;
206
+ left: 17px;
207
207
  width: 6px;
208
208
  height: 6px;
209
209
  border: 1px solid var(--accent);
@@ -215,23 +215,23 @@
215
215
  background: transparent;
216
216
  }
217
217
  .resize-nw {
218
- top: -12px;
219
- left: -12px;
218
+ top: -20px;
219
+ left: -20px;
220
220
  cursor: nwse-resize;
221
221
  }
222
222
  .resize-ne {
223
- top: -12px;
224
- right: -12px;
223
+ top: -20px;
224
+ right: -20px;
225
225
  cursor: nesw-resize;
226
226
  }
227
227
  .resize-sw {
228
- bottom: -12px;
229
- left: -12px;
228
+ bottom: -20px;
229
+ left: -20px;
230
230
  cursor: nesw-resize;
231
231
  }
232
232
  .resize-se {
233
- right: -12px;
234
- bottom: -12px;
233
+ right: -20px;
234
+ bottom: -20px;
235
235
  cursor: nwse-resize;
236
236
  }
237
237
  .guide {
@@ -266,3 +266,34 @@
266
266
  min-height: 100%;
267
267
  height: 100%;
268
268
  }
269
+ .revision-pin {
270
+ position: absolute;
271
+ display: grid;
272
+ place-items: center;
273
+ width: 40px;
274
+ height: 40px;
275
+ min-height: 0;
276
+ padding: 0;
277
+ border: 0;
278
+ border-radius: 50%;
279
+ background: radial-gradient(
280
+ circle,
281
+ var(--accent) 0 10px,
282
+ var(--ui-surface) 10.5px 11.5px,
283
+ transparent 12px
284
+ );
285
+ color: white;
286
+ font: 500 11px "IBM Plex Sans", sans-serif;
287
+ box-shadow: none;
288
+ filter: drop-shadow(0 1px 2px #0003);
289
+ }
290
+ .revision-pin:hover:not(:disabled),
291
+ .revision-pin:active:not(:disabled) {
292
+ border-color: transparent;
293
+ background: radial-gradient(
294
+ circle,
295
+ #006aa1 0 10px,
296
+ var(--ui-surface) 10.5px 11.5px,
297
+ transparent 12px
298
+ );
299
+ }
@@ -14,7 +14,7 @@
14
14
  top: 20px;
15
15
  left: 20px;
16
16
  width: 252px;
17
- height: 56px;
17
+ height: 60px;
18
18
  gap: 10px;
19
19
  padding: 8px 10px;
20
20
  border-radius: 14px;
@@ -42,7 +42,7 @@
42
42
  }
43
43
 
44
44
  .document-title input {
45
- height: 36px;
45
+ height: 40px;
46
46
  border-color: transparent;
47
47
  background: transparent;
48
48
  font-size: 13px;
@@ -125,7 +125,7 @@
125
125
  display: inline-flex;
126
126
  align-items: center;
127
127
  gap: 6px;
128
- height: 34px;
128
+ height: 40px;
129
129
  padding: 0 12px;
130
130
  border-radius: 7px;
131
131
  border-color: transparent;
@@ -141,17 +141,18 @@
141
141
 
142
142
  .action-island button.icon-only {
143
143
  flex: none;
144
- width: 34px;
145
- padding: 0 8px;
144
+ width: 40px;
145
+ padding: 0;
146
+ justify-content: center;
146
147
  }
147
148
 
148
149
  .action-island button.primary {
149
- background: #202020;
150
- color: white;
150
+ background: white;
151
+ color: var(--ui-text);
151
152
  }
152
153
 
153
154
  .action-island button.primary:hover:not(:disabled) {
154
- background: #383838;
155
+ background: var(--ui-hover);
155
156
  }
156
157
 
157
158
  .action-island button.primary:disabled {
@@ -159,6 +160,86 @@
159
160
  color: var(--ui-muted);
160
161
  }
161
162
 
163
+ .action-island .build-button {
164
+ position: relative;
165
+ overflow: hidden;
166
+ justify-content: center;
167
+ flex: none;
168
+ padding-inline: 9px;
169
+ white-space: nowrap;
170
+ }
171
+
172
+ .build-button .build-label {
173
+ min-width: 11ch;
174
+ text-align: left;
175
+ }
176
+
177
+ .action-island button.build-button.building {
178
+ background: white;
179
+ color: #2449aa;
180
+ opacity: 1;
181
+ cursor: wait;
182
+ }
183
+
184
+ .build-button.building .build-label {
185
+ background: linear-gradient(110deg, #155e75 10%, #2449aa 30%, #a82d62 45%, #87bdeb 50%, #7535a5 55%, #155e75 80%);
186
+ background-size: 250% 100%;
187
+ background-clip: text;
188
+ color: transparent;
189
+ animation: build-colors 3s linear infinite;
190
+ }
191
+
192
+ .build-orb circle {
193
+ fill: currentColor;
194
+ stroke: none;
195
+ transform: translate(var(--x), var(--y));
196
+ }
197
+ .build-orb.active circle {
198
+ animation: orb-shape 8s ease-in-out infinite;
199
+ }
200
+ .build-orb.nebula {
201
+ width: 100%;
202
+ height: 100%;
203
+ color: #4265ad;
204
+ transform: rotate(-18deg);
205
+ }
206
+ .build-orb.nebula circle {
207
+ animation: orb-sphere 12s ease-in-out infinite;
208
+ animation-delay: var(--phase);
209
+ }
210
+ .build-orb.nebula circle:nth-child(3n) { fill: #9772bb; }
211
+ .build-orb.nebula circle:nth-child(3n + 1) { fill: #198faa; }
212
+
213
+ @keyframes orb-shape {
214
+ 0%, 10%, 100% { transform: translate(var(--x), var(--y)); }
215
+ 25%, 35% { transform: translate(var(--tx), var(--ty)); }
216
+ 50%, 60% { transform: translate(var(--sx), var(--sy)); }
217
+ 75%, 85% { transform: translate(var(--hx), var(--hy)); }
218
+ }
219
+ @keyframes orb-sphere {
220
+ 0%, 100% { transform: translate(var(--radius), var(--y)); opacity: 0.5; }
221
+ 25% { transform: translate(0, var(--y)); opacity: 1; }
222
+ 50% { transform: translate(calc(-1 * var(--radius)), var(--y)); opacity: 0.5; }
223
+ 75% { transform: translate(0, var(--y)); opacity: 0.15; }
224
+ }
225
+ @keyframes orb-turn {
226
+ to { transform: rotate(360deg); }
227
+ }
228
+
229
+ @keyframes build-colors {
230
+ to { background-position: 150% 0; }
231
+ }
232
+
233
+ @media (prefers-reduced-motion: reduce) {
234
+ .build-button.building .build-label,
235
+ .build-orb.active circle,
236
+ .build-orb.nebula circle,
237
+ .build-orb.nebula,
238
+ .build-nebula::before {
239
+ animation: none;
240
+ }
241
+ }
242
+
162
243
  .action-island button svg {
163
244
  flex: none;
164
245
  width: 18px;
@@ -170,6 +251,11 @@
170
251
  stroke-width: 1.7;
171
252
  }
172
253
 
254
+ .action-island .build-button .build-orb {
255
+ width: 20px;
256
+ height: 20px;
257
+ }
258
+
173
259
  .component-dock {
174
260
  bottom: 20px;
175
261
  left: 50%;
@@ -188,6 +274,7 @@
188
274
  gap: 1px;
189
275
  padding: 4px 7px;
190
276
  border-color: transparent;
277
+ border-radius: 10px;
191
278
  color: var(--ui-text);
192
279
  font-size: 11px;
193
280
  }
@@ -212,3 +299,14 @@
212
299
  min-width: 55px;
213
300
  }
214
301
  }
302
+ @media (max-width: 600px) {
303
+ .component-dock {
304
+ width: calc(100% - 24px);
305
+ }
306
+ .component-dock button {
307
+ flex: 1;
308
+ min-width: 0;
309
+ padding-inline: 3px;
310
+ font-size: 10px;
311
+ }
312
+ }