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

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 { CopyContent } from "@getrefino/core";
2
2
  import type { JSX, ReactNode } from "react";
3
3
  import type { CopyRequestHeaders } from "./store.js";
4
4
  import type { EditorEventSink } from "./telemetry.js";
5
+ import type { IntegrationUpdateSource } from "./updates.js";
5
6
  export interface RefinoProviderProps {
6
7
  /** The canonical copy, usually `import copy from "./content/copy.json"`. */
7
8
  content: CopyContent;
@@ -32,6 +33,13 @@ export interface RefinoProviderProps {
32
33
  * sites get a sink that posts to Refino; everyone else gets silence.
33
34
  */
34
35
  onEvent?: EditorEventSink | undefined;
36
+ /**
37
+ * Lets the toolbar offer updates to the site's Refino integration. Asked
38
+ * once per editing session; nothing in the repository changes unless the
39
+ * owner presses "Update Refino". Refino-connected sites get this from
40
+ * their generated client; everyone else leaves it out and sees nothing.
41
+ */
42
+ updates?: IntegrationUpdateSource | undefined;
35
43
  /** Render the built-in toolbar in edit mode. Set false to use `useCopyEditor` with custom UI. */
36
44
  toolbar?: boolean | undefined;
37
45
  children?: ReactNode | undefined;
@@ -13,7 +13,7 @@ import { CopyEditorToolbar } from "./Toolbar.js";
13
13
  * the toolbar and editor styles.
14
14
  */
15
15
  export function RefinoProvider(props) {
16
- const { content, editing = false, endpoint = "/api/copy", headers, signInHref = "/edit", onExit, onEvent, toolbar = true, children } = props;
16
+ const { content, editing = false, endpoint = "/api/copy", headers, signInHref = "/edit", onExit, onEvent, updates, toolbar = true, children } = props;
17
17
  // Created once. The sink is read through the ref below, so a host that
18
18
  // passes a new closure on every render neither recreates the store nor
19
19
  // replays a lifecycle event.
@@ -53,6 +53,6 @@ export function RefinoProvider(props) {
53
53
  window.removeEventListener("keydown", onKeyDown);
54
54
  };
55
55
  }, [editing, store]);
56
- const value = useMemo(() => ({ store, onExit, signInHref }), [store, onExit, signInHref]);
56
+ const value = useMemo(() => ({ store, onExit, signInHref, updates }), [store, onExit, signInHref, updates]);
57
57
  return (_jsxs(CopyContext.Provider, { value: value, children: [children, editing && (_jsxs(_Fragment, { children: [_jsx("style", { "data-refino-styles": "", children: EDITOR_STYLES }), toolbar && _jsx(CopyEditorToolbar, {})] }))] }));
58
58
  }
package/dist/Toolbar.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback } from "react";
3
+ import { useCallback, useRef } from "react";
4
4
  import { INSTRUCTIONS_ID } from "./dom.js";
5
+ import { UpdateButton, UpdatePanel, useIntegrationUpdate } from "./UpdateNotice.js";
5
6
  import { useCopyEditor } from "./useCopyEditor.js";
