+
+ ⋮⋮
+
+
+
+ {expanded && (
+ {`Visual editing ${
+ enabled ? 'on' : 'off'
+ }`}
+ )}
+
+
+ {expanded && (
+
+ {temporary
+ ? 'Temporary · release ⌥ Alt to revert'
+ : `Hold ⌥ Alt to ${enabled ? 'hide' : 'peek'}`}
+
+ )}
+
+ {expanded && (
+
+ )}
+ {expanded && exitDraftModeUrl && (
+
+ Exit preview
+
+ )}
+
+
+ {announcement}
+
+
+ >,
+ shadowRoot,
+ );
+}
diff --git a/src/ContentLink/VisualEditingToggle/styles.ts b/src/ContentLink/VisualEditingToggle/styles.ts
new file mode 100644
index 0000000..950c6c2
--- /dev/null
+++ b/src/ContentLink/VisualEditingToggle/styles.ts
@@ -0,0 +1,56 @@
+// Lives inside the widget's Shadow DOM: site CSS can't reach it, and it can't
+// reach the site.
+export function buildStyles(accent: string): string {
+ return `
+ :host { all: initial; }
+ .widget {
+ position: fixed; z-index: 2147483647;
+ cursor: grab; touch-action: none; user-select: none;
+ display: flex; align-items: center; gap: 10px;
+ padding: 6px 6px 6px 8px; border-radius: 6px;
+ background: ${accent}; color: #fff;
+ font: 600 13px/1.2 system-ui, -apple-system, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.15), 0 1px 3px rgb(0 0 0 / 0.1);
+ }
+ .widget.top { top: 16px; }
+ .widget.bottom { bottom: 16px; }
+ .widget.left { left: 16px; }
+ .widget.right { right: 16px; }
+ .widget.dragging { cursor: grabbing; box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); }
+ .grip { color: rgb(255 255 255 / 0.6); font-size: 12px; letter-spacing: -2px; border-radius: 2px; }
+ .grip:focus-visible { outline: 2px solid #fff; outline-offset: 2px; color: #fff; }
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+ .text { display: flex; flex-direction: column; gap: 3px; }
+ .row { display: flex; align-items: center; gap: 8px; }
+ .label { white-space: nowrap; }
+ .hint { font-weight: 400; font-size: 11px; color: rgb(255 255 255 / 0.85); white-space: nowrap; }
+ .temp .hint { color: #fff; font-weight: 600; }
+ button, a {
+ all: unset; cursor: pointer; box-sizing: border-box; white-space: nowrap;
+ border-radius: 4px; transition: opacity 0.2s;
+ }
+ button:hover, a:hover { opacity: 0.85; }
+ button:active, a:active { opacity: 0.7; }
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
+ button:focus-visible, a:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+ .flash { padding: 6px 10px; background: #fff; color: ${accent}; }
+ .exit { padding: 5px 9px; border: 1px solid rgb(255 255 255 / 0.7); }
+ .switch {
+ position: relative; flex: none; width: 32px; height: 18px; border-radius: 18px;
+ background: rgb(0 0 0 / 0.25); transition: background 0.2s;
+ }
+ .switch::after {
+ content: ''; position: absolute; top: 2px; left: 2px;
+ width: 14px; height: 14px; border-radius: 50%; background: #fff;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.2); transition: left 0.2s;
+ }
+ .on .switch { background: #fff; }
+ .on .switch::after { left: 16px; background: ${accent}; }
+ .collapse {
+ width: 26px; height: 26px; display: grid; place-items: center;
+ color: #fff; font-size: 16px; font-weight: 400;
+ }
+ .collapse:hover { background: rgb(0 0 0 / 0.15); opacity: 1; }
+ `;
+}
diff --git a/src/ContentLink/VisualEditingToggle/utils.ts b/src/ContentLink/VisualEditingToggle/utils.ts
new file mode 100644
index 0000000..7330c24
--- /dev/null
+++ b/src/ContentLink/VisualEditingToggle/utils.ts
@@ -0,0 +1,84 @@
+export type Position = { x: number; y: number };
+
+/** Inside an iframe (e.g. the Web Previews plugin), which has its own toggle. */
+export function isInIframe(): boolean {
+ return window.parent !== window;
+}
+
+// Storage can be missing (SSR) or throw (private mode, blocked site data): in both
+// cases the widget still works, it just doesn't remember anything.
+
+export function readBoolean(key: string, fallback: boolean): boolean {
+ try {
+ const value = window.localStorage.getItem(key);
+ return value === null ? fallback : value === 'true';
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeBoolean(key: string, value: boolean): void {
+ try {
+ window.localStorage.setItem(key, String(value));
+ } catch {}
+}
+
+export function readPosition(key: string): Position | null {
+ try {
+ const value = JSON.parse(window.localStorage.getItem(key) || 'null');
+ return value && Number.isFinite(value.x) && Number.isFinite(value.y)
+ ? { x: value.x, y: value.y }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writePosition(key: string, value: Position | null): void {
+ try {
+ if (value) {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } else {
+ window.localStorage.removeItem(key);
+ }
+ } catch {}
+}
+
+/**
+ * Same color as the Content Link overlay borders and "Open in DatoCMS" labels.
+ *
+ * Copied from @datocms/content-link (src/createController/clickToEdit/constants.ts),
+ * which doesn't export it: the darkest-needed HSL lightness (S=100%) that keeps
+ * white text at ≥3.5:1 contrast.
+ */
+export function overlayColor(hue: number): string {
+ const hslToRgb = (h: number, l: number) => {
+ const a = Math.min(l, 1 - l);
+ const f = (n: number) => {
+ const k = (n + h / 30) % 12;
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
+ };
+ return [f(0), f(8), f(4)].map((c) => Math.round(c * 255));
+ };
+ const luminance = ([r, g, b]: number[]) => {
+ const lin = (c: number) =>
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ return (
+ 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255)
+ );
+ };
+
+ let lo = 0;
+ let hi = 0.5;
+ let lightness = 0;
+ while (hi - lo > 0.001) {
+ const mid = (lo + hi) / 2;
+ if (1.05 / (luminance(hslToRgb(hue, mid)) + 0.05) >= 3.5) {
+ lightness = mid;
+ lo = mid + 0.001;
+ } else {
+ hi = mid - 0.001;
+ }
+ }
+ return `hsl(${hue}, 100%, ${Math.round(lightness * 100)}%)`;
+}
diff --git a/src/ContentLink/__tests__/VisualEditingToggle.test.tsx b/src/ContentLink/__tests__/VisualEditingToggle.test.tsx
new file mode 100644
index 0000000..667f40c
--- /dev/null
+++ b/src/ContentLink/__tests__/VisualEditingToggle.test.tsx
@@ -0,0 +1,298 @@
+import type { Controller } from '@datocms/content-link';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { act } from 'react-dom/test-utils';
+import {
+ VisualEditingToggle,
+ type VisualEditingToggleProps,
+} from '../VisualEditingToggle/index.js';
+import * as utils from '../VisualEditingToggle/utils.js';
+
+const TOGGLE_EVENT = 'datocms:click-to-edit:toggle';
+
+// Behaves like the real controller: every change dispatches the toggle event.
+function fakeController() {
+ let enabled = false;
+ const set = (value: boolean) => {
+ enabled = value;
+ document.dispatchEvent(new CustomEvent(TOGGLE_EVENT, { detail: value }));
+ };
+ return {
+ enableClickToEdit: jest.fn(() => set(true)),
+ disableClickToEdit: jest.fn(() => set(false)),
+ isClickToEditEnabled: () => enabled,
+ flashAll: jest.fn(),
+ // What the library does while Alt/Option is held
+ simulateAlt: (value: boolean) => set(value),
+ };
+}
+
+type Fake = ReturnType