konpeki 0.1.1 → 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,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
 
@@ -27,6 +31,8 @@ export function useFileSession(
27
31
  const token = fileSessionToken();
28
32
  const [status, setStatus] = useState<FileStatus>(enabled ? "loading" : "saved");
29
33
  const [building, setBuilding] = useState(false);
34
+ const [review, setReview] = useState<ReviewState>(emptyReview);
35
+ const reviewRef = useRef(review);
30
36
  const buildPending = useRef(false);
31
37
  const submittingBuild = useRef(false);
32
38
  const callbacksRef = useRef(callbacks);
@@ -52,6 +58,7 @@ export function useFileSession(
52
58
  savedJSON: canonicalJSON(session.document),
53
59
  };
54
60
  setStatus("saved");
61
+ receiveReview(session.review);
55
62
  callbacksRef.current.onOpen(session.document, session.name);
56
63
  }).catch((error) => {
57
64
  if (cancelled) return;
@@ -78,12 +85,15 @@ export function useFileSession(
78
85
  if (!state.current.ready || state.current.drain || submittingBuild.current) return;
79
86
  const revision = state.current.revision;
80
87
  void loadFileSession(token).then((remote) => {
81
- if (cancelled || state.current.drain || submittingBuild.current || revision !== state.current.revision || remote.revision === 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;
82
92
  if (canonicalJSON(draftRef.current) !== state.current.savedJSON) {
83
- finishBuild();
84
93
  setStatus("conflict");
85
94
  callbacksRef.current.onNotice(
86
95
  "The file changed elsewhere. Neither version was overwritten.",
96
+ "error",
87
97
  );
88
98
  return;
89
99
  }
@@ -94,13 +104,11 @@ export function useFileSession(
94
104
  };
95
105
  setStatus("saved");
96
106
  callbacksRef.current.onExternalChange(remote.document);
97
- finishBuild();
98
107
  }).catch(() => {
99
108
  if (cancelled || submittingBuild.current || revision !== state.current.revision) return;
100
109
  setStatus("error");
101
110
  if (buildPending.current) {
102
- finishBuild();
103
- callbacksRef.current.onNotice("Could not load the agent result. Check the local file service.");
111
+ callbacksRef.current.onNotice("Could not load the agent result. Check the local file service.", "error");
104
112
  }
105
113
  });
106
114
  }, 1000);
@@ -115,8 +123,25 @@ export function useFileSession(
115
123
  setBuilding(false);
116
124
  }
117
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
+
118
143
  function save(next: Draft): Promise<string | undefined> {
119
- if (!token || !state.current.ready) return Promise.resolve(undefined);
144
+ if (!token || !state.current.ready || activeRequest(reviewRef.current.request)) return Promise.resolve(undefined);
120
145
  const serialized = canonicalJSON(next);
121
146
  if (serialized === state.current.savedJSON && !state.current.drain)
122
147
  return Promise.resolve(state.current.revision);
@@ -149,9 +174,12 @@ export function useFileSession(
149
174
  state.current.pending = undefined;
150
175
  const conflict = error instanceof FileSessionError && error.status === 409;
151
176
  setStatus(conflict ? "conflict" : "error");
152
- callbacksRef.current.onNotice(conflict
153
- ? "The composition changed outside this browser. Your edits were not overwritten."
154
- : 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
+ );
155
183
  return;
156
184
  }
157
185
  }
@@ -161,8 +189,10 @@ export function useFileSession(
161
189
  instruction: string,
162
190
  slideId: string,
163
191
  componentId?: string,
192
+ elementId?: string,
164
193
  ) {
165
194
  if (!token || buildPending.current) return false;
195
+ if (submittingBuild.current) throw new Error("Wait for your note to finish saving before building.");
166
196
  buildPending.current = true;
167
197
  submittingBuild.current = true;
168
198
  setBuilding(true);
@@ -172,12 +202,13 @@ export function useFileSession(
172
202
  finishBuild();
173
203
  return false;
174
204
  }
175
- await sendBuildRequest(token, {
205
+ receiveReview(await sendBuildRequest(token, {
176
206
  revision,
177
207
  instruction,
178
208
  slideId,
179
209
  ...(componentId ? { componentId } : {}),
180
- });
210
+ ...(elementId ? { elementId } : {}),
211
+ }));
181
212
  return true;
182
213
  } catch (error) {
183
214
  finishBuild();
@@ -187,5 +218,10 @@ export function useFileSession(
187
218
  }
188
219
  }
189
220
 
190
- return { status, build, building };
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
+ };
191
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,8 +141,9 @@
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 {
@@ -163,46 +164,78 @@
163
164
  position: relative;
164
165
  overflow: hidden;
165
166
  justify-content: center;
166
- flex: 1;
167
+ flex: none;
168
+ padding-inline: 9px;
167
169
  white-space: nowrap;
168
170
  }
169
171
 
172
+ .build-button .build-label {
173
+ min-width: 11ch;
174
+ text-align: left;
175
+ }
176
+
170
177
  .action-island button.build-button.building {
171
- background: linear-gradient(110deg, #155e75, #2449aa, #7535a5, #a82d62, #155e75);
172
- background-size: 300% 100%;
173
- color: white;
178
+ background: white;
179
+ color: #2449aa;
174
180
  opacity: 1;
175
181
  cursor: wait;
176
- animation: build-colors 4s linear infinite;
177
182
  }
178
183
 
179
- .build-button.building::after,
180
- .build-loading-track::after {
181
- position: absolute;
182
- inset: 0;
183
- background: linear-gradient(90deg, transparent, #ffffff50, transparent);
184
- transform: translateX(-100%);
185
- animation: build-sweep 1.8s ease-in-out infinite;
186
- content: "";
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;
187
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; }
188
212
 
189
- .build-button.building::after {
190
- top: auto;
191
- height: 3px;
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); }
192
227
  }
193
228
 
194
229
  @keyframes build-colors {
195
230
  to { background-position: 150% 0; }
196
231
  }
197
232
 
198
- @keyframes build-sweep {
199
- to { transform: translateX(100%); }
200
- }
201
-
202
233
  @media (prefers-reduced-motion: reduce) {
203
- .action-island button.build-button.building,
204
- .build-button.building::after,
205
- .build-loading-track::after {
234
+ .build-button.building .build-label,
235
+ .build-orb.active circle,
236
+ .build-orb.nebula circle,
237
+ .build-orb.nebula,
238
+ .build-nebula::before {
206
239
  animation: none;
207
240
  }
208
241
  }
@@ -218,6 +251,11 @@
218
251
  stroke-width: 1.7;
219
252
  }
220
253
 
254
+ .action-island .build-button .build-orb {
255
+ width: 20px;
256
+ height: 20px;
257
+ }
258
+
221
259
  .component-dock {
222
260
  bottom: 20px;
223
261
  left: 50%;
@@ -236,6 +274,7 @@
236
274
  gap: 1px;
237
275
  padding: 4px 7px;
238
276
  border-color: transparent;
277
+ border-radius: 10px;
239
278
  color: var(--ui-text);
240
279
  font-size: 11px;
241
280
  }
@@ -260,3 +299,14 @@
260
299
  min-width: 55px;
261
300
  }