6
7
  function plural(count, word) {
7
8
  return `${count} ${word}${count === 1 ? "" : "s"}`;
@@ -18,6 +19,8 @@ export function CopyEditorToolbar() {
18
19
  const dirtyCount = dirtyIds.length;
19
20
  const activeDirty = state.activeId !== null && dirtyIds.includes(state.activeId);
20
21
  const busy = state.status === "saving" || state.status === "loading";
22
+ const update = useIntegrationUpdate(editor.updates, state.loaded);
23
+ const updateButtonRef = useRef(null);
21
24
  const onRevertSelected = useCallback(() => {
22
25
  if (state.activeId)
23
26
  editor.revertValue(state.activeId);
@@ -50,5 +53,5 @@ export function CopyEditorToolbar() {
50
53
  statusText = dirtyCount > 0 ? `${plural(dirtyCount, "unsaved change")}` : "No changes";
51
54
  dotClass = dirtyCount > 0 ? "dirty" : "";
52
55
  }
53
- return (_jsxs("div", { className: "refino-toolbar", role: "region", "aria-label": "Copy editor", children: [_jsx("p", { id: INSTRUCTIONS_ID, className: "refino-sr-only", children: "Editable text. Type to change it. Press Enter to finish or Escape to cancel this edit." }), state.status === "conflict" && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--conflict", role: "alert", children: [_jsx("p", { children: "The copy file changed in the repository while you were editing (an AI agent or developer probably pushed). Your edits were not saved and nothing was overwritten." }), _jsxs("p", { children: ["Reload the latest copy and your ", plural(dirtyCount, "unsaved edit"), " will be reapplied on top of it for you to review and save again."] })] })), state.status === "error" && state.error && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--error", role: "alert", children: [_jsx("p", { children: state.error.message }), _jsxs("p", { children: [_jsx("code", { children: state.error.code }), state.error.code === "UNAUTHORIZED" && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: editor.signInHref, children: "Sign in again" })] }))] })] })), state.rebase && (_jsxs("div", { className: "refino-toolbar__notice", role: "status", children: [state.rebase.overlapping.length > 0 && (_jsxs("p", { children: ["Both you and the repository changed:", " ", state.rebase.overlapping.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), ". Your wording is shown; review before saving."] })), state.rebase.dropped.length > 0 && (_jsxs("p", { children: ["Dropped edits for fields that no longer exist:", " ", state.rebase.dropped.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), "."] }))] })), _jsxs("div", { className: "refino-toolbar__bar", role: "toolbar", "aria-label": "Copy editor controls", children: [_jsxs("span", { className: "refino-toolbar__status", "aria-live": "polite", children: [_jsx("span", { className: `refino-toolbar__dot${dotClass ? ` refino-toolbar__dot--${dotClass}` : ""}` }), statusText, state.status === "saved" && state.lastSave?.commitUrl && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: state.lastSave.commitUrl, target: "_blank", rel: "noreferrer", children: "view commit" })] }))] }), state.activeId && (_jsx("span", { className: "refino-toolbar__key", title: state.activeId, children: state.activeId })), state.status === "conflict" ? (_jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.reloadLatest(), children: "Reload latest" })) : (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "refino-toolbar__button", onMouseDown: (event) => event.preventDefault(), onClick: onRevertSelected, disabled: !activeDirty || busy, title: "Revert the selected text to its saved value", children: "Revert" }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: editor.revertAll, disabled: dirtyCount === 0 || busy, title: "Discard all unsaved changes", children: "Revert all" }), _jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.save(), disabled: dirtyCount === 0 || busy || !state.loaded, title: "Save all changes (Ctrl/Cmd+S)", children: state.status === "saving" ? "Saving…" : "Save" })] })), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => void editor.exit(), disabled: busy, children: "Exit" })] })] }));
56
+ return (_jsxs("div", { className: "refino-toolbar", role: "region", "aria-label": "Copy editor", children: [_jsx("p", { id: INSTRUCTIONS_ID, className: "refino-sr-only", children: "Editable text. Type to change it. Press Enter to finish or Escape to cancel this edit." }), _jsx(UpdatePanel, { control: update, saving: state.status === "saving", buttonRef: updateButtonRef }), state.status === "conflict" && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--conflict", role: "alert", children: [_jsx("p", { children: "The copy file changed in the repository while you were editing (an AI agent or developer probably pushed). Your edits were not saved and nothing was overwritten." }), _jsxs("p", { children: ["Reload the latest copy and your ", plural(dirtyCount, "unsaved edit"), " will be reapplied on top of it for you to review and save again."] })] })), state.status === "error" && state.error && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--error", role: "alert", children: [_jsx("p", { children: state.error.message }), _jsxs("p", { children: [_jsx("code", { children: state.error.code }), state.error.code === "UNAUTHORIZED" && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: editor.signInHref, children: "Sign in again" })] }))] })] })), state.rebase && (_jsxs("div", { className: "refino-toolbar__notice", role: "status", children: [state.rebase.overlapping.length > 0 && (_jsxs("p", { children: ["Both you and the repository changed:", " ", state.rebase.overlapping.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), ". Your wording is shown; review before saving."] })), state.rebase.dropped.length > 0 && (_jsxs("p", { children: ["Dropped edits for fields that no longer exist:", " ", state.rebase.dropped.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), "."] }))] })), _jsxs("div", { className: "refino-toolbar__bar", role: "toolbar", "aria-label": "Copy editor controls", children: [_jsxs("span", { className: "refino-toolbar__status", "aria-live": "polite", children: [_jsx("span", { className: `refino-toolbar__dot${dotClass ? ` refino-toolbar__dot--${dotClass}` : ""}` }), statusText, state.status === "saved" && state.lastSave?.commitUrl && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: state.lastSave.commitUrl, target: "_blank", rel: "noreferrer", children: "view commit" })] }))] }), state.activeId && (_jsx("span", { className: "refino-toolbar__key", title: state.activeId, children: state.activeId })), state.status === "conflict" ? (_jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.reloadLatest(), children: "Reload latest" })) : (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "refino-toolbar__button", onMouseDown: (event) => event.preventDefault(), onClick: onRevertSelected, disabled: !activeDirty || busy, title: "Revert the selected text to its saved value", children: "Revert" }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: editor.revertAll, disabled: dirtyCount === 0 || busy, title: "Discard all unsaved changes", children: "Revert all" }), _jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.save(), disabled: dirtyCount === 0 || busy || !state.loaded, title: "Save all changes (Ctrl/Cmd+S)", children: state.status === "saving" ? "Saving…" : "Save" })] })), _jsx(UpdateButton, { control: update, buttonRef: updateButtonRef }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => void editor.exit(), disabled: busy, children: "Exit" })] })] }));
54
57
  }
