@getrefino/react 0.1.0-rc.4 → 0.1.0-rc.6

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/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ContentId, ContentSnapshot, CopyContent, DraftState } from "@getrefino/core";
1
+ import type { ContentId, ContentSnapshot, ContentSourceType, CopyContent, DocumentFieldInfo, DocumentId, DraftState, FieldId } from "@getrefino/core";
2
2
  import type { EditorEventSink } from "./telemetry.js";
3
3
  export type EditorStatus = "idle" | "loading" | "saving" | "saved" | "error" | "conflict";
4
4
  export interface EditorError {
@@ -14,6 +14,32 @@ export interface RebaseInfo {
14
14
  readonly overlapping: readonly ContentId[];
15
15
  readonly dropped: readonly ContentId[];
16
16
  }
17
+ /**
18
+ * An article rendered on this page, with the field values it rendered with.
19
+ * The page already read the file; passing those values means normal mode
20
+ * needs no editor code path and edit mode has something to show before the
21
+ * canonical version has loaded.
22
+ */
23
+ export interface ArticleDocument {
24
+ readonly documentId: DocumentId;
25
+ /** e.g. `{ "meta.title": "…", "meta.excerpt": "…", body: "…" }`. */
26
+ readonly fields: Readonly<Record<FieldId, string>>;
27
+ }
28
+ /** What the editor knows about one article. */
29
+ export interface ArticleState {
30
+ readonly documentId: DocumentId;
31
+ /** Null until the canonical article has loaded; a save is refused without it. */
32
+ readonly revision: string | null;
33
+ readonly path: string | null;
34
+ readonly sourceType: ContentSourceType | null;
35
+ /** Empty until loaded. Includes fields Refino can read but not write. */
36
+ readonly fields: readonly DocumentFieldInfo[];
37
+ }
38
+ /** Which editing surface is open on top of the page. */
39
+ export interface EditorPanel {
40
+ readonly kind: "body" | "metadata";
41
+ readonly documentId: DocumentId;
42
+ }
17
43
  /**
18
44
  * Everything the editor UI needs. Plain data, replaced immutably on every
19
45
  * change so React's `useSyncExternalStore` can subscribe to slices of it.
@@ -21,7 +47,7 @@ export interface RebaseInfo {
21
47
  export interface CopyEditorState {
22
48
  readonly editing: boolean;
23
49
  readonly draft: DraftState;
24
- /** Revision the draft is based on; null until the canonical copy has loaded. */
50
+ /** Revision the copy file draft is based on; null until the canonical copy has loaded. */
25
51
  readonly revision: string | null;
26
52
  readonly loaded: boolean;
27
53
  readonly status: EditorStatus;
@@ -31,6 +57,10 @@ export interface CopyEditorState {
31
57
  readonly activeId: ContentId | null;
32
58
  readonly lastSave: LastSave | null;
33
59
  readonly rebase: RebaseInfo | null;
60
+ /** Articles on this page, in the order the host declared them. */
61
+ readonly documentIds: readonly DocumentId[];
62
+ readonly documents: Readonly<Record<DocumentId, ArticleState>>;
63
+ readonly panel: EditorPanel | null;
34
64
  }
35
65
  export type CopyRequestHeaders = Readonly<Record<string, string>> | (() => Readonly<Record<string, string>>);
36
66
  export interface CopyStoreOptions {
@@ -63,7 +93,16 @@ export interface CopyStore {
63
93
  setActive(id: ContentId | null): void;
64
94
  /** Replace base content in normal mode (host re-rendered with new props). */
65
95
  setBaseContent(content: CopyContent): void;
96
+ /**
97
+ * Declare an article rendered on this page. Idempotent, so a component may
98
+ * call it on every mount; `<RefinoArticle>` does exactly that.
99
+ */
100
+ addDocument(document: ArticleDocument): void;
101
+ /** Withdraw an article: the page navigated away from it. */
102
+ removeDocument(documentId: DocumentId): void;
66
103
  setEditing(editing: boolean): void;
104
+ openPanel(panel: EditorPanel): void;
105
+ closePanel(): void;
67
106
  load(): Promise<void>;
68
107
  save(): Promise<void>;
69
108
  /** After a conflict: move the draft onto the latest upstream copy. */
package/dist/store.js CHANGED
@@ -1,4 +1,20 @@
1
- import { createDraft, getDirtyIds, getDraftValue, hasCopy, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, } from "@getrefino/core";
1
+ import { BODY_FIELD_ID, createDraft, documentFieldId, getDirtyIds, getDraftValue, hasCopy, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, splitDocumentFieldId, } from "@getrefino/core";
2
+ /** The flat ids one article's rendered values are held under. */
3
+ function initialFieldValues(document) {
4
+ const values = {};
5
+ for (const [fieldId, value] of Object.entries(document.fields)) {
6
+ values[documentFieldId(document.documentId, fieldId)] = value;
7
+ }
8
+ return values;
9
+ }
10
+ function sameFields(a, b) {
11
+ const aKeys = Object.keys(a.fields);
12
+ const bKeys = Object.keys(b.fields);
13
+ return aKeys.length === bKeys.length && aKeys.every((key) => a.fields[key] === b.fields[key]);
14
+ }
15
+ function emptyArticleState(documentId) {
16
+ return { documentId, revision: null, path: null, sourceType: null, fields: [] };
17
+ }
2
18
  export function createCopyStore(options) {
3
19
  const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
4
20
  const listeners = new Set();
@@ -18,6 +34,12 @@ export function createCopyStore(options) {
18
34
  * back must.
19
35
  */
20
36
  let openedReported = false;
37
+ /** The last canonical copy, and the last canonical articles, kept apart so
38
+ * either can be refreshed without discarding the other. */
39
+ let copyContent = options.content;
40
+ /** What the page rendered each article with, by article. */
41
+ const declared = new Map();
42
+ let initialFields = {};
21
43
  function report(event) {
22
44
  const sink = options.onEvent;
23
45
  if (!sink)
@@ -43,20 +65,74 @@ export function createCopyStore(options) {
43
65
  activeId: null,
44
66
  lastSave: null,
45
67
  rebase: null,
68
+ documentIds: [],
69
+ documents: {},
70
+ panel: null,
46
71
  };
47
72
  function update(patch) {
48
73
  state = { ...state, ...patch };
49
74
  for (const listener of listeners)
50
75
  listener();
51
76
  }
77
+ /**
78
+ * The draft's base: the canonical copy, then every article field. A field
79
+ * Refino can read but not write keeps whatever the page rendered with, so
80
+ * edit mode never shows a placeholder where the article shows a value.
81
+ */
82
+ function mergedBase(documents) {
83
+ const base = { ...copyContent, ...initialFields };
84
+ for (const article of Object.values(documents)) {
85
+ for (const field of article.fields) {
86
+ const id = documentFieldId(article.documentId, field.id);
87
+ if (field.editable || !(id in initialFields))
88
+ base[id] = field.value;
89
+ }
90
+ }
91
+ return base;
92
+ }
93
+ function rebuildDraft(documents, keepChanges) {
94
+ const base = mergedBase(documents);
95
+ if (!keepChanges) {
96
+ update({ documents, draft: createDraft(base), rebase: null });
97
+ return;
98
+ }
99
+ const result = rebaseDraft(state.draft, base);
100
+ update({
101
+ documents,
102
+ draft: result.draft,
103
+ rebase: result.overlapping.length > 0 || result.dropped.length > 0
104
+ ? { overlapping: result.overlapping, dropped: result.dropped }
105
+ : null,
106
+ });
107
+ }
108
+ function articleStatesFrom(snapshots) {
109
+ const documents = { ...state.documents };
110
+ for (const snapshot of snapshots) {
111
+ documents[snapshot.documentId] = {
112
+ documentId: snapshot.documentId,
113
+ revision: snapshot.revision,
114
+ path: snapshot.path,
115
+ sourceType: snapshot.sourceType,
116
+ fields: snapshot.fields,
117
+ };
118
+ }
119
+ return documents;
120
+ }
52
121
  function extraHeaders() {
53
122
  const headers = options.headers;
54
123
  return typeof headers === "function" ? headers() : (headers ?? {});
55
124
  }
56
- async function request(init) {
125
+ /** The load URL: the endpoint, plus the articles this page is showing. */
126
+ function loadUrl() {
127
+ if (state.documentIds.length === 0)
128
+ return options.endpoint;
129
+ const separator = options.endpoint.includes("?") ? "&" : "?";
130
+ return `${options.endpoint}${separator}documents=${encodeURIComponent(state.documentIds.join(","))}`;
131
+ }
132
+ async function request(url, init) {
57
133
  let response;
58
134
  try {
59
- response = await doFetch(options.endpoint, {
135
+ response = await doFetch(url, {
60
136
  credentials: "same-origin",
61
137
  ...init,
62
138
  headers: { accept: "application/json", ...extraHeaders(), ...(init?.headers ?? {}) },
@@ -87,21 +163,17 @@ export function createCopyStore(options) {
87
163
  },
88
164
  };
89
165
  }
90
- function applySnapshot(snapshot, keepChanges) {
91
- if (keepChanges) {
92
- const result = rebaseDraft(state.draft, snapshot.content);
93
- update({
94
- draft: result.draft,
95
- revision: snapshot.revision,
96
- loaded: true,
97
- rebase: result.overlapping.length > 0 || result.dropped.length > 0
98
- ? { overlapping: result.overlapping, dropped: result.dropped }
99
- : null,
100
- });
101
- }
102
- else {
103
- update({ draft: createDraft(snapshot.content), revision: snapshot.revision, loaded: true, rebase: null });
104
- }
166
+ function applySnapshot(snapshot, documents, keepChanges) {
167
+ copyContent = snapshot.content;
168
+ update({ revision: snapshot.revision, loaded: true });
169
+ rebuildDraft(documents ? articleStatesFrom(documents) : state.documents, keepChanges);
170
+ }
171
+ /** Which surface an id is edited on. A low-cardinality enum, never the id. */
172
+ function surfaceOf(id) {
173
+ const split = splitDocumentFieldId(state.documentIds, id);
174
+ if (!split)
175
+ return "inline_copy";
176
+ return split.fieldId === BODY_FIELD_ID ? "article_body" : "article_metadata";
105
177
  }
106
178
  return {
107
179
  getState: () => state,
@@ -122,13 +194,14 @@ export function createCopyStore(options) {
122
194
  // The transition from a clean draft to a dirty one, not every
123
195
  // keystroke: typing a word produces one event, not twenty.
124
196
  const started = dirtyCount(state.draft) === 0 && dirtyCount(draft) > 0;
197
+ const surface = surfaceOf(id);
125
198
  update({
126
199
  draft,
127
200
  status: state.status === "saved" || state.status === "error" ? "idle" : state.status,
128
201
  error: state.status === "error" ? null : state.error,
129
202
  });
130
203
  if (started)
131
- report({ name: "edit_started", properties: {} });
204
+ report({ name: "edit_started", properties: { edit_surface: surface } });
132
205
  },
133
206
  revertValue(id) {
134
207
  const draft = revertDraftValue(state.draft, id);
@@ -150,7 +223,38 @@ export function createCopyStore(options) {
150
223
  setBaseContent(content) {
151
224
  if (state.editing)
152
225
  return;
153
- update({ draft: createDraft(content) });
226
+ copyContent = content;
227
+ update({ draft: createDraft(mergedBase(state.documents)) });
228
+ },
229
+ addDocument(document) {
230
+ const existing = declared.get(document.documentId);
231
+ if (existing && sameFields(existing, document))
232
+ return;
233
+ declared.set(document.documentId, document);
234
+ initialFields = { ...initialFields, ...initialFieldValues(document) };
235
+ const documents = { ...state.documents };
236
+ documents[document.documentId] ??= emptyArticleState(document.documentId);
237
+ const documentIds = state.documentIds.includes(document.documentId)
238
+ ? state.documentIds
239
+ : [...state.documentIds, document.documentId];
240
+ update({ documentIds });
241
+ // Unsaved work on another article on the same page is kept.
242
+ rebuildDraft(documents, true);
243
+ },
244
+ removeDocument(documentId) {
245
+ if (!declared.has(documentId))
246
+ return;
247
+ declared.delete(documentId);
248
+ initialFields = {};
249
+ for (const document of declared.values())
250
+ Object.assign(initialFields, initialFieldValues(document));
251
+ const documents = { ...state.documents };
252
+ delete documents[documentId];
253
+ update({
254
+ documentIds: state.documentIds.filter((id) => id !== documentId),
255
+ panel: state.panel?.documentId === documentId ? null : state.panel,
256
+ });
257
+ rebuildDraft(documents, true);
154
258
  },
155
259
  setEditing(editing) {
156
260
  if (state.editing === editing)
@@ -159,13 +263,22 @@ export function createCopyStore(options) {
159
263
  // reports its own opening, whether or not the provider ever unmounted.
160
264
  if (!editing)
161
265
  openedReported = false;
162
- update({ editing });
266
+ update({ editing, panel: editing ? state.panel : null });
267
+ },
268
+ openPanel(panel) {
269
+ if (!state.documentIds.includes(panel.documentId))
270
+ return;
271
+ update({ panel });
272
+ },
273
+ closePanel() {
274
+ if (state.panel)
275
+ update({ panel: null });
163
276
  },
164
277
  async load() {
165
278
  update({ status: "loading", error: null });
166
- const body = await request({ method: "GET" });
279
+ const body = await request(loadUrl(), { method: "GET" });
167
280
  if (body.ok && "snapshot" in body) {
168
- applySnapshot(body.snapshot, getDirtyIds(state.draft).length > 0);
281
+ applySnapshot(body.snapshot, body.documents, getDirtyIds(state.draft).length > 0);
169
282
  update({ status: "idle" });
170
283
  // The editor is open once the canonical copy is actually in hand.
171
284
  if (state.editing && !openedReported) {
@@ -180,35 +293,68 @@ export function createCopyStore(options) {
180
293
  async save() {
181
294
  if (state.status === "saving")
182
295
  return;
183
- if (state.revision === null) {
184
- update({
185
- status: "error",
186
- error: { code: "NOT_LOADED", message: "The latest copy has not loaded yet. Try again." },
187
- });
188
- return;
189
- }
190
296
  const changes = state.draft.changes;
191
297
  const changedCount = Object.keys(changes).length;
192
298
  if (changedCount === 0) {
193
299
  update({ status: "saved", lastSave: { at: Date.now(), status: "unchanged" }, error: null });
194
300
  return;
195
301
  }
302
+ // Split the one draft into the copy file and each article. Ids carry
303
+ // their own document, so this needs no bookkeeping while typing.
304
+ const copyChanges = {};
305
+ const byDocument = new Map();
306
+ for (const [id, value] of Object.entries(changes)) {
307
+ const split = splitDocumentFieldId(state.documentIds, id);
308
+ if (!split) {
309
+ copyChanges[id] = value;
310
+ continue;
311
+ }
312
+ const fields = byDocument.get(split.documentId) ?? {};
313
+ fields[split.fieldId] = value;
314
+ byDocument.set(split.documentId, fields);
315
+ }
316
+ const copyCount = Object.keys(copyChanges).length;
317
+ if (copyCount > 0 && state.revision === null) {
318
+ update({ status: "error", error: { code: "NOT_LOADED", message: "The latest copy has not loaded yet. Try again." } });
319
+ return;
320
+ }
321
+ const documents = [];
322
+ for (const [documentId, fields] of byDocument) {
323
+ const revision = state.documents[documentId]?.revision;
324
+ if (!revision) {
325
+ update({ status: "error", error: { code: "NOT_LOADED", message: "The latest article has not loaded yet. Try again." } });
326
+ return;
327
+ }
328
+ documents.push({ documentId, baseRevision: revision, changes: fields });
329
+ }
196
330
  update({ status: "saving", error: null, conflict: null });
197
- const body = await request({
331
+ const body = await request(options.endpoint, {
198
332
  method: "POST",
199
333
  headers: { "content-type": "application/json" },
200
- body: JSON.stringify({ baseRevision: state.revision, changes }),
334
+ body: JSON.stringify({
335
+ ...(copyCount > 0 ? { baseRevision: state.revision, changes: copyChanges } : {}),
336
+ ...(documents.length > 0 ? { documents } : {}),
337
+ }),
201
338
  });
202
339
  if (body.ok && "result" in body) {
203
340
  const { result } = body;
204
- applySnapshot(result.snapshot, false);
205
- const lastSave = result.status === "saved" && result.commit?.url
206
- ? { at: Date.now(), status: result.status, commitUrl: result.commit.url }
207
- : { at: Date.now(), status: result.status };
341
+ const documentResults = result.documents ?? [];
342
+ if (result.status === "documents") {
343
+ rebuildDraft(articleStatesFrom(documentResults.map((entry) => entry.snapshot)), false);
344
+ }
345
+ else {
346
+ applySnapshot(result.snapshot, documentResults.map((entry) => entry.snapshot), false);
347
+ }
348
+ const wrote = (result.status === "saved") || documentResults.some((entry) => entry.status === "saved");
349
+ const commitUrl = (result.status === "saved" ? result.commit?.url : undefined) ??
350
+ documentResults.find((entry) => entry.commit?.url)?.commit?.url;
351
+ const lastSave = commitUrl
352
+ ? { at: Date.now(), status: wrote ? "saved" : "unchanged", commitUrl }
353
+ : { at: Date.now(), status: wrote ? "saved" : "unchanged" };
208
354
  update({ status: "saved", lastSave });
209
355
  // Only a save that produced a commit. An unchanged re-save wrote
210
356
  // nothing, so it is not an edit.
211
- if (result.status === "saved") {
357
+ if (wrote) {
212
358
  report({ name: "edit_saved", properties: { changed_count: changedCount } });
213
359
  }
214
360
  return;
@@ -225,16 +371,18 @@ export function createCopyStore(options) {
225
371
  },
226
372
  async reloadLatest() {
227
373
  const known = state.conflict;
228
- if (known) {
229
- applySnapshot(known, true);
374
+ // A copy conflict comes back with the latest copy attached; an article
375
+ // conflict does not, so that case reloads everything.
376
+ if (known && state.documentIds.length === 0) {
377
+ applySnapshot(known, undefined, true);
230
378
  update({ status: "idle", conflict: null, error: null });
231
379
  return;
232
380
  }
233
381
  update({ status: "loading", error: null });
234
- const body = await request({ method: "GET" });
382
+ const body = await request(loadUrl(), { method: "GET" });
235
383
  if (body.ok && "snapshot" in body) {
236
- applySnapshot(body.snapshot, true);
237
- update({ status: "idle", conflict: null });
384
+ applySnapshot(body.snapshot, body.documents, true);
385
+ update({ status: "idle", conflict: null, error: null });
238
386
  }
239
387
  else if (!body.ok) {
240
388
  update({ status: "error", error: body.error });
package/dist/styles.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * `outline` and `box-shadow` never affect layout, so text reflows exactly
5
5
  * as it does for visitors.
6
6
  */
7
- export declare const EDITOR_STYLES = "\n[data-refino-id] {\n cursor: text;\n outline: 1px dashed transparent;\n outline-offset: 3px;\n border-radius: 2px;\n transition: outline-color 120ms ease, box-shadow 120ms ease;\n}\n[data-refino-id]:hover {\n outline-color: rgba(37, 99, 235, 0.6);\n}\n[data-refino-id][data-refino-state=\"dirty\"] {\n outline-color: rgba(217, 119, 6, 0.75);\n}\n[data-refino-id][data-refino-state=\"active\"],\n[data-refino-id]:focus,\n[data-refino-id]:focus-visible {\n outline: 2px solid #2563eb;\n outline-offset: 3px;\n box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.14);\n}\n[data-refino-id]:empty::before {\n content: \"\\00a0\";\n}\n.refino-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n.refino-toolbar {\n all: initial;\n position: fixed;\n left: 50%;\n bottom: 16px;\n transform: translateX(-50%);\n z-index: 2147483000;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 8px;\n max-width: calc(100vw - 24px);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13px;\n line-height: 1.3;\n color: #f9fafb;\n box-sizing: border-box;\n}\n.refino-toolbar *, .refino-toolbar *::before, .refino-toolbar *::after {\n box-sizing: border-box;\n font: inherit;\n color: inherit;\n}\n.refino-toolbar__bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 8px 10px;\n background: #111827;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 999px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n}\n.refino-toolbar__status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 8px;\n min-width: 0;\n white-space: nowrap;\n color: #d1d5db;\n}\n.refino-toolbar__dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #6b7280;\n flex: none;\n}\n.refino-toolbar__dot--dirty { background: #f59e0b; }\n.refino-toolbar__dot--saving { background: #60a5fa; }\n.refino-toolbar__dot--saved { background: #34d399; }\n.refino-toolbar__dot--error, .refino-toolbar__dot--conflict { background: #f87171; }\n.refino-toolbar__key {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n color: #9ca3af;\n padding: 0 8px;\n max-width: 220px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.refino-toolbar__button {\n appearance: none;\n cursor: pointer;\n border: 1px solid transparent;\n border-radius: 999px;\n padding: 6px 12px;\n background: transparent;\n color: #e5e7eb;\n font-weight: 500;\n}\n.refino-toolbar__button:hover:not(:disabled) {\n background: rgba(255, 255, 255, 0.08);\n}\n.refino-toolbar__button:focus-visible {\n outline: 2px solid #93c5fd;\n outline-offset: 1px;\n}\n.refino-toolbar__button:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.refino-toolbar__button--primary {\n background: #2563eb;\n color: #ffffff;\n}\n.refino-toolbar__button--primary:hover:not(:disabled) {\n background: #1d4ed8;\n}\n.refino-toolbar__notice {\n padding: 10px 14px;\n border-radius: 12px;\n background: #1f2937;\n border: 1px solid rgba(255, 255, 255, 0.12);\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n max-width: 560px;\n color: #f3f4f6;\n}\n.refino-toolbar__notice--error { border-color: rgba(248, 113, 113, 0.6); }\n.refino-toolbar__notice--conflict { border-color: rgba(251, 191, 36, 0.6); }\n.refino-toolbar__notice p { margin: 0 0 6px; }\n.refino-toolbar__notice p:last-child { margin: 0; }\n.refino-toolbar__notice code {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n}\n.refino-toolbar__link {\n color: #93c5fd;\n text-decoration: underline;\n}\n@media (max-width: 520px) {\n .refino-toolbar { bottom: 8px; }\n .refino-toolbar__key { display: none; }\n}\n";
7
+ export declare const EDITOR_STYLES = "\n[data-refino-id] {\n cursor: text;\n outline: 1px dashed transparent;\n outline-offset: 3px;\n border-radius: 2px;\n transition: outline-color 120ms ease, box-shadow 120ms ease;\n}\n[data-refino-id]:hover {\n outline-color: rgba(37, 99, 235, 0.6);\n}\n[data-refino-id][data-refino-state=\"dirty\"] {\n outline-color: rgba(217, 119, 6, 0.75);\n}\n[data-refino-id][data-refino-state=\"active\"],\n[data-refino-id]:focus,\n[data-refino-id]:focus-visible {\n outline: 2px solid #2563eb;\n outline-offset: 3px;\n box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.14);\n}\n[data-refino-id]:empty::before {\n content: \"\\00a0\";\n}\n.refino-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n.refino-toolbar {\n all: initial;\n position: fixed;\n left: 50%;\n bottom: 16px;\n transform: translateX(-50%);\n z-index: 2147483000;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 8px;\n max-width: calc(100vw - 24px);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13px;\n line-height: 1.3;\n color: #f9fafb;\n box-sizing: border-box;\n}\n.refino-toolbar *, .refino-toolbar *::before, .refino-toolbar *::after {\n box-sizing: border-box;\n font: inherit;\n color: inherit;\n}\n.refino-toolbar__bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 8px 10px;\n background: #111827;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 999px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n}\n.refino-toolbar__status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 8px;\n min-width: 0;\n white-space: nowrap;\n color: #d1d5db;\n}\n.refino-toolbar__dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #6b7280;\n flex: none;\n}\n.refino-toolbar__dot--dirty { background: #f59e0b; }\n.refino-toolbar__dot--saving { background: #60a5fa; }\n.refino-toolbar__dot--saved { background: #34d399; }\n.refino-toolbar__dot--error, .refino-toolbar__dot--conflict { background: #f87171; }\n.refino-toolbar__key {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n color: #9ca3af;\n padding: 0 8px;\n max-width: 220px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.refino-toolbar__button {\n appearance: none;\n cursor: pointer;\n border: 1px solid transparent;\n border-radius: 999px;\n padding: 6px 12px;\n background: transparent;\n color: #e5e7eb;\n font-weight: 500;\n}\n.refino-toolbar__button:hover:not(:disabled) {\n background: rgba(255, 255, 255, 0.08);\n}\n.refino-toolbar__button:focus-visible {\n outline: 2px solid #93c5fd;\n outline-offset: 1px;\n}\n.refino-toolbar__button:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.refino-toolbar__button--primary {\n background: #2563eb;\n color: #ffffff;\n}\n.refino-toolbar__button--primary:hover:not(:disabled) {\n background: #1d4ed8;\n}\n.refino-toolbar__notice {\n padding: 10px 14px;\n border-radius: 12px;\n background: #1f2937;\n border: 1px solid rgba(255, 255, 255, 0.12);\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n max-width: 560px;\n color: #f3f4f6;\n}\n.refino-toolbar__notice--error { border-color: rgba(248, 113, 113, 0.6); }\n.refino-toolbar__notice--conflict { border-color: rgba(251, 191, 36, 0.6); }\n.refino-toolbar__notice p { margin: 0 0 6px; }\n.refino-toolbar__notice p:last-child { margin: 0; }\n.refino-toolbar__notice code {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n}\n.refino-toolbar__button--update {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n border-color: rgba(147, 197, 253, 0.45);\n color: #dbeafe;\n}\n.refino-toolbar__dot--update { background: #60a5fa; }\n.refino-update {\n width: min(360px, calc(100vw - 24px));\n}\n.refino-update__title {\n font-weight: 600;\n font-size: 14px;\n}\n.refino-update__summary {\n margin: 0 0 8px;\n padding-left: 18px;\n}\n.refino-update__summary li { margin: 0 0 2px; }\n.refino-toolbar__notice p.refino-update__note {\n color: #9ca3af;\n font-size: 12px;\n}\n.refino-update__actions {\n display: flex;\n justify-content: flex-end;\n gap: 6px;\n margin-top: 10px;\n}\n.refino-toolbar__link {\n color: #93c5fd;\n text-decoration: underline;\n}\n@media (max-width: 520px) {\n .refino-toolbar { bottom: 8px; }\n .refino-toolbar__key { display: none; }\n}\n\n[data-refino-body] {\n position: relative;\n outline: 1px dashed rgba(37, 99, 235, 0.45);\n outline-offset: 8px;\n border-radius: 2px;\n}\n[data-refino-body][data-refino-state=\"dirty\"] {\n outline-color: rgba(217, 119, 6, 0.8);\n}\n.refino-body__edit {\n all: initial;\n position: absolute;\n top: -14px;\n right: 0;\n z-index: 2147482000;\n cursor: pointer;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 12px;\n font-weight: 500;\n line-height: 1;\n color: #ffffff;\n background: #2563eb;\n border-radius: 999px;\n padding: 6px 12px;\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);\n}\n.refino-body__edit:hover { background: #1d4ed8; }\n.refino-body__edit:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }\n[data-refino-body][data-refino-state=\"dirty\"] .refino-body__edit { background: #b45309; }\n.refino-panel {\n all: initial;\n position: fixed;\n right: 16px;\n bottom: 76px;\n z-index: 2147483100;\n display: flex;\n flex-direction: column;\n width: min(760px, calc(100vw - 32px));\n max-height: min(70vh, 720px);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13px;\n line-height: 1.45;\n color: #f3f4f6;\n background: #111827;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 14px;\n box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);\n box-sizing: border-box;\n overflow: hidden;\n}\n.refino-panel *, .refino-panel *::before, .refino-panel *::after {\n box-sizing: border-box;\n font: inherit;\n color: inherit;\n}\n.refino-panel__head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 12px 14px;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n}\n.refino-panel__title { font-weight: 600; }\n.refino-panel__path {\n display: block;\n margin-top: 2px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n color: #9ca3af;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 60ch;\n}\n.refino-panel__close {\n appearance: none;\n cursor: pointer;\n border: 1px solid transparent;\n border-radius: 999px;\n padding: 6px 14px;\n background: #2563eb;\n color: #ffffff;\n font-weight: 500;\n flex: none;\n}\n.refino-panel__close:hover { background: #1d4ed8; }\n.refino-panel__close:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }\n.refino-panel__body {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px 14px 14px;\n overflow: auto;\n min-height: 0;\n}\n.refino-panel__hint, .refino-panel__notice { margin: 0; color: #d1d5db; }\n.refino-panel__notice { color: #fcd34d; }\n.refino-panel__source {\n appearance: none;\n width: 100%;\n min-height: 320px;\n resize: vertical;\n padding: 12px;\n background: #0b1220;\n color: #e5e7eb;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 10px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12.5px;\n line-height: 1.6;\n tab-size: 2;\n white-space: pre;\n overflow-wrap: normal;\n}\n.refino-panel__source:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }\n.refino-panel__fields {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n gap: 10px 14px;\n}\n.refino-panel__field { display: flex; flex-direction: column; gap: 4px; }\n.refino-panel__label {\n display: flex;\n align-items: baseline;\n gap: 6px;\n color: #d1d5db;\n font-size: 12px;\n}\n.refino-panel__key {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n color: #6b7280;\n}\n.refino-panel__field[data-refino-state=\"dirty\"] .refino-panel__label { color: #fbbf24; }\n.refino-panel__input {\n appearance: none;\n width: 100%;\n padding: 8px 10px;\n background: #0b1220;\n color: #e5e7eb;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 8px;\n}\n.refino-panel__input--multiline { min-height: 72px; resize: vertical; line-height: 1.5; }\n.refino-panel__input:disabled { opacity: 0.55; cursor: not-allowed; }\n.refino-panel__input:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }\n.refino-panel__field[data-refino-state=\"dirty\"] .refino-panel__input { border-color: rgba(251, 191, 36, 0.7); }\n.refino-panel__reason { color: #9ca3af; font-size: 11px; }\n.refino-panel__actions {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n flex-wrap: wrap;\n}\n.refino-panel__state { color: #9ca3af; font-size: 12px; }\n@media (max-width: 520px) {\n .refino-panel { right: 8px; left: 8px; width: auto; bottom: 64px; max-height: 76vh; }\n}\n";
package/dist/styles.js CHANGED
@@ -149,6 +149,36 @@ export const EDITOR_STYLES = `
149
149
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
150
150
  font-size: 12px;
151
151
  }
152
+ .refino-toolbar__button--update {
153
+ display: inline-flex;
154
+ align-items: center;
155
+ gap: 6px;
156
+ border-color: rgba(147, 197, 253, 0.45);
157
+ color: #dbeafe;
158
+ }
159
+ .refino-toolbar__dot--update { background: #60a5fa; }
160
+ .refino-update {
161
+ width: min(360px, calc(100vw - 24px));
162
+ }
163
+ .refino-update__title {
164
+ font-weight: 600;
165
+ font-size: 14px;
166
+ }
167
+ .refino-update__summary {
168
+ margin: 0 0 8px;
169
+ padding-left: 18px;
170
+ }
171
+ .refino-update__summary li { margin: 0 0 2px; }
172
+ .refino-toolbar__notice p.refino-update__note {
173
+ color: #9ca3af;
174
+ font-size: 12px;
175
+ }
176
+ .refino-update__actions {
177
+ display: flex;
178
+ justify-content: flex-end;
179
+ gap: 6px;
180
+ margin-top: 10px;
181
+ }
152
182
  .refino-toolbar__link {
153
183
  color: #93c5fd;
154
184
  text-decoration: underline;
@@ -157,4 +187,165 @@ export const EDITOR_STYLES = `
157
187
  .refino-toolbar { bottom: 8px; }
158
188
  .refino-toolbar__key { display: none; }
159
189
  }
190
+
191
+ [data-refino-body] {
192
+ position: relative;
193
+ outline: 1px dashed rgba(37, 99, 235, 0.45);
194
+ outline-offset: 8px;
195
+ border-radius: 2px;
196
+ }
197
+ [data-refino-body][data-refino-state="dirty"] {
198
+ outline-color: rgba(217, 119, 6, 0.8);
199
+ }
200
+ .refino-body__edit {
201
+ all: initial;
202
+ position: absolute;
203
+ top: -14px;
204
+ right: 0;
205
+ z-index: 2147482000;
206
+ cursor: pointer;
207
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
208
+ font-size: 12px;
209
+ font-weight: 500;
210
+ line-height: 1;
211
+ color: #ffffff;
212
+ background: #2563eb;
213
+ border-radius: 999px;
214
+ padding: 6px 12px;
215
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
216
+ }
217
+ .refino-body__edit:hover { background: #1d4ed8; }
218
+ .refino-body__edit:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
219
+ [data-refino-body][data-refino-state="dirty"] .refino-body__edit { background: #b45309; }
220
+ .refino-panel {
221
+ all: initial;
222
+ position: fixed;
223
+ right: 16px;
224
+ bottom: 76px;
225
+ z-index: 2147483100;
226
+ display: flex;
227
+ flex-direction: column;
228
+ width: min(760px, calc(100vw - 32px));
229
+ max-height: min(70vh, 720px);
230
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
231
+ font-size: 13px;
232
+ line-height: 1.45;
233
+ color: #f3f4f6;
234
+ background: #111827;
235
+ border: 1px solid rgba(255, 255, 255, 0.14);
236
+ border-radius: 14px;
237
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
238
+ box-sizing: border-box;
239
+ overflow: hidden;
240
+ }
241
+ .refino-panel *, .refino-panel *::before, .refino-panel *::after {
242
+ box-sizing: border-box;
243
+ font: inherit;
244
+ color: inherit;
245
+ }
246
+ .refino-panel__head {
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: space-between;
250
+ gap: 12px;
251
+ padding: 12px 14px;
252
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
253
+ }
254
+ .refino-panel__title { font-weight: 600; }
255
+ .refino-panel__path {
256
+ display: block;
257
+ margin-top: 2px;
258
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
259
+ font-size: 11px;
260
+ color: #9ca3af;
261
+ overflow: hidden;
262
+ text-overflow: ellipsis;
263
+ white-space: nowrap;
264
+ max-width: 60ch;
265
+ }
266
+ .refino-panel__close {
267
+ appearance: none;
268
+ cursor: pointer;
269
+ border: 1px solid transparent;
270
+ border-radius: 999px;
271
+ padding: 6px 14px;
272
+ background: #2563eb;
273
+ color: #ffffff;
274
+ font-weight: 500;
275
+ flex: none;
276
+ }
277
+ .refino-panel__close:hover { background: #1d4ed8; }
278
+ .refino-panel__close:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
279
+ .refino-panel__body {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 10px;
283
+ padding: 12px 14px 14px;
284
+ overflow: auto;
285
+ min-height: 0;
286
+ }
287
+ .refino-panel__hint, .refino-panel__notice { margin: 0; color: #d1d5db; }
288
+ .refino-panel__notice { color: #fcd34d; }
289
+ .refino-panel__source {
290
+ appearance: none;
291
+ width: 100%;
292
+ min-height: 320px;
293
+ resize: vertical;
294
+ padding: 12px;
295
+ background: #0b1220;
296
+ color: #e5e7eb;
297
+ border: 1px solid rgba(255, 255, 255, 0.14);
298
+ border-radius: 10px;
299
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
300
+ font-size: 12.5px;
301
+ line-height: 1.6;
302
+ tab-size: 2;
303
+ white-space: pre;
304
+ overflow-wrap: normal;
305
+ }
306
+ .refino-panel__source:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
307
+ .refino-panel__fields {
308
+ display: grid;
309
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
310
+ gap: 10px 14px;
311
+ }
312
+ .refino-panel__field { display: flex; flex-direction: column; gap: 4px; }
313
+ .refino-panel__label {
314
+ display: flex;
315
+ align-items: baseline;
316
+ gap: 6px;
317
+ color: #d1d5db;
318
+ font-size: 12px;
319
+ }
320
+ .refino-panel__key {
321
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
322
+ font-size: 11px;
323
+ color: #6b7280;
324
+ }
325
+ .refino-panel__field[data-refino-state="dirty"] .refino-panel__label { color: #fbbf24; }
326
+ .refino-panel__input {
327
+ appearance: none;
328
+ width: 100%;
329
+ padding: 8px 10px;
330
+ background: #0b1220;
331
+ color: #e5e7eb;
332
+ border: 1px solid rgba(255, 255, 255, 0.14);
333
+ border-radius: 8px;
334
+ }
335
+ .refino-panel__input--multiline { min-height: 72px; resize: vertical; line-height: 1.5; }
336
+ .refino-panel__input:disabled { opacity: 0.55; cursor: not-allowed; }
337
+ .refino-panel__input:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
338
+ .refino-panel__field[data-refino-state="dirty"] .refino-panel__input { border-color: rgba(251, 191, 36, 0.7); }
339
+ .refino-panel__reason { color: #9ca3af; font-size: 11px; }
340
+ .refino-panel__actions {
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: space-between;
344
+ gap: 10px;
345
+ flex-wrap: wrap;
346
+ }
347
+ .refino-panel__state { color: #9ca3af; font-size: 12px; }
348
+ @media (max-width: 520px) {
349
+ .refino-panel { right: 8px; left: 8px; width: auto; bottom: 64px; max-height: 76vh; }
350
+ }
160
351
  `;
@@ -21,9 +21,18 @@ export type EditorEventName =
21
21
  | "edit_saved"
22
22
  /** A draft change was thrown away. */
23
23
  | "edit_reverted";
24
+ /**
25
+ * Which editing surface an edit happened on. Three values, forever: it
26
+ * answers "is anyone using article editing", and nothing finer. It is not a
27
+ * page, not a file and not an id.
28
+ */
29
+ export type EditorSurface = "inline_copy" | "article_metadata" | "article_body";
24
30
  export interface EditorEventProperties {
25
31
  editor_opened: Record<string, never>;
26
- edit_started: Record<string, never>;
32
+ /** Which surface the owner started typing on. An enum of three, never the id. */
33
+ edit_started: {
34
+ readonly edit_surface: EditorSurface;
35
+ };
27
36
  /** How many ids were in the save. A count, never the ids. */
28
37
  edit_saved: {
29
38
  readonly changed_count: number;