262
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
+ }
@@ -20,7 +20,7 @@
20
20
  max-width: min(520px, max(240px, calc(100vw - 744px)), calc(100vw - 40px));
21
21
  z-index: 50;
22
22
  }
23
- .toast > [role="status"] {
23
+ .toast > span {
24
24
  min-width: 0;
25
25
  overflow-wrap: anywhere;
26
26
  }
@@ -32,6 +32,24 @@
32
32
  pointer-events: none;
33
33
  animation: toast-exit 180ms ease-in both;
34
34
  }
35
+ .toast.error {
36
+ border-color: #e4b5b5;
37
+ background: #fff7f7;
38
+ color: #8d1717;
39
+ box-shadow: 0 0 0 1px #b13d3d14, 0 4px 16px #5f11110d;
40
+ }
41
+ .toast.error::before {
42
+ display: grid;
43
+ place-items: center;
44
+ flex: none;
45
+ width: 20px;
46
+ height: 20px;
47
+ border-radius: 50%;
48
+ background: #b13d3d;
49
+ color: white;
50
+ content: "!";
51
+ font-weight: 600;
52
+ }
35
53
  @keyframes toast-enter {
36
54
  from {
37
55
  opacity: 0;
@@ -1,3 +1,44 @@
1
+ .left-notes {
2
+ flex: 1;
3
+ min-height: 0;
4
+ overflow-y: auto;
5
+ }
6
+ .revision-notes { padding: 18px 16px; }
7
+ .revision-note-submit {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: space-between;
11
+ gap: 12px;
12
+ }
13
+ .revision-note-count,
14
+ .revision-note-scope {
15
+ color: var(--ui-muted);
16
+ font-size: 11px;
17
+ line-height: 1.5;
18
+ }
19
+ .revision-note-count { margin: 20px 0 8px; }
20
+ .revision-notes label { font-size: 12px; }
21
+ .revision-note-scope {
22
+ margin: 6px 0 8px;
23
+ padding: 6px 8px;
24
+ border-radius: 6px;
25
+ background: var(--ui-selected);
26
+ color: #31596d;
27
+ overflow-wrap: anywhere;
28
+ }
29
+ .revision-notes textarea { width: 100%; resize: vertical; min-height: 72px; font-size: 12px; line-height: 1.5; }
30
+ .revision-notes textarea { border-color: transparent; border-radius: 6px; background: #f4f4f5; }
31
+ .revision-notes textarea:hover { border-color: #d4d4d8; }
32
+ .revision-note-submit { margin-top: 8px; justify-content: flex-end; }
33
+ .revision-note-submit button { min-height: 40px; white-space: nowrap; font-size: 12px; }
34
+ .revision-note-list { list-style: none; margin: 0; padding: 0; }
35
+ .revision-note-list li { padding: 12px 0; border-top: 1px solid var(--ui-border); }
36
+ .revision-note-target { display: flex; align-items: baseline; gap: 8px; width: 100%; min-height: 40px; padding: 6px 0; border: 0; background: none; text-align: left; font-size: 11px; overflow-wrap: anywhere; }
37
+ .revision-note-target > span { color: var(--ui-muted); }
38
+ .revision-note-list p { font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
39
+ .revision-note-remove { display: inline-flex; align-items: center; min-height: 40px; border: 0; padding: 6px 0; background: none; color: var(--ui-muted); font-size: 11px; }
40
+ .revision-note-error { color: #a02020; font-size: 12px; }
41
+
1
42
  .slide-list {
2
43
  flex: 1;
3
44
  min-height: 0;
@@ -24,8 +65,8 @@
24
65
 
25
66
  .slide-thumbnail.selected,
26
67
  .slide-thumbnail.selected:hover {
27
- border-color: #86868b;
28
- background: #f4f4f5;
68
+ border-color: color-mix(in srgb, var(--accent) 45%, white);
69
+ background: var(--ui-selected);
29
70
  }
30
71
 
31
72
  .slide-thumbnail:focus-visible {
@@ -42,10 +83,10 @@
42
83
 
43
84
  .remove-slide {
44
85
  position: absolute;
45
- right: 8px;
46
- bottom: 7px;
47
- width: 26px;
48
- height: 26px;
86
+ right: 2px;
87
+ bottom: 0;
88
+ width: 40px;
89
+ height: 40px;
49
90
  padding: 0;
50
91
  border-color: transparent;
51
92
  border-radius: 5px;
@@ -64,7 +105,7 @@
64
105
  align-items: center;
65
106
  justify-content: center;
66
107
  width: 100%;
67
- min-height: 38px;
108
+ min-height: 40px;
68
109
  margin-top: 8px;
69
110
  border-style: dashed;
70
111
  color: var(--ui-muted);