@@ -0,0 +1,51 @@
1
+ import type { JSX, RefObject } from "react";
2
+ import type { IntegrationUpdateResult, IntegrationUpdateSource, IntegrationUpdateStatus } from "./updates.js";
3
+ type Available = Extract<IntegrationUpdateStatus, {
4
+ state: "available";
5
+ }>;
6
+ export type UpdatePhase = {
7
+ readonly kind: "hidden";
8
+ } | {
9
+ readonly kind: "available";
10
+ readonly update: Available;
11
+ } | {
12
+ readonly kind: "updating";
13
+ readonly update: Available;
14
+ } | {
15
+ readonly kind: "updated";
16
+ readonly version: string;
17
+ } | {
18
+ readonly kind: "failed";
19
+ readonly update: Available;
20
+ readonly result: Extract<IntegrationUpdateResult, {
21
+ ok: false;
22
+ }>;
23
+ };
24
+ export interface IntegrationUpdateControl {
25
+ readonly phase: UpdatePhase;
26
+ readonly open: boolean;
27
+ setOpen(open: boolean): void;
28
+ /** Install the offered version. The only path to a repository change, and only ever from a click. */
29
+ apply(): Promise<void>;
30
+ /** Close the panel after a success or failure; a success also retires the toolbar control. */
31
+ close(): void;
32
+ }
33
+ /**
34
+ * Asks once per editing session, after the copy has loaded, whether the
35
+ * site's integration has an update, and runs the update when (and only
36
+ * when) the owner asks for it. No polling, no retries, no background work.
37
+ */
38
+ export declare function useIntegrationUpdate(source: IntegrationUpdateSource | undefined, ready: boolean): IntegrationUpdateControl;
39
+ /** The toolbar control: absent unless there is something to offer. */
40
+ export declare function UpdateButton({ control, buttonRef }: {
41
+ control: IntegrationUpdateControl;
42
+ buttonRef: RefObject<HTMLButtonElement | null>;
43
+ }): JSX.Element | null;
44
+ /** The compact panel above the toolbar. */
45
+ export declare function UpdatePanel({ control, saving, buttonRef, }: {
46
+ control: IntegrationUpdateControl;
47
+ /** A save is in flight; an update now would race it for the branch. */
48
+ saving: boolean;
49
+ buttonRef: RefObject<HTMLButtonElement | null>;
50
+ }): JSX.Element | null;
51
+ export {};
@@ -0,0 +1,136 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
4
+ import { readUpdateResult, readUpdateStatus } from "./updates.js";
5
+ /**
6
+ * Asks once per editing session, after the copy has loaded, whether the
7
+ * site's integration has an update, and runs the update when (and only
8
+ * when) the owner asks for it. No polling, no retries, no background work.
9
+ */
10
+ export function useIntegrationUpdate(source, ready) {
11
+ const [phase, setPhase] = useState({ kind: "hidden" });
12
+ const [open, setOpen] = useState(false);
13
+ const asked = useRef(false);
14
+ useEffect(() => {
15
+ if (!source || !ready || asked.current)
16
+ return;
17
+ asked.current = true;
18
+ // Whatever goes wrong while asking, the answer is "nothing to show".
19
+ void Promise.resolve()
20
+ .then(() => source.check())
21
+ .then(readUpdateStatus, () => ({ state: "current" }))
22
+ .then((status) => {
23
+ if (status.state === "available")
24
+ setPhase({ kind: "available", update: status });
25
+ });
26
+ }, [source, ready]);
27
+ const apply = useCallback(async () => {
28
+ if (!source || (phase.kind !== "available" && phase.kind !== "failed"))
29
+ return;
30
+ const update = phase.update;
31
+ if (!update.automatic)
32
+ return;
33
+ setPhase({ kind: "updating", update });
34
+ let result;
35
+ try {
36
+ result = readUpdateResult(await source.apply(update.version), update.version);
37
+ }
38
+ catch {
39
+ result = readUpdateResult(null, update.version);
40
+ }
41
+ setPhase(result.ok ? { kind: "updated", version: result.version } : { kind: "failed", update, result });
42
+ }, [phase, source]);
43
+ const close = useCallback(() => {
44
+ setOpen(false);
45
+ if (phase.kind === "updated")
46
+ setPhase({ kind: "hidden" });
47
+ else if (phase.kind === "failed")
48
+ setPhase({ kind: "available", update: phase.update });
49
+ }, [phase]);
50
+ return { phase, open, setOpen, apply, close };
51
+ }
52
+ function title(update) {
53
+ switch (update.urgency) {
54
+ case "required":
55
+ return "Refino update required";
56
+ case "recommended":
57
+ return "Refino update recommended";
58
+ default:
59
+ return "Refino update available";
60
+ }
61
+ }
62
+ function triggerLabel(phase) {
63
+ switch (phase.kind) {
64
+ case "available":
65
+ case "failed":
66
+ return phase.update.urgency === "required" ? "Update required" : phase.update.urgency === "recommended" ? "Update recommended" : "Update available";
67
+ case "updating":
68
+ return "Updating…";
69
+ case "updated":
70
+ return "Updated";
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+ /** The toolbar control: absent unless there is something to offer. */
76
+ export function UpdateButton({ control, buttonRef }) {
77
+ const label = triggerLabel(control.phase);
78
+ if (!label)
79
+ return null;
80
+ return (_jsxs("button", { ref: buttonRef, type: "button", className: "refino-toolbar__button refino-toolbar__button--update", "aria-haspopup": "dialog", "aria-expanded": control.open, onClick: () => control.setOpen(!control.open), children: [_jsx("span", { className: "refino-toolbar__dot refino-toolbar__dot--update", "aria-hidden": "true" }), label] }));
81
+ }
82
+ /** The compact panel above the toolbar. */
83
+ export function UpdatePanel({ control, saving, buttonRef, }) {
84
+ const { phase, open } = control;
85
+ const titleId = useId();
86
+ const focusRef = useRef(null);
87
+ const visible = open && phase.kind !== "hidden";
88
+ const dismiss = useCallback(() => {
89
+ if (phase.kind === "updating")
90
+ return;
91
+ control.close();
92
+ buttonRef.current?.focus();
93
+ }, [buttonRef, control, phase.kind]);
94
+ useEffect(() => {
95
+ if (visible)
96
+ focusRef.current?.focus();
97
+ }, [visible, phase.kind]);
98
+ if (!visible)
99
+ return null;
100
+ const onKeyDown = (event) => {
101
+ if (event.key === "Escape") {
102
+ event.stopPropagation();
103
+ dismiss();
104
+ }
105
+ };
106
+ let heading;
107
+ let body;
108
+ let actions;
109
+ switch (phase.kind) {
110
+ case "available": {
111
+ const { update } = phase;
112
+ heading = title(update);
113
+ body = (_jsxs(_Fragment, { children: [_jsxs("p", { children: ["Version ", update.version, " is available."] }), update.summary.length > 0 && (_jsx("ul", { className: "refino-update__summary", children: update.summary.map((line) => (_jsx("li", { children: line }, line))) })), update.automatic ? (_jsx("p", { className: "refino-update__note", children: "Your site redeploys with the update as usual. Your text and unsaved edits are not affected." })) : (_jsx("p", { className: "refino-update__note", children: update.message ?? "This update needs to be installed by whoever maintains your site's code." }))] }));
114
+ actions = update.automatic ? (_jsxs(_Fragment, { children: [_jsx("button", { ref: focusRef, type: "button", className: "refino-toolbar__button", onClick: dismiss, children: "Not now" }), _jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void control.apply(), disabled: saving, title: saving ? "Wait for the save to finish" : undefined, children: "Update Refino" })] })) : (_jsx("button", { ref: focusRef, type: "button", className: "refino-toolbar__button", onClick: dismiss, children: "Close" }));
115
+ break;
116
+ }
117
+ case "updating":
118
+ heading = "Updating Refino…";
119
+ body = _jsx("p", { children: "This usually takes a few seconds. Keep this page open." });
120
+ actions = _jsx(_Fragment, {});
121
+ break;
122
+ case "updated":
123
+ heading = `Refino updated to ${phase.version}`;
124
+ body = _jsx("p", { children: "Your site is redeploying with the update, which can take a few minutes. You can keep editing in the meantime." });
125
+ actions = (_jsx("button", { ref: focusRef, type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: dismiss, children: "Done" }));
126
+ break;
127
+ case "failed": {
128
+ const { result } = phase;
129
+ heading = result.unchanged ? "Refino was not updated" : "Update status unknown";
130
+ body = (_jsxs(_Fragment, { children: [_jsx("p", { children: result.message }), _jsx("p", { className: "refino-update__note", children: result.unchanged ? "Nothing on your site was changed." : "Reload this page in a minute to see whether the update went through." })] }));
131
+ actions = (_jsxs(_Fragment, { children: [_jsx("button", { ref: focusRef, type: "button", className: "refino-toolbar__button", onClick: dismiss, children: "Close" }), result.unchanged && (_jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void control.apply(), disabled: saving, children: "Try again" }))] }));
132
+ break;
133
+ }
134
+ }
135
+ return (_jsxs("div", { className: "refino-toolbar__notice refino-update", role: "dialog", "aria-labelledby": titleId, onKeyDown: onKeyDown, children: [_jsx("p", { id: titleId, className: "refino-update__title", "aria-live": "polite", children: heading }), body, phase.kind !== "updating" && _jsx("div", { className: "refino-update__actions", children: actions })] }));
136
+ }
package/dist/context.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { CopyEditorState, CopyStore } from "./store.js";
2
+ import type { IntegrationUpdateSource } from "./updates.js";
2
3
  export interface CopyContextValue {
3
4
  readonly store: CopyStore;
4
5
  readonly onExit: (() => void | Promise<void>) | undefined;
5
6
  /** Where to send the owner when the session has ended. */
6
7
  readonly signInHref: string;
8
+ /** Where the toolbar asks about, and installs, updates to the site's Refino integration. */
9
+ readonly updates: IntegrationUpdateSource | undefined;
7
10
  }
8
11
  export declare const CopyContext: import("react").Context<CopyContextValue | null>;
9
12
  export declare function useCopyContext(): CopyContextValue | null;
package/dist/index.d.ts CHANGED
@@ -8,4 +8,6 @@ export type { CopyEditor } from "./useCopyEditor.js";
8
8
  export { createCopyStore } from "./store.js";
9
9
  export type { CopyEditorState, CopyRequestHeaders, CopyStore, CopyStoreOptions, EditorError, EditorStatus, LastSave, RebaseInfo, } from "./store.js";
10
10
  export type { EditorEvent, EditorEventName, EditorEventProperties, EditorEventSink } from "./telemetry.js";
11
+ export { readUpdateResult, readUpdateStatus } from "./updates.js";
12
+ export type { IntegrationUpdateResult, IntegrationUpdateSource, IntegrationUpdateStatus, UpdateUrgency } from "./updates.js";
11
13
  export { EDITOR_STYLES } from "./styles.js";
package/dist/index.js CHANGED
@@ -3,4 +3,5 @@ export { EditableText } from "./EditableText.js";
3
3
  export { CopyEditorToolbar } from "./Toolbar.js";
4
4
  export { useCopyEditor } from "./useCopyEditor.js";
5
5
  export { createCopyStore } from "./store.js";
6
+ export { readUpdateResult, readUpdateStatus } from "./updates.js";
6
7
  export { EDITOR_STYLES } from "./styles.js";
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";
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;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Updating the site's Refino integration from the editor.
3
+ *
4
+ * The editor knows nothing about where updates come from. A host that can
5
+ * update itself passes an `IntegrationUpdateSource` (Refino-hosted sites get
6
+ * one generated for them) whose two methods resolve with whatever JSON the
7
+ * update service answered. Everything is validated here, in the package,
8
+ * rather than in the generated transport, so the generated code can stay a
9
+ * thin pipe that rarely needs to change, and an answer this version does not
10
+ * understand reads as "nothing to offer" rather than as an error.
11
+ */
12
+ export type UpdateUrgency = "optional" | "recommended" | "required";
13
+ export type IntegrationUpdateStatus = {
14
+ readonly state: "current";
15
+ } | {
16
+ readonly state: "available";
17
+ /** The version the update installs. */
18
+ readonly version: string;
19
+ readonly urgency: UpdateUrgency;
20
+ /** Plain-language highlights of the release, possibly none. */
21
+ readonly summary: readonly string[];
22
+ /** False when the update needs a developer; `message` then says so in plain words. */
23
+ readonly automatic: boolean;
24
+ readonly message: string | null;
25
+ };
26
+ export type IntegrationUpdateResult = {
27
+ readonly ok: true;
28
+ readonly version: string;
29
+ } | {
30
+ readonly ok: false;
31
+ readonly code: string;
32
+ readonly message: string;
33
+ /**
34
+ * Whether the repository was left exactly as it was. False only when
35
+ * the request's outcome is unknown (the connection dropped while it
36
+ * ran), never after a refusal.
37
+ */
38
+ readonly unchanged: boolean;
39
+ };
40
+ export interface IntegrationUpdateSource {
41
+ /** Resolve with the update service's JSON body for "is there an update?". */
42
+ check(): Promise<unknown>;
43
+ /** Resolve with the update service's JSON body for "install `version` now". */
44
+ apply(version: string): Promise<unknown>;
45
+ }
46
+ /** `{ ok: true, update: {...} }` → a status. Anything unexpected is "current": no noise. */
47
+ export declare function readUpdateStatus(body: unknown): IntegrationUpdateStatus;
48
+ /** `{ ok: true, result: { status: "updated", version } }` or `{ ok: false, error }` → a result. */
49
+ export declare function readUpdateResult(body: unknown, requested: string): IntegrationUpdateResult;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Updating the site's Refino integration from the editor.
3
+ *
4
+ * The editor knows nothing about where updates come from. A host that can
5
+ * update itself passes an `IntegrationUpdateSource` (Refino-hosted sites get
6
+ * one generated for them) whose two methods resolve with whatever JSON the
7
+ * update service answered. Everything is validated here, in the package,
8
+ * rather than in the generated transport, so the generated code can stay a
9
+ * thin pipe that rarely needs to change, and an answer this version does not
10
+ * understand reads as "nothing to offer" rather than as an error.
11
+ */
12
+ const CURRENT = { state: "current" };
13
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
14
+ const MAX_TEXT = 300;
15
+ function record(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
17
+ }
18
+ function text(value) {
19
+ return typeof value === "string" && value.trim().length > 0 ? value.trim().slice(0, MAX_TEXT) : null;
20
+ }
21
+ /** `{ ok: true, update: {...} }` → a status. Anything unexpected is "current": no noise. */
22
+ export function readUpdateStatus(body) {
23
+ const update = record(record(body)?.ok === true ? record(body).update : null);
24
+ if (!update || update.state !== "available")
25
+ return CURRENT;
26
+ const version = typeof update.version === "string" && VERSION.test(update.version) ? update.version : null;
27
+ if (!version)
28
+ return CURRENT;
29
+ const urgency = update.urgency === "recommended" || update.urgency === "required" ? update.urgency : "optional";
30
+ const summary = Array.isArray(update.summary) ? update.summary.map(text).filter((line) => line !== null).slice(0, 5) : [];
31
+ const automatic = update.automatic === true;
32
+ return { state: "available", version, urgency, summary, automatic, message: automatic ? null : text(update.message) };
33
+ }
34
+ const GENERIC_FAILURE = "Refino could not update this site.";
35
+ const UNKNOWN_OUTCOME = "Refino could not confirm whether the update finished.";
36
+ /** `{ ok: true, result: { status: "updated", version } }` or `{ ok: false, error }` → a result. */
37
+ export function readUpdateResult(body, requested) {
38
+ const value = record(body);
39
+ const result = record(value?.ok === true ? value.result : null);
40
+ if (result && (result.status === "updated" || result.status === "current")) {
41
+ const version = typeof result.version === "string" && VERSION.test(result.version) ? result.version : requested;
42
+ return { ok: true, version };
43
+ }
44
+ const error = record(value?.error);
45
+ const code = typeof error?.code === "string" ? error.code : "UNEXPECTED";
46
+ // "Nothing was changed" is only ever said when the service says so. A
47
+ // dropped connection, a proxy error page or an answer this version cannot
48
+ // read leaves the outcome unknown, and the owner is told exactly that.
49
+ const unchanged = error?.repositoryUnchanged === true;
50
+ const message = text(error?.message) ?? (unchanged ? GENERIC_FAILURE : UNKNOWN_OUTCOME);
51
+ return { ok: false, code, message, unchanged };
52
+ }
@@ -1,11 +1,14 @@
1
1
  import type { ContentId } from "@getrefino/core";
2
2
  import type { CopyEditorState } from "./store.js";
3
+ import type { IntegrationUpdateSource } from "./updates.js";
3
4
  export interface CopyEditor {
4
5
  readonly state: CopyEditorState;
5
6
  readonly dirtyIds: readonly ContentId[];
6
7
  readonly isDirty: boolean;
7
8
  /** Login page to offer when the endpoint reports the session ended. */
8
9
  readonly signInHref: string;
10
+ /** The provider's update source, if the host offers integration updates. */
11
+ readonly updates: IntegrationUpdateSource | undefined;
9
12
  save(): Promise<void>;
10
13
  revertValue(id: ContentId): void;
11
14
  revertAll(): void;
@@ -7,7 +7,7 @@ import { useCopyEditorState, useCopyStoreOrThrow } from "./context.js";
7
7
  * the default toolbar does not fit the host site.
8
8
  */
9
9
  export function useCopyEditor() {
10
- const { store, onExit, signInHref } = useCopyStoreOrThrow();
10
+ const { store, onExit, signInHref, updates } = useCopyStoreOrThrow();
11
11
  const state = useCopyEditorState(store);
12
12
  const dirtyIds = useMemo(() => getDirtyIds(state.draft), [state.draft]);
13
13
  const exit = useCallback(async () => {
@@ -24,11 +24,12 @@ export function useCopyEditor() {
24
24
  dirtyIds,
25
25
  isDirty: dirtyIds.length > 0,
26
26
  signInHref,
27
+ updates,
27
28
  save: store.save,
28
29
  revertValue: store.revertValue,
29
30
  revertAll: store.revertAll,
30
31
  reloadLatest: store.reloadLatest,
31
32
  dismissError: store.dismissError,
32
33
  exit,
33
- }), [state, dirtyIds, store, exit, signInHref]);
34
+ }), [state, dirtyIds, store, exit, signInHref, updates]);
34
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/react",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.5",
4
4
  "description": "React components for Refino: edit a website's existing copy in place on the rendered page and commit it to the repository. Click text. Edit text. Save.",
5
5
  "keywords": [
6
6
  "refino",
@@ -37,7 +37,7 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@getrefino/core": "0.1.0-rc.4"
40
+ "@getrefino/core": "0.1.0-rc.5"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.2.0 || ^19.0.0",