@obelism/content 0.26.0 → 0.27.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.
package/dist/bridge.js CHANGED
@@ -17,15 +17,15 @@ const DEFAULT_STATS_LABELS = {
17
17
  clicked: '{rate}% clicked',
18
18
  title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)'
19
19
  };
20
- const fill = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
21
- // The same tokens as the prompt box: near-black pill, beige text, the orange
20
+ const fill$1 = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
21
+ // The same tokens as the prompt box: an ink pill, paper text, the orange
22
22
  // accent for the rate.
23
- const CSS$1 = `
23
+ const CSS$2 = `
24
24
  :host { all: initial; }
25
25
  .layer {
26
26
  position: fixed; inset: 0; pointer-events: none;
27
27
  z-index: 2147483646;
28
- font: 600 12px/1 'InterVariable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
+ font: 600 12px/1 'Geist', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
29
29
  -webkit-font-smoothing: antialiased;
30
30
  }
31
31
  .badge {
@@ -33,8 +33,8 @@ const CSS$1 = `
33
33
  display: inline-flex; gap: 6px; align-items: center;
34
34
  padding: 6px 10px; white-space: nowrap; cursor: pointer;
35
35
  pointer-events: auto; border: 0;
36
- color: rgb(238 236 233); background: rgb(12 12 12 / .88);
37
- border-radius: 5em; box-shadow: 0 6px 16px -8px rgb(12 12 12 / .5);
36
+ color: rgb(242 240 236); background: rgb(23 22 20 / .88);
37
+ border-radius: 5em; box-shadow: 0 6px 16px -8px rgb(23 22 20 / .5);
38
38
  font: inherit;
39
39
  }
40
40
  .badge::before {
@@ -83,7 +83,7 @@ const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=
83
83
  mode: 'open'
84
84
  });
85
85
  const style = doc.createElement('style');
86
- style.textContent = CSS$1;
86
+ style.textContent = CSS$2;
87
87
  const layer = doc.createElement('div');
88
88
  layer.className = 'layer';
89
89
  shadow.append(style, layer);
@@ -108,10 +108,10 @@ const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=
108
108
  badge.type = 'button';
109
109
  badge.className = 'badge';
110
110
  badge.dataset.uid = uid;
111
- badge.textContent = fill(labels.clicked, {
111
+ badge.textContent = fill$1(labels.clicked, {
112
112
  rate: Math.round(block.rate * 100)
113
113
  });
114
- badge.title = fill(labels.title, {
114
+ badge.title = fill$1(labels.title, {
115
115
  clickers: block.clickers,
116
116
  viewers: block.viewers,
117
117
  clicks: block.clicks
@@ -617,29 +617,128 @@ const wrapScriptBridge = (script, params)=>{
617
617
  };
618
618
  };
619
619
 
620
- const DEFAULT_PROMPT_LABELS = {
620
+ // Plain DOM helpers for the bridge's shadow-root UIs (the prompt box, the
621
+ // on-site editor).
622
+ const element = (doc, tag, props = {}, ...children)=>{
623
+ const node = doc.createElement(tag);
624
+ Object.assign(node, props);
625
+ // appendChild, not append: Workers types redefine Element.append, and a
626
+ // site's server code may well have them loaded.
627
+ for (const child of children){
628
+ node.appendChild(typeof child === 'string' ? doc.createTextNode(child) : child);
629
+ }
630
+ return node;
631
+ };
632
+ // The obelism marks (U+205C dotted cross, the logo; U+2059 five dot
633
+ // punctuation, working), as the design system draws them: filled, on
634
+ // currentColor, 24px grid.
635
+ const MARKS = {
636
+ obelism: '<rect x="10" y="2" width="4" height="20"/><rect x="2" y="10" width="20" height="4"/><circle cx="6" cy="6" r="2.6"/><circle cx="18" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/><circle cx="18" cy="18" r="2.6"/>',
637
+ working: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="6" r="2.2"/><circle cx="12" cy="12" r="2.2" class="accent"/><circle cx="6" cy="18" r="2.2"/><circle cx="18" cy="18" r="2.2"/>'
638
+ };
639
+ // A span with an SVG, hidden from screen readers: a word beside it says
640
+ // what it is.
641
+ const svgSpan = (doc, className, paths)=>{
642
+ const span = element(doc, 'span', {
643
+ className
644
+ });
645
+ span.setAttribute('aria-hidden', 'true');
646
+ span.innerHTML = `<svg viewBox="0 0 24 24">${paths}</svg>`;
647
+ return span;
648
+ };
649
+ // `{name}` style placeholders.
650
+ const fill = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
651
+ // Line icons (Lucide), drawn in the text colour.
652
+ const ICONS = {
653
+ page: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
654
+ newPage: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/>',
655
+ block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
656
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
657
+ close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
658
+ minimize: '<path d="m6 9 6 6 6-6"/>',
659
+ expand: '<path d="m18 15-6-6-6 6"/>',
660
+ chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
661
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
662
+ check: '<path d="M20 6 9 17l-5-5"/>',
663
+ mark: `<g fill="currentColor" stroke="none">${MARKS.obelism}</g>`,
664
+ send: '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>',
665
+ arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
666
+ dash: '<path d="M5 12h14"/>',
667
+ eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
668
+ eyeOff: '<path d="M10.7 5.1A10 10 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-2.2 3.2"/><path d="M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="m2 2 20 20"/>',
669
+ plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
670
+ scope: '<rect x="3" y="3" width="18" height="18" rx="3" stroke-dasharray="3 3"/>',
671
+ alert: '<circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/>'
672
+ };
673
+ const icon$1 = (doc, name)=>svgSpan(doc, 'icon', ICONS[name]);
674
+
675
+ // The CMS's form (`bridge.openForm`) in a container, while shown: a fresh
676
+ // one, and one-time address, every time it mounts.
677
+ const createFormSlot = (bridge, container, title, { onReady, onError, onEscape })=>{
678
+ let handle = null;
679
+ let loading = false;
680
+ let error = null;
681
+ return {
682
+ get mounted () {
683
+ return handle !== null;
684
+ },
685
+ get loading () {
686
+ return loading;
687
+ },
688
+ get error () {
689
+ return error;
690
+ },
691
+ // Callbacks come later (the address is fetched first), never during
692
+ // the caller's render.
693
+ mount () {
694
+ loading = true;
695
+ error = null;
696
+ const opened = bridge.openForm(container, {
697
+ title,
698
+ onReady: ()=>{
699
+ if (handle !== opened) return;
700
+ loading = false;
701
+ onReady();
702
+ },
703
+ onError: (message)=>{
704
+ if (handle !== opened) return;
705
+ loading = false;
706
+ error = message;
707
+ onError(message);
708
+ },
709
+ onEscape: ()=>{
710
+ if (handle === opened) onEscape?.();
711
+ }
712
+ });
713
+ handle = opened;
714
+ },
715
+ unmount () {
716
+ handle?.close();
717
+ handle = null;
718
+ loading = false;
719
+ error = null;
720
+ }
721
+ };
722
+ };
723
+
724
+ // The words of the prompt box and the on-site editor. What both say is
725
+ // `SharedLabels`; each adds its own.
726
+ const SHARED_LABELS = {
621
727
  open: 'Edit with a prompt',
622
- placeholder: 'Describe a change, e.g. “make the heading shorter”',
728
+ placeholder: 'Describe a change, like “make it warmer”',
623
729
  send: 'Apply',
624
730
  undo: 'Undo',
625
- close: 'Close',
626
731
  minimize: 'Minimize, to see the page',
627
- expand: 'Expand',
628
732
  scopeEntry: 'Whole page',
629
- scopeNoPage: 'Whole page',
630
- scopeBlock: 'Selected block',
631
- clearBlock: 'Edit the whole page instead',
632
733
  working: 'Working on it…',
633
- save: 'Save',
634
734
  publish: 'Publish',
635
735
  saving: 'Saving…',
636
736
  publishing: 'Publishing…',
637
737
  saved: 'Saved as a draft',
638
738
  published: 'Published',
639
739
  unsaved: 'Unsaved changes',
640
- signOut: 'Exit editing',
740
+ signOut: 'Exit',
641
741
  viewingDrafts: 'Viewing drafts',
642
- selectHint: 'Click a block to change only that block',
643
742
  newPage: 'New page',
644
743
  newPageCancel: 'Edit this page instead',
645
744
  newPagePlaceholder: 'Describe the new page, e.g. “a pricing page for agencies”',
@@ -648,8 +747,8 @@ const DEFAULT_PROMPT_LABELS = {
648
747
  creating: 'Writing the page…',
649
748
  created: 'Opening the new page…',
650
749
  modes: 'Edit with',
651
- modePrompt: 'Prompt',
652
- form: 'Form',
750
+ modePrompt: 'Ask',
751
+ form: 'Edit fields',
653
752
  formTitle: 'The content form',
654
753
  formLoading: 'Opening the form…',
655
754
  pages: 'Pages',
@@ -668,6 +767,258 @@ const DEFAULT_PROMPT_LABELS = {
668
767
  addedCollectionPage: 'Opening the collection page…',
669
768
  collectionPageHint: 'This collection has no page yet. Add one for what goes around its products.'
670
769
  };
770
+ const DEFAULT_PROMPT_LABELS = {
771
+ ...SHARED_LABELS,
772
+ close: 'Close',
773
+ expand: 'Expand',
774
+ scopeNoPage: 'Whole page',
775
+ scopeBlock: 'Selected block',
776
+ clearBlock: 'Edit the whole page instead',
777
+ save: 'Save',
778
+ selectHint: 'Click a block to change only that block'
779
+ };
780
+ const DEFAULT_SITE_EDITOR_LABELS = {
781
+ ...SHARED_LABELS,
782
+ editor: 'Obelism editor',
783
+ editing: 'Editing',
784
+ hideOutlines: 'Show the page without outlines',
785
+ environment: 'Environment',
786
+ envProduction: 'live',
787
+ scopeNoteBlock: 'Working on {name}. Click another block to switch.',
788
+ scopeNotePage: 'Working on the whole page. Click a block to change only that block.',
789
+ block: 'Block',
790
+ previewChip: '{name} · preview',
791
+ askIntro: 'Describe a change and Obelism shows it on the page. Nothing is stored until you keep it and save.',
792
+ you: 'You',
793
+ obelism: 'Obelism',
794
+ suggested: 'Suggested by Obelism',
795
+ keep: 'Keep it',
796
+ retry: 'Try another',
797
+ retryPrompt: '{prompt}\n\nWrite a different version than the last one.',
798
+ kept: 'Kept',
799
+ undone: 'Undone',
800
+ before: 'Before',
801
+ after: 'After',
802
+ emptyValue: 'Empty',
803
+ moreChanges: '{count} more changed',
804
+ keepFirst: 'Keep or undo the suggestion to save',
805
+ keepBeforeFields: 'Keep or undo the suggestion before editing fields.',
806
+ saveDraft: 'Save draft',
807
+ upToDate: 'No unsaved changes',
808
+ saveFailed: 'Couldn’t save',
809
+ publishFailed: 'Couldn’t publish',
810
+ tryAgain: 'Try again'
811
+ };
812
+
813
+ const createPagesPicker = (doc, bridge, labels, { onLoad, onClose })=>{
814
+ const search = element(doc, 'input', {
815
+ type: 'text',
816
+ placeholder: labels.pagesSearch,
817
+ autocomplete: 'off',
818
+ spellcheck: false
819
+ });
820
+ search.setAttribute('role', 'combobox');
821
+ search.setAttribute('aria-label', labels.pagesSearch);
822
+ search.setAttribute('aria-autocomplete', 'list');
823
+ search.setAttribute('aria-expanded', 'true');
824
+ search.setAttribute('aria-controls', 'obelism-pages');
825
+ const searchRow = element(doc, 'label', {
826
+ className: 'search'
827
+ }, icon$1(doc, 'search'), search);
828
+ const list = element(doc, 'ul', {
829
+ className: 'options',
830
+ id: 'obelism-pages'
831
+ });
832
+ list.setAttribute('role', 'listbox');
833
+ list.setAttribute('aria-label', labels.pages);
834
+ const note = element(doc, 'p', {
835
+ className: 'empty'
836
+ });
837
+ let pages = null;
838
+ let load = null;
839
+ let error = null;
840
+ let active = 0;
841
+ const matching = ()=>{
842
+ const query = search.value.trim().toLowerCase();
843
+ return (pages ?? []).filter((page)=>!query || page.name.toLowerCase().includes(query) || page.url.toLowerCase().includes(query) || `/${page.path}`.toLowerCase().includes(query));
844
+ };
845
+ const choose = (page)=>{
846
+ if (page.entryId === bridge.entryId) return onClose();
847
+ // Asks first when changes aren't saved; stays here if told no.
848
+ bridge.openPage(page);
849
+ };
850
+ const render = ()=>{
851
+ const options = matching();
852
+ active = Math.max(0, Math.min(active, options.length - 1));
853
+ list.textContent = '';
854
+ options.forEach((page, index)=>{
855
+ const option = element(doc, 'li', {
856
+ className: `option${index === active ? ' active' : ''}`,
857
+ id: `obelism-page-${index}`
858
+ }, element(doc, 'span', {
859
+ className: 'option-text'
860
+ }, element(doc, 'span', {
861
+ textContent: page.name
862
+ }), element(doc, 'span', {
863
+ className: 'option-path',
864
+ textContent: page.url
865
+ })));
866
+ option.setAttribute('role', 'option');
867
+ option.setAttribute('aria-selected', String(index === active));
868
+ if (page.status !== 'published') {
869
+ option.appendChild(element(doc, 'span', {
870
+ className: 'badge',
871
+ textContent: page.status === 'draft' ? labels.pageDraft : labels.pageChanged
872
+ }));
873
+ }
874
+ if (page.entryId === bridge.entryId) {
875
+ option.setAttribute('aria-current', 'page');
876
+ const check = icon$1(doc, 'check');
877
+ check.removeAttribute('aria-hidden');
878
+ check.setAttribute('role', 'img');
879
+ check.setAttribute('aria-label', labels.pageCurrent);
880
+ option.appendChild(check);
881
+ }
882
+ option.addEventListener('click', ()=>choose(page));
883
+ list.appendChild(option);
884
+ });
885
+ const activeOption = list.children[active];
886
+ if (activeOption) {
887
+ search.setAttribute('aria-activedescendant', activeOption.id);
888
+ activeOption.scrollIntoView?.({
889
+ block: 'nearest'
890
+ });
891
+ } else {
892
+ search.removeAttribute('aria-activedescendant');
893
+ }
894
+ const text = error ?? (pages ? '' : labels.pagesLoading);
895
+ note.textContent = text || (options.length === 0 ? labels.pagesEmpty : '');
896
+ note.hidden = !note.textContent;
897
+ note.className = `empty${error ? ' error' : ''}`;
898
+ };
899
+ const fetch = ()=>{
900
+ load ??= bridge.pages().then((list)=>{
901
+ pages = list;
902
+ error = null;
903
+ render();
904
+ onLoad();
905
+ }, (reason)=>{
906
+ load = null;
907
+ error = reason instanceof Error ? reason.message : String(reason);
908
+ render();
909
+ onLoad();
910
+ });
911
+ return load;
912
+ };
913
+ search.addEventListener('input', ()=>{
914
+ active = 0;
915
+ render();
916
+ });
917
+ search.addEventListener('keydown', (event)=>{
918
+ const count = list.children.length;
919
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
920
+ event.preventDefault();
921
+ if (count === 0) return;
922
+ active = (active + (event.key === 'ArrowDown' ? 1 : -1) + count) % count;
923
+ render();
924
+ } else if (event.key === 'Enter' && !event.isComposing) {
925
+ event.preventDefault();
926
+ const page = matching()[active];
927
+ if (page) choose(page);
928
+ } else if (event.key === 'Escape') {
929
+ event.preventDefault();
930
+ onClose();
931
+ }
932
+ });
933
+ return {
934
+ searchRow,
935
+ search,
936
+ list,
937
+ note,
938
+ get pages () {
939
+ return pages;
940
+ },
941
+ get error () {
942
+ return error;
943
+ },
944
+ current: ()=>pages?.find(({ entryId })=>entryId === bridge.entryId) ?? null,
945
+ fetch,
946
+ forget () {
947
+ pages = null;
948
+ load = null;
949
+ },
950
+ reset () {
951
+ search.value = '';
952
+ active = Math.max(0, (pages ?? []).findIndex(({ entryId })=>entryId === bridge.entryId));
953
+ void fetch();
954
+ render();
955
+ },
956
+ render
957
+ };
958
+ };
959
+
960
+ // What the prompt box and the on-site editor decide alike on the site.
961
+ const failure = (error)=>error instanceof Error ? error.message : String(error);
962
+ const pageContext = (bridge, state, promptWithoutPage)=>{
963
+ const site = bridge.mode === 'site';
964
+ const canCreate = site && state.features.includes('create');
965
+ // A page of the site that isn't an entry (no `editable(entry)`, like a
966
+ // hand-written /about) has nothing to edit: only New page…
967
+ const pageless = site && bridge.entryId === null;
968
+ // …unless it shows a product (collection): then Add a product
969
+ // (collection) page first.
970
+ const shopHere = !pageless || !canCreate ? null : bridge.product ? 'product' : bridge.collection ? 'collection' : null;
971
+ const onlyNew = pageless && canCreate && !shopHere && !promptWithoutPage;
972
+ return {
973
+ site,
974
+ canCreate,
975
+ pageless,
976
+ shopHere,
977
+ onlyNew
978
+ };
979
+ };
980
+ const shopCopy = (labels, shopHere)=>shopHere === 'collection' ? {
981
+ add: labels.addCollectionPage,
982
+ adding: labels.addingCollectionPage,
983
+ hint: labels.collectionPageHint
984
+ } : {
985
+ add: labels.addProductPage,
986
+ adding: labels.addingProductPage,
987
+ hint: labels.productPageHint
988
+ };
989
+ // Makes the page's product (collection) page; the bridge then reloads, and
990
+ // the answer shows until it does.
991
+ const addShopPage = async (bridge, labels)=>{
992
+ try {
993
+ if (bridge.product) {
994
+ await bridge.createProductPage();
995
+ return {
996
+ text: labels.addedProductPage,
997
+ error: false
998
+ };
999
+ }
1000
+ await bridge.createCollectionPage();
1001
+ return {
1002
+ text: labels.addedCollectionPage,
1003
+ error: false
1004
+ };
1005
+ } catch (error) {
1006
+ return {
1007
+ text: failure(error),
1008
+ error: true
1009
+ };
1010
+ }
1011
+ };
1012
+ // Saves or publishes; the reason when that failed, else null.
1013
+ const storeChanges = async (bridge, action)=>{
1014
+ try {
1015
+ await bridge[action]();
1016
+ return null;
1017
+ } catch (error) {
1018
+ return failure(error);
1019
+ }
1020
+ };
1021
+
671
1022
  const SIDE_KEY = 'obelism-content-prompt-side';
672
1023
  // How far the pointer moves before it drags the box, not clicks it.
673
1024
  const DRAG_THRESHOLD_PX = 6;
@@ -680,47 +1031,28 @@ const FLICK_MAX_PX = 120;
680
1031
  const FLICK_STALE_MS = 80;
681
1032
  // Its distance from the side of the screen, as in the CSS.
682
1033
  const EDGE_PX = 16;
683
- // Icons (Lucide), drawn in the text colour.
684
- const ICONS = {
685
- page: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
686
- newPage: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/>',
687
- block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
688
- undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
689
- close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
690
- minimize: '<path d="m6 9 6 6 6-6"/>',
691
- expand: '<path d="m18 15-6-6-6 6"/>',
692
- chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
693
- search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
694
- check: '<path d="M20 6 9 17l-5-5"/>',
695
- mark: '<path d="M12 3v18M3 12h18"/><g fill="currentColor" stroke="none"><circle cx="7" cy="7" r="1.75"/><circle cx="17" cy="7" r="1.75"/><circle cx="7" cy="17" r="1.75"/><circle cx="17" cy="17" r="1.75"/></g>',
696
- send: '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>'
697
- };
698
1034
  // Obelism's design tokens (obelism.studio/design.md), under their own names:
699
- // beige and near-black, one orange accent, Inter, pill buttons.
700
- const CSS = `
1035
+ // warm paper and ink, one orange accent, Geist, pill buttons.
1036
+ const CSS$1 = `
701
1037
  :host { all: initial; }
702
1038
  * { box-sizing: border-box; font: inherit; color: inherit; margin: 0; }
703
1039
  .root {
704
- --color-white-rgb: 248 248 248; --color-beige-rgb: 238 236 233; --color-beige-off-rgb: 228 225 217;
705
- --color-grey-light-rgb: 132 132 132; --color-grey-rgb: 108 108 108; --color-grey-mid-rgb: 100 100 100; --color-grey-dark-rgb: 40 40 40;
706
- --color-black-off-rgb: 20 20 20; --color-black-rgb: 12 12 12;
707
- --color-orange-rgb: 212 130 30; --color-red-rgb: 240 93 93;
708
- --theme-foreground-rgb: var(--color-black-rgb);
709
- --theme-background-alt-rgb: var(--color-white-rgb);
710
- --theme-background-actions-rgb: var(--color-beige-off-rgb);
711
- --theme-border-rgb: var(--color-grey-light-rgb);
712
- --theme-accent-rgb: var(--color-orange-rgb);
713
- --theme-foreground-muted-rgb: var(--color-grey-mid-rgb);
714
- --theme-error: #b3261e;
1040
+ --theme-foreground-rgb: 23 22 20;
1041
+ --theme-background-alt-rgb: 255 255 255;
1042
+ --theme-background-actions-rgb: 232 229 223;
1043
+ --theme-border-rgb: 138 132 124;
1044
+ --theme-accent-rgb: 212 130 30;
1045
+ --theme-foreground-muted-rgb: 105 100 94;
1046
+ --theme-error: rgb(184 46 46);
715
1047
  --border-radius-medium: 20px; --border-radius-pill: 5em;
716
1048
  --ease-roll: cubic-bezier(0.65, 0, 0.35, 1);
717
- --shadow: 0 18px 40px -16px rgb(var(--color-black-rgb) / .3), 0 2px 6px rgb(var(--color-black-rgb) / .05);
718
- --font-inter: 'InterVariable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
719
- Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
1049
+ --shadow: 0 0 0 1px rgb(23 22 20 / .08), 0 12px 32px rgb(23 22 20 / .14);
1050
+ --font-base: 'Geist', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
1051
+ Roboto, 'Helvetica Neue', Arial, sans-serif;
720
1052
  position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
721
1053
  transition: translate .45s cubic-bezier(0.34, 1.3, 0.64, 1);
722
1054
  z-index: 2147483647; width: min(640px, calc(100vw - 32px)); color-scheme: light dark;
723
- font: 400 14px/1.4 var(--font-inter); color: rgb(var(--theme-foreground-rgb));
1055
+ font: 400 14px/1.4 var(--font-base); color: rgb(var(--theme-foreground-rgb));
724
1056
  -webkit-font-smoothing: antialiased;
725
1057
  }
726
1058
  /* Dragged to a side of the screen, it stays there, springing into place. */
@@ -734,13 +1066,13 @@ const CSS = `
734
1066
  .pill { touch-action: none; }
735
1067
  @media (prefers-color-scheme: dark) {
736
1068
  .root {
737
- --theme-foreground-rgb: var(--color-white-rgb);
738
- --theme-background-alt-rgb: var(--color-black-off-rgb);
739
- --theme-background-actions-rgb: var(--color-grey-dark-rgb);
740
- --theme-border-rgb: var(--color-grey-rgb);
741
- --theme-foreground-muted-rgb: var(--color-grey-light-rgb);
742
- --theme-error: rgb(var(--color-red-rgb));
743
- --shadow: 0 18px 40px -14px rgb(0 0 0 / .7), 0 2px 6px rgb(0 0 0 / .3);
1069
+ --theme-foreground-rgb: 242 240 236;
1070
+ --theme-background-alt-rgb: 27 26 24;
1071
+ --theme-background-actions-rgb: 45 43 40;
1072
+ --theme-border-rgb: 112 108 102;
1073
+ --theme-foreground-muted-rgb: 160 154 146;
1074
+ --theme-error: rgb(240 110 110);
1075
+ --shadow: 0 0 0 1px rgb(255 255 255 / .06), 0 12px 32px rgb(0 0 0 / .5);
744
1076
  }
745
1077
  }
746
1078
  .icon { display: inline-flex; flex: none; }
@@ -881,7 +1213,7 @@ button.chip:disabled { opacity: .5; }
881
1213
  .modes button:hover { color: rgb(var(--theme-foreground-rgb)); }
882
1214
  .modes button[aria-pressed='true'] {
883
1215
  background: rgb(var(--theme-background-alt-rgb)); color: rgb(var(--theme-foreground-rgb));
884
- box-shadow: 0 1px 2px rgb(var(--color-black-rgb) / .08);
1216
+ box-shadow: 0 1px 2px rgb(23 22 20 / .08);
885
1217
  }
886
1218
  .divider { width: 1px; height: 20px; background: rgb(var(--theme-border-rgb) / .3); margin: 0 4px; }
887
1219
 
@@ -933,24 +1265,7 @@ button.chip:disabled { opacity: .5; }
933
1265
  .empty.error { color: var(--theme-error); }
934
1266
  [hidden] { display: none !important; }
935
1267
  `;
936
- const element = (doc, tag, props = {}, ...children)=>{
937
- const node = doc.createElement(tag);
938
- Object.assign(node, props);
939
- // appendChild, not append: Workers types redefine Element.append, and a
940
- // site's server code may well have them loaded.
941
- for (const child of children){
942
- node.appendChild(typeof child === 'string' ? doc.createTextNode(child) : child);
943
- }
944
- return node;
945
- };
946
- const icon = (doc, name)=>{
947
- const span = element(doc, 'span', {
948
- className: 'icon'
949
- });
950
- span.setAttribute('aria-hidden', 'true');
951
- span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
952
- return span;
953
- };
1268
+ const icon = (doc, name)=>icon$1(doc, name);
954
1269
  const mark = (doc)=>{
955
1270
  const span = icon(doc, 'mark');
956
1271
  span.classList.add('mark');
@@ -1117,31 +1432,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1117
1432
  pageChip.setAttribute('aria-haspopup', 'listbox');
1118
1433
  pageChip.setAttribute('aria-expanded', 'false');
1119
1434
  pageChip.setAttribute('aria-controls', 'obelism-pages');
1120
- const search = element(doc, 'input', {
1121
- type: 'text',
1122
- placeholder: labels.pagesSearch,
1123
- autocomplete: 'off',
1124
- spellcheck: false
1125
- });
1126
- search.setAttribute('role', 'combobox');
1127
- search.setAttribute('aria-label', labels.pagesSearch);
1128
- search.setAttribute('aria-autocomplete', 'list');
1129
- search.setAttribute('aria-expanded', 'true');
1130
- search.setAttribute('aria-controls', 'obelism-pages');
1131
- const optionList = element(doc, 'ul', {
1132
- className: 'options',
1133
- id: 'obelism-pages'
1134
- });
1135
- optionList.setAttribute('role', 'listbox');
1136
- optionList.setAttribute('aria-label', labels.pages);
1137
- const pagesNote = element(doc, 'p', {
1138
- className: 'empty'
1435
+ const picker = createPagesPicker(doc, bridge, labels, {
1436
+ onLoad: ()=>refresh(),
1437
+ onClose: ()=>setPicking(false)
1139
1438
  });
1140
1439
  const pagesSection = element(doc, 'div', {
1141
1440
  className: 'pages'
1142
- }, element(doc, 'label', {
1143
- className: 'search'
1144
- }, icon(doc, 'search'), search), optionList, pagesNote);
1441
+ }, picker.searchRow, picker.list, picker.note);
1145
1442
  // The page's own controls (`actions`), in the footer while open and next
1146
1443
  // to the pill while closed.
1147
1444
  const actionsInFooter = element(doc, 'slot');
@@ -1188,7 +1485,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1188
1485
  className: 'bar'
1189
1486
  }, pill, viewing, actionsInBar), panel);
1190
1487
  shadow.append(element(doc, 'style', {
1191
- textContent: CSS
1488
+ textContent: CSS$1
1192
1489
  }), root);
1193
1490
  // Dragged sideways, the box moves to the left, the middle or the right
1194
1491
  // of the screen, whichever third it is let go (or flicked) in; this
@@ -1283,20 +1580,23 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1283
1580
  let minimized = false;
1284
1581
  let creating = false;
1285
1582
  let message = null;
1286
- // Form mode, and the form while mounted: a fresh one (and one-time
1287
- // address) every time it shows.
1288
1583
  let form = false;
1289
- let formHandle = null;
1290
- let formLoading = false;
1291
- // The pages, fetched once the panel opens and kept; `active` is the
1292
- // option arrow keys are on.
1584
+ const formSlot = createFormSlot(bridge, formBox, labels.formTitle, {
1585
+ onReady: ()=>{
1586
+ message = null;
1587
+ refresh();
1588
+ },
1589
+ onError: (text)=>{
1590
+ message = {
1591
+ text,
1592
+ error: true
1593
+ };
1594
+ refresh();
1595
+ }
1596
+ });
1293
1597
  let picking = false;
1294
- let pages = null;
1295
- let pagesLoad = null;
1296
- let pagesError = null;
1297
- let active = 0;
1298
- const failure = (error)=>({
1299
- text: error instanceof Error ? error.message : String(error),
1598
+ const failure$1 = (error)=>({
1599
+ text: failure(error),
1300
1600
  error: true
1301
1601
  });
1302
1602
  const render = (state)=>{
@@ -1307,34 +1607,19 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1307
1607
  pill.hidden = open || !available;
1308
1608
  viewing.hidden = available || !site;
1309
1609
  panel.hidden = !open || !available;
1310
- const canCreate = site && state.features.includes('create');
1311
- // A page of the site that isn't an entry (no `editable(entry)`, like a
1312
- // hand-written /about) has nothing to edit: only New page.
1313
- const pageless = site && bridge.entryId === null;
1314
- // …unless it shows a product (collection): then Add a product
1315
- // (collection) page first.
1316
- const shopHere = !pageless || !canCreate ? null : bridge.product ? 'product' : bridge.collection ? 'collection' : null;
1610
+ const { canCreate, pageless, shopHere, onlyNew } = pageContext(bridge, state, options.promptWithoutPage);
1317
1611
  const productHere = shopHere !== null;
1318
- const shopCopy = shopHere === 'collection' ? {
1319
- add: labels.addCollectionPage,
1320
- adding: labels.addingCollectionPage,
1321
- hint: labels.collectionPageHint
1322
- } : {
1323
- add: labels.addProductPage,
1324
- adding: labels.addingProductPage,
1325
- hint: labels.productPageHint
1326
- };
1327
- addProduct.lastChild.textContent = shopCopy.add;
1328
- const onlyNew = pageless && canCreate && !productHere && !options.promptWithoutPage;
1612
+ const shopCopy$1 = shopCopy(labels, shopHere);
1613
+ addProduct.lastChild.textContent = shopCopy$1.add;
1329
1614
  if (!canCreate) creating = false;
1330
1615
  if (onlyNew) creating = true;
1331
1616
  if (creating || pageless || !site) form = false;
1332
1617
  // The form is there only while shown in the open panel.
1333
1618
  const showForm = open && available && form;
1334
- if (showForm && !formHandle) mountForm();
1335
- else if (!showForm && formHandle) unmountForm();
1619
+ if (showForm && !formSlot.mounted) formSlot.mount();
1620
+ else if (!showForm && formSlot.mounted) formSlot.unmount();
1336
1621
  const offerProduct = productHere && !creating;
1337
- pillText.textContent = offerProduct ? shopCopy.add : onlyNew ? labels.newPage : labels.open;
1622
+ pillText.textContent = offerProduct ? shopCopy$1.add : onlyNew ? labels.newPage : labels.open;
1338
1623
  cancelNew.hidden = onlyNew;
1339
1624
  scope.hidden = creating || offerProduct;
1340
1625
  newScope.hidden = !creating;
@@ -1347,8 +1632,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1347
1632
  pathInput.disabled = busy;
1348
1633
  input.hidden = form || picking || minimized || offerProduct;
1349
1634
  formBox.hidden = !form || picking || minimized;
1350
- formBox.classList.toggle('loading', formLoading);
1351
- formLoader.hidden = !formLoading;
1635
+ formBox.classList.toggle('loading', formSlot.loading);
1636
+ formLoader.hidden = !formSlot.loading;
1352
1637
  toolbar.hidden = picking;
1353
1638
  pagesSection.hidden = !picking;
1354
1639
  input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
@@ -1391,11 +1676,11 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1391
1676
  unsaved.hidden = !state.unsaved;
1392
1677
  pageChip.hidden = !site;
1393
1678
  pageChip.setAttribute('aria-expanded', String(picking));
1394
- pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
1395
- const loadingForm = form && formLoading && !picking;
1679
+ pageChipText.textContent = picker.current()?.name ?? labels.pages;
1680
+ const loadingForm = form && formSlot.loading && !picking;
1396
1681
  const working = state.prompting;
1397
1682
  // A form opening says so in its own place.
1398
- const note = state.prompting ? creating ? labels.creating : offerProduct ? shopCopy.adding : labels.working : loadingForm ? '' : message?.text ?? (offerProduct ? shopCopy.hint : site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1683
+ const note = state.prompting ? creating ? labels.creating : offerProduct ? shopCopy$1.adding : labels.working : loadingForm ? '' : message?.text ?? (offerProduct ? shopCopy$1.hint : site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1399
1684
  status.textContent = note;
1400
1685
  status.hidden = !note || minimized;
1401
1686
  status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
@@ -1405,36 +1690,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1405
1690
  input.style.height = 'auto';
1406
1691
  input.style.height = `${input.scrollHeight}px`;
1407
1692
  };
1408
- // Callbacks come later (the address is fetched first), never during a
1409
- // render.
1410
- const mountForm = ()=>{
1411
- formLoading = true;
1412
- formBox.classList.add('loading');
1413
- const handle = bridge.openForm(formBox, {
1414
- title: labels.formTitle,
1415
- onReady: ()=>{
1416
- if (formHandle !== handle) return;
1417
- formLoading = false;
1418
- message = null;
1419
- refresh();
1420
- },
1421
- onError: (text)=>{
1422
- if (formHandle !== handle) return;
1423
- formLoading = false;
1424
- message = {
1425
- text,
1426
- error: true
1427
- };
1428
- refresh();
1429
- }
1430
- });
1431
- formHandle = handle;
1432
- };
1433
- const unmountForm = ()=>{
1434
- formHandle?.close();
1435
- formHandle = null;
1436
- formLoading = false;
1437
- };
1438
1693
  const setForm = (next)=>{
1439
1694
  if (form === next) return;
1440
1695
  form = next;
@@ -1453,8 +1708,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1453
1708
  if (!path) return pathInput.focus();
1454
1709
  // The bridge opens the new page; this shows until it does.
1455
1710
  await bridge.createPage(path, prompt);
1456
- pages = null;
1457
- pagesLoad = null;
1711
+ picker.forget();
1458
1712
  message = {
1459
1713
  text: labels.created,
1460
1714
  error: false
@@ -1469,94 +1723,17 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1469
1723
  input.value = '';
1470
1724
  grow();
1471
1725
  } catch (error) {
1472
- message = failure(error);
1726
+ message = failure$1(error);
1473
1727
  }
1474
1728
  refresh();
1475
1729
  input.focus();
1476
1730
  };
1477
- const fetchPages = ()=>{
1478
- pagesLoad ??= bridge.pages().then((list)=>{
1479
- pages = list;
1480
- pagesError = null;
1481
- renderPages();
1482
- refresh();
1483
- }, (error)=>{
1484
- pagesLoad = null;
1485
- pagesError = failure(error).text;
1486
- renderPages();
1487
- });
1488
- return pagesLoad;
1489
- };
1490
- const matching = ()=>{
1491
- const query = search.value.trim().toLowerCase();
1492
- return (pages ?? []).filter((page)=>!query || page.name.toLowerCase().includes(query) || page.url.toLowerCase().includes(query) || `/${page.path}`.toLowerCase().includes(query));
1493
- };
1494
- const renderPages = ()=>{
1495
- const list = matching();
1496
- active = Math.max(0, Math.min(active, list.length - 1));
1497
- optionList.textContent = '';
1498
- list.forEach((page, index)=>{
1499
- const current = page.entryId === bridge.entryId;
1500
- const option = element(doc, 'li', {
1501
- className: `option${index === active ? ' active' : ''}`,
1502
- id: `obelism-page-${index}`
1503
- }, element(doc, 'span', {
1504
- className: 'option-text'
1505
- }, element(doc, 'span', {
1506
- textContent: page.name
1507
- }), element(doc, 'span', {
1508
- className: 'option-path',
1509
- textContent: page.url
1510
- })));
1511
- option.setAttribute('role', 'option');
1512
- option.setAttribute('aria-selected', String(index === active));
1513
- if (page.status !== 'published') {
1514
- option.appendChild(element(doc, 'span', {
1515
- className: 'badge',
1516
- textContent: page.status === 'draft' ? labels.pageDraft : labels.pageChanged
1517
- }));
1518
- }
1519
- if (current) {
1520
- option.setAttribute('aria-current', 'page');
1521
- const check = icon(doc, 'check');
1522
- check.removeAttribute('aria-hidden');
1523
- check.setAttribute('role', 'img');
1524
- check.setAttribute('aria-label', labels.pageCurrent);
1525
- option.appendChild(check);
1526
- }
1527
- option.addEventListener('click', ()=>choose(page));
1528
- optionList.appendChild(option);
1529
- });
1530
- const activeOption = optionList.children[active];
1531
- if (activeOption) {
1532
- search.setAttribute('aria-activedescendant', activeOption.id);
1533
- activeOption.scrollIntoView?.({
1534
- block: 'nearest'
1535
- });
1536
- } else {
1537
- search.removeAttribute('aria-activedescendant');
1538
- }
1539
- const note = pagesError ?? (pages ? '' : labels.pagesLoading);
1540
- pagesNote.textContent = note || (list.length === 0 ? labels.pagesEmpty : '');
1541
- pagesNote.hidden = !pagesNote.textContent;
1542
- pagesNote.className = `empty${pagesError ? ' error' : ''}`;
1543
- };
1544
- const setPicking = (next)=>{
1545
- picking = next;
1546
- message = null;
1547
- if (picking) {
1548
- search.value = '';
1549
- active = Math.max(0, (pages ?? []).findIndex(({ entryId })=>entryId === bridge.entryId));
1550
- void fetchPages();
1551
- renderPages();
1552
- }
1553
- refresh();
1554
- (picking ? search : pageChip).focus();
1555
- };
1556
- const choose = (page)=>{
1557
- if (page.entryId === bridge.entryId) return setPicking(false);
1558
- // Asks first when changes aren't saved; stays here if told no.
1559
- bridge.openPage(page);
1731
+ const setPicking = (next)=>{
1732
+ picking = next;
1733
+ message = null;
1734
+ if (picking) picker.reset();
1735
+ refresh();
1736
+ (picking ? picker.search : pageChip).focus();
1560
1737
  };
1561
1738
  const setOpen = (next)=>{
1562
1739
  open = next;
@@ -1566,7 +1743,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1566
1743
  }
1567
1744
  refresh();
1568
1745
  if (site) bridge.setSelecting(open && !creating);
1569
- if (open && site) void fetchPages();
1746
+ if (open && site) void picker.fetch();
1570
1747
  };
1571
1748
  const setCreating = (next)=>{
1572
1749
  creating = next;
@@ -1578,15 +1755,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1578
1755
  };
1579
1756
  const store = async (action)=>{
1580
1757
  message = null;
1581
- try {
1582
- await bridge[action]();
1583
- message = {
1584
- text: action === 'save' ? labels.saved : labels.published,
1585
- error: false
1586
- };
1587
- } catch (error) {
1588
- message = failure(error);
1589
- }
1758
+ const error = await storeChanges(bridge, action);
1759
+ message = error ? {
1760
+ text: error,
1761
+ error: true
1762
+ } : {
1763
+ text: action === 'save' ? labels.saved : labels.published,
1764
+ error: false
1765
+ };
1590
1766
  refresh();
1591
1767
  };
1592
1768
  pill.addEventListener('click', ()=>{
@@ -1616,24 +1792,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1616
1792
  newPage.addEventListener('click', ()=>setCreating(true));
1617
1793
  addProduct.addEventListener('click', async ()=>{
1618
1794
  message = null;
1619
- try {
1620
- // The bridge reloads the page; this shows until it does.
1621
- if (bridge.product) {
1622
- await bridge.createProductPage();
1623
- message = {
1624
- text: labels.addedProductPage,
1625
- error: false
1626
- };
1627
- } else {
1628
- await bridge.createCollectionPage();
1629
- message = {
1630
- text: labels.addedCollectionPage,
1631
- error: false
1632
- };
1633
- }
1634
- } catch (error) {
1635
- message = failure(error);
1636
- }
1795
+ message = await addShopPage(bridge, labels);
1637
1796
  refresh();
1638
1797
  });
1639
1798
  cancelNew.addEventListener('click', ()=>setCreating(false));
@@ -1649,26 +1808,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1649
1808
  setOpen(false);
1650
1809
  }
1651
1810
  });
1652
- search.addEventListener('input', ()=>{
1653
- active = 0;
1654
- renderPages();
1655
- });
1656
- search.addEventListener('keydown', (event)=>{
1657
- const count = optionList.children.length;
1658
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1659
- event.preventDefault();
1660
- if (count === 0) return;
1661
- active = (active + (event.key === 'ArrowDown' ? 1 : -1) + count) % count;
1662
- renderPages();
1663
- } else if (event.key === 'Enter' && !event.isComposing) {
1664
- event.preventDefault();
1665
- const page = matching()[active];
1666
- if (page) choose(page);
1667
- } else if (event.key === 'Escape') {
1668
- event.preventDefault();
1669
- setPicking(false);
1670
- }
1671
- });
1672
1811
  undo.addEventListener('click', ()=>{
1673
1812
  message = null;
1674
1813
  bridge.undo();
@@ -1694,7 +1833,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1694
1833
  doc.body.appendChild(host);
1695
1834
  return ()=>{
1696
1835
  unsubscribe();
1697
- unmountForm();
1836
+ formSlot.unmount();
1698
1837
  host.remove();
1699
1838
  };
1700
1839
  };
@@ -1707,11 +1846,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1707
1846
  // Messages are only exchanged with the URL's origin, and only with the
1708
1847
  // iframe's window:
1709
1848
  // - form → bridge: { source: 'obelism-content-form', type: 'ready' |
1710
- // 'loaded' | 'change' | 'error' | 'height', loading?, content?, message?,
1711
- // height? } — `ready` with `loading: true` when the form still loads the
1849
+ // 'loaded' | 'change' | 'error' | 'height' | 'escape', loading?, content?,
1850
+ // message?, height? } — `ready` with `loading: true` when the form still loads the
1712
1851
  // entry after it and says `loaded` once it shows (a CMS from before says
1713
1852
  // only `ready`), `change` carries the whole entry content after an edit,
1714
- // `height` the form's own height in px, which the iframe takes (up to 60vh)
1853
+ // `height` the form's own height in px, which the iframe takes (up to 60vh),
1854
+ // `escape` that Escape was pressed in it (focus there never reaches the
1855
+ // page's keydown)
1715
1856
  // - bridge → form: { source: 'obelism-content-bridge', type: 'content',
1716
1857
  // entryId, content, locale, selectedUid }
1717
1858
  const FORM_SOURCE = 'obelism-content-form';
@@ -1775,6 +1916,8 @@ const mountFormFrame = (win, container, url, title, handlers)=>{
1775
1916
  const px = Math.round(Math.min(Math.max(height, 80), 4000));
1776
1917
  iframe.style.height = `min(${px}px, 60vh)`;
1777
1918
  }
1919
+ } else if (data.type === 'escape') {
1920
+ handlers.onEscape?.();
1778
1921
  } else if (data.type === 'error') {
1779
1922
  handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
1780
1923
  }
@@ -1868,7 +2011,8 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
1868
2011
  unsaved: [
1869
2012
  ...working.values()
1870
2013
  ].some(({ content, saved })=>!same(content, saved)),
1871
- canUndo: history.length > 0
2014
+ canUndo: history.length > 0,
2015
+ undoSteps: history.length
1872
2016
  });
1873
2017
  const api = async (path, body)=>{
1874
2018
  const response = await win.fetch(`${basePath}/api/${path}`, body === undefined ? {
@@ -2028,7 +2172,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2028
2172
  get collection () {
2029
2173
  return pageCollection();
2030
2174
  },
2031
- environment: null,
2175
+ get environment () {
2176
+ return session?.environment ?? null;
2177
+ },
2032
2178
  on: emitter.on,
2033
2179
  getState: ()=>state,
2034
2180
  async prompt (prompt, options = {}) {
@@ -2204,7 +2350,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2204
2350
  }
2205
2351
  },
2206
2352
  openForm (container, options = {}) {
2207
- const { title = 'Content form', onReady, onError } = options;
2353
+ const { title = 'Content form', onReady, onError, onEscape } = options;
2208
2354
  closeForm?.();
2209
2355
  let closed = false;
2210
2356
  let frame = null;
@@ -2278,7 +2424,8 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2278
2424
  frame = mountFormFrame(win, container, address, title, {
2279
2425
  onReady: ()=>onReady?.(),
2280
2426
  onChange: change,
2281
- onError: (message)=>onError?.(message)
2427
+ onError: (message)=>onError?.(message),
2428
+ onEscape: ()=>onEscape?.()
2282
2429
  });
2283
2430
  unsubscribe = emitter.on('change', show);
2284
2431
  show();
@@ -2433,6 +2580,1342 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2433
2580
  return bridge;
2434
2581
  };
2435
2582
 
2583
+ // What the site gives up beside the panel: its 404px and a 16px gap on
2584
+ // either side.
2585
+ const DOCK_WIDTH_PX = 436;
2586
+ // Narrower than this the panel is a sheet over the page instead.
2587
+ const DOCK_QUERY = '(min-width: 768px)';
2588
+ const OPEN_KEY = 'obelism-content-editor';
2589
+ // Changed fields an answer shows; the rest are counted.
2590
+ const MAX_DIFFS = 3;
2591
+ // Obelism's tokens (the design system's foundation/tokens.css), copied: a
2592
+ // shadow root can't reach the site's, nor the site's fonts but Geist's
2593
+ // family name.
2594
+ const CSS = `
2595
+ :host { all: initial; }
2596
+ * { box-sizing: border-box; font: inherit; color: inherit; margin: 0; }
2597
+ .root {
2598
+ --bg: 255 255 255; --subtle: 250 249 247; --paper: 242 240 236; --actions: 232 229 223;
2599
+ --fg: 23 22 20; --soft: 68 64 60; --muted: 105 100 94;
2600
+ --hairline: 236 233 228; --border-soft: 220 216 209; --border: 138 132 124;
2601
+ --accent: 212 130 30; --focus: 176 98 0; --ink: 23 22 20;
2602
+ --positive: 30 112 45; --negative: 184 46 46; --warning: 150 88 0;
2603
+ --coral: 251 217 203; --coral-wash: 255 243 236; --coral-ink: 122 46 20;
2604
+ --mint-wash: 237 247 240; --mint-ink: 20 83 45;
2605
+ --butter-wash: 253 248 228; --butter-ink: 107 78 7;
2606
+ --env-develop: 22 163 74; --env-staging: 217 119 6; --env-live: 220 38 38;
2607
+ --tile: 23 22 20;
2608
+ --shadow-small: 0 1px 2px rgb(23 22 20 / .06);
2609
+ --shadow-medium: 0 0 0 1px rgb(23 22 20 / .08), 0 12px 32px rgb(23 22 20 / .14);
2610
+ --shadow-large: 0 0 0 1px rgb(23 22 20 / .08), 0 32px 80px rgb(23 22 20 / .28);
2611
+ --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
2612
+ --font-base: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
2613
+ 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
2614
+ font: 400 14px/20px var(--font-base); color: rgb(var(--fg)); color-scheme: light dark;
2615
+ -webkit-font-smoothing: antialiased;
2616
+ }
2617
+ @media (prefers-color-scheme: dark) {
2618
+ .root {
2619
+ --bg: 27 26 24; --subtle: 33 31 29; --paper: 17 16 15; --actions: 45 43 40;
2620
+ --fg: 242 240 236; --soft: 214 210 204; --muted: 160 154 146;
2621
+ --hairline: 44 42 39; --border-soft: 64 61 57; --border: 112 108 102;
2622
+ --focus: 212 130 30;
2623
+ --positive: 125 190 122; --negative: 240 110 110; --warning: 230 160 60;
2624
+ --coral: 92 46 30; --coral-wash: 46 30 24; --coral-ink: 251 196 172;
2625
+ --mint-wash: 24 40 30; --mint-ink: 176 226 190;
2626
+ --butter-wash: 42 36 20; --butter-ink: 245 222 140;
2627
+ --env-develop: 74 222 128; --env-staging: 251 191 36; --env-live: 248 113 113;
2628
+ --tile: 45 43 40;
2629
+ --shadow-small: 0 1px 2px rgb(0 0 0 / .4);
2630
+ --shadow-medium: 0 0 0 1px rgb(255 255 255 / .06), 0 12px 32px rgb(0 0 0 / .5);
2631
+ --shadow-large: 0 0 0 1px rgb(255 255 255 / .08), 0 32px 80px rgb(0 0 0 / .6);
2632
+ }
2633
+ }
2634
+ [hidden] { display: none !important; }
2635
+ .visually-hidden {
2636
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
2637
+ }
2638
+ .icon { display: inline-flex; flex: none; }
2639
+ .icon svg {
2640
+ width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5;
2641
+ stroke-linecap: round; stroke-linejoin: round;
2642
+ }
2643
+ .mark { display: inline-flex; flex: none; color: rgb(var(--accent)); }
2644
+ .mark svg { width: 16px; height: 16px; fill: currentColor; }
2645
+ .working svg { width: 16px; height: 16px; fill: rgb(var(--muted)); }
2646
+ .working .accent { fill: rgb(var(--accent)); }
2647
+ .working svg { animation: pulse 1.2s ease-in-out infinite; }
2648
+ @keyframes pulse { 50% { opacity: .35; } }
2649
+ button { cursor: pointer; background: none; border: 0; padding: 0; position: relative; }
2650
+ button:disabled { cursor: not-allowed; }
2651
+ :focus-visible { outline: 2px solid rgb(var(--focus)); outline-offset: 2px; }
2652
+ /* 44px targets around smaller-looking controls. */
2653
+ .hit::after { content: ''; position: absolute; inset: -4px; }
2654
+
2655
+ .panel {
2656
+ position: fixed; top: 16px; right: 16px; bottom: 16px; width: 404px; z-index: 2147483647;
2657
+ display: flex; flex-direction: column; overflow: hidden;
2658
+ background: rgb(var(--bg)); border-radius: 22px; box-shadow: var(--shadow-large);
2659
+ transition: transform .3s var(--ease), opacity .3s var(--ease), visibility 0s;
2660
+ }
2661
+ .panel[data-closed] {
2662
+ transform: translateX(calc(100% + 24px)); opacity: 0; visibility: hidden;
2663
+ transition: transform .3s var(--ease), opacity .3s var(--ease), visibility 0s .3s;
2664
+ }
2665
+ @media (max-width: 767px) {
2666
+ .panel { top: auto; left: 8px; right: 8px; bottom: 64px; width: auto; height: min(60vh, 520px); }
2667
+ .panel[data-closed] { transform: translateY(24px); }
2668
+ }
2669
+ @media (prefers-reduced-motion: reduce) {
2670
+ .panel, .panel[data-closed] { transform: none; transition: opacity .2s linear, visibility 0s; }
2671
+ .panel[data-closed] { transition: opacity .2s linear, visibility 0s .2s; }
2672
+ .working svg { animation: none; }
2673
+ }
2674
+ .head {
2675
+ display: flex; align-items: center; gap: 8px; padding: 12px 12px 12px 14px;
2676
+ border-bottom: 1px solid rgb(var(--hairline));
2677
+ }
2678
+ .tile {
2679
+ display: inline-flex; align-items: center; justify-content: center; flex: none;
2680
+ width: 28px; height: 28px; border-radius: 8px; background: rgb(var(--tile));
2681
+ }
2682
+ .tile .mark svg { width: 15px; height: 15px; }
2683
+ .page-button, .page-name {
2684
+ display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 190px; height: 32px;
2685
+ padding: 0 10px; border-radius: 999px; background: rgb(var(--paper)); font-size: 13px; font-weight: 500;
2686
+ }
2687
+ .page-button:hover { background: rgb(var(--actions)); }
2688
+ .page-button > span:first-child, .page-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2689
+ .page-name { display: block; line-height: 32px; }
2690
+ .page-button .icon svg { width: 12px; height: 12px; stroke-width: 1.75; }
2691
+ .env {
2692
+ display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; flex: none;
2693
+ border-radius: 11px; font-size: 12px; font-weight: 500;
2694
+ background: rgb(var(--paper)); color: rgb(var(--soft));
2695
+ }
2696
+ .env::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: rgb(var(--muted)); }
2697
+ .env[data-env='develop'] { background: rgb(var(--mint-wash)); color: rgb(var(--mint-ink)); }
2698
+ .env[data-env='develop']::before { background: rgb(var(--env-develop)); }
2699
+ .env[data-env='staging'] { background: rgb(var(--butter-wash)); color: rgb(var(--butter-ink)); }
2700
+ .env[data-env='staging']::before { background: rgb(var(--env-staging)); }
2701
+ .env[data-env='production'] { background: rgb(var(--negative) / .1); color: rgb(var(--negative)); }
2702
+ .env[data-env='production']::before { background: rgb(var(--env-live)); }
2703
+ .spacer { flex: 1; }
2704
+ .icon-button {
2705
+ display: inline-flex; align-items: center; justify-content: center; flex: none;
2706
+ width: 36px; height: 36px; border-radius: 50%; color: rgb(var(--muted));
2707
+ }
2708
+ .icon-button:hover { background: rgb(var(--paper)); color: rgb(var(--fg)); }
2709
+
2710
+ .pages {
2711
+ position: absolute; top: 60px; left: 12px; right: 12px; z-index: 1; max-height: calc(100% - 140px);
2712
+ display: flex; flex-direction: column; gap: 8px; padding: 10px;
2713
+ background: rgb(var(--bg)); border-radius: 16px; box-shadow: var(--shadow-medium);
2714
+ }
2715
+ .search {
2716
+ display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 12px; flex: none;
2717
+ border: 1px solid rgb(var(--border)); border-radius: 999px; color: rgb(var(--muted));
2718
+ }
2719
+ .search:focus-within { border-color: rgb(var(--accent)); }
2720
+ .search input {
2721
+ flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: rgb(var(--fg));
2722
+ }
2723
+ .search input::placeholder, textarea::placeholder, .path input::placeholder { color: rgb(var(--muted)); }
2724
+ .options { list-style: none; padding: 0; overflow-y: auto; display: grid; gap: 2px; min-height: 0; }
2725
+ .option {
2726
+ display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 10px; border-radius: 12px; cursor: pointer;
2727
+ }
2728
+ .option:hover, .option.active { background: rgb(var(--paper)); }
2729
+ .option-text { flex: 1; min-width: 0; display: grid; }
2730
+ .option-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2731
+ .option-path { font-size: 12px; color: rgb(var(--muted)); }
2732
+ .option .icon { color: rgb(var(--accent)); }
2733
+ .badge {
2734
+ flex: none; font-size: 12px; padding: 0 8px; border-radius: 999px;
2735
+ background: rgb(var(--paper)); color: rgb(var(--soft));
2736
+ }
2737
+ .empty { padding: 4px 10px; color: rgb(var(--muted)); }
2738
+ .empty.error { color: rgb(var(--negative)); }
2739
+ .new-page { justify-content: flex-start; }
2740
+
2741
+ .tabs {
2742
+ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; flex: none;
2743
+ margin: 12px 14px 0; padding: 3px; border-radius: 999px; background: rgb(var(--actions));
2744
+ }
2745
+ .tab {
2746
+ display: flex; align-items: center; justify-content: center; gap: 6px; height: 38px;
2747
+ border-radius: 999px; font-size: 13px; font-weight: 500; color: rgb(var(--soft));
2748
+ }
2749
+ .tab:hover { color: rgb(var(--fg)); }
2750
+ .tab[aria-selected='true'] { background: rgb(var(--bg)); color: rgb(var(--fg)); box-shadow: var(--shadow-small); }
2751
+ .tab .mark svg { width: 12px; height: 12px; }
2752
+ .ask, .fields { flex: 1; min-height: 0; display: flex; flex-direction: column; }
2753
+ .scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 14px; display: flex; flex-direction: column; gap: 14px; }
2754
+ .note {
2755
+ display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px;
2756
+ background: rgb(var(--coral-wash)); color: rgb(var(--coral-ink)); font-size: 12px; line-height: 18px;
2757
+ }
2758
+ .note > span:not(.icon) { flex: 1; min-width: 0; }
2759
+ .note strong { font-weight: 600; }
2760
+ .note button {
2761
+ flex: none; font-size: 12px; font-weight: 500; text-decoration: underline; text-underline-offset: 2px;
2762
+ }
2763
+ .note button::after { content: ''; position: absolute; inset: -13px -6px; }
2764
+ .intro { color: rgb(var(--muted)); font-size: 13px; }
2765
+ .gate {
2766
+ display: grid; gap: 8px; margin: 10px 14px 0; padding: 10px 12px; border-radius: 12px;
2767
+ background: rgb(var(--coral-wash)); color: rgb(var(--coral-ink)); font-size: 12px; line-height: 18px;
2768
+ }
2769
+ .shop { display: grid; gap: 10px; justify-items: start; padding: 12px; border-radius: 14px; background: rgb(var(--subtle)); }
2770
+ .log { display: flex; flex-direction: column; gap: 14px; }
2771
+ .bubble {
2772
+ align-self: flex-end; max-width: 80%; padding: 10px 12px; border-radius: 16px 16px 4px 16px;
2773
+ background: rgb(var(--fg)); color: rgb(var(--bg)); white-space: pre-wrap; overflow-wrap: anywhere;
2774
+ }
2775
+ .answer { display: flex; gap: 10px; align-items: flex-start; }
2776
+ .answer > .chip {
2777
+ display: inline-flex; align-items: center; justify-content: center; flex: none;
2778
+ width: 26px; height: 26px; border-radius: 50%; background: rgb(var(--tile));
2779
+ }
2780
+ .answer > .chip .mark svg { width: 12px; height: 12px; }
2781
+ .card { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
2782
+ .answer[data-status='pending'] .card { padding: 12px; border-radius: 16px; background: rgb(var(--coral-wash)); }
2783
+ .eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: rgb(var(--coral-ink)); }
2784
+ .answer:not([data-status='pending']) .eyebrow { color: rgb(var(--muted)); }
2785
+ .summary { color: rgb(var(--soft)); overflow-wrap: anywhere; }
2786
+ .answer[data-status='undone'] .summary { color: rgb(var(--muted)); }
2787
+ .status-line { display: flex; align-items: center; gap: 8px; color: rgb(var(--muted)); }
2788
+ .failure { display: flex; align-items: flex-start; gap: 8px; color: rgb(var(--negative)); overflow-wrap: anywhere; }
2789
+ .failure .icon { margin-top: 2px; }
2790
+ .diff-field { display: grid; gap: 4px; }
2791
+ .diff-label { font-size: 12px; font-weight: 500; color: rgb(var(--muted)); }
2792
+ .diff { border-radius: 14px; overflow: hidden; box-shadow: inset 0 0 0 1px rgb(var(--hairline)); }
2793
+ .diff > div { display: grid; gap: 2px; padding: 10px 12px; }
2794
+ .diff .before { background: rgb(var(--negative) / .07); color: rgb(var(--negative)); }
2795
+ .diff .after { background: rgb(var(--mint-wash)); color: rgb(var(--mint-ink)); }
2796
+ .diff .tag { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
2797
+ .diff .value {
2798
+ white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical;
2799
+ -webkit-line-clamp: 6; overflow: hidden;
2800
+ }
2801
+ .diff .before .value { text-decoration: line-through; text-decoration-color: rgb(var(--negative) / .5); }
2802
+ .diff .after .value { font-weight: 500; }
2803
+ .diff .value.none { text-decoration: none; font-style: italic; font-weight: 400; color: rgb(var(--muted)); }
2804
+ .more { font-size: 12px; color: rgb(var(--muted)); }
2805
+ .actions { display: flex; flex-wrap: wrap; gap: 6px; }
2806
+
2807
+ .button {
2808
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px;
2809
+ padding: 0 14px; border-radius: 999px; font-size: 13px; font-weight: 500; white-space: nowrap;
2810
+ }
2811
+ .button::after { content: ''; position: absolute; inset: -4px 0; }
2812
+ .button.primary { background: rgb(var(--fg)); color: rgb(var(--bg)); }
2813
+ .button.primary:hover:not(:disabled) { background: rgb(var(--soft)); }
2814
+ .button.secondary { background: rgb(var(--bg)); box-shadow: inset 0 0 0 1px rgb(var(--border-soft)); }
2815
+ .button.secondary:hover:not(:disabled) { background: rgb(var(--paper)); }
2816
+ .button.quiet { color: rgb(var(--soft)); padding: 0 10px; }
2817
+ .button.quiet:hover:not(:disabled) { background: rgb(var(--paper)); color: rgb(var(--fg)); }
2818
+ .button:disabled { background: rgb(var(--actions)); color: rgb(var(--muted)); box-shadow: none; }
2819
+ .button.quiet:disabled { background: none; }
2820
+
2821
+ .composer { flex: none; display: grid; gap: 8px; padding: 10px 14px 12px; }
2822
+ .path { display: flex; align-items: center; gap: 10px; color: rgb(var(--muted)); font-size: 13px; }
2823
+ .path input {
2824
+ flex: 1; min-width: 0; height: 40px; padding: 0 14px; outline: 0; color: rgb(var(--fg));
2825
+ border: 1px solid rgb(var(--border)); border-radius: 999px; background: rgb(var(--bg));
2826
+ }
2827
+ .path input:focus-visible { border-color: rgb(var(--accent)); }
2828
+ .bar {
2829
+ display: flex; align-items: flex-end; gap: 6px; padding: 0 0 0 14px; border-radius: 18px;
2830
+ background: rgb(var(--paper)); box-shadow: inset 0 0 0 1px rgb(var(--hairline));
2831
+ }
2832
+ .bar:focus-within { box-shadow: inset 0 0 0 1px rgb(var(--accent)), 0 0 0 3px rgb(var(--accent) / .18); }
2833
+ .bar > .mark { height: 44px; align-items: center; }
2834
+ /* The bar's ring shows focus. */
2835
+ textarea:focus-visible, .search input:focus-visible { outline: none; }
2836
+ textarea {
2837
+ flex: 1; min-width: 0; resize: none; min-height: 44px; max-height: 160px; padding: 12px 0;
2838
+ border: 0; outline: 0; background: transparent; line-height: 20px; color: rgb(var(--fg));
2839
+ }
2840
+ textarea:disabled, .path input:disabled { opacity: .6; }
2841
+ .send { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex: none; }
2842
+ .send::before {
2843
+ content: ''; position: absolute; width: 34px; height: 34px; border-radius: 50%; background: rgb(var(--accent));
2844
+ }
2845
+ .send > .icon, .send > .working { position: relative; color: rgb(var(--ink)); }
2846
+ .send:disabled::before { opacity: .4; }
2847
+ .send .icon svg { stroke-width: 2; }
2848
+ .send .working svg, .send .working .accent { fill: rgb(var(--ink)); }
2849
+
2850
+ .form { position: relative; flex: 1; min-height: 0; margin: 12px 14px 0; }
2851
+ .form iframe {
2852
+ display: block; width: 100%; height: 100% !important; border: 0; border-radius: 12px; background: rgb(var(--subtle));
2853
+ }
2854
+ .form.loading iframe { visibility: hidden; }
2855
+ .form-note {
2856
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px;
2857
+ padding: 16px; text-align: center; border-radius: 12px; background: rgb(var(--subtle)); color: rgb(var(--muted));
2858
+ }
2859
+ .form-note.error { color: rgb(var(--negative)); }
2860
+
2861
+ .foot {
2862
+ display: flex; align-items: center; gap: 8px; flex: none; padding: 12px 14px;
2863
+ border-top: 1px solid rgb(var(--hairline)); background: rgb(var(--subtle));
2864
+ }
2865
+ .save-state { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; line-height: 16px; color: rgb(var(--muted)); }
2866
+ .save-state .dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: rgb(var(--warning)); }
2867
+ .save-state[data-state='unsaved'] { color: rgb(var(--warning)); }
2868
+ .save-state[data-state='saved'] { color: rgb(var(--positive)); }
2869
+ .save-state[data-state='error'] { color: rgb(var(--negative)); }
2870
+ .save-state .icon svg { width: 14px; height: 14px; }
2871
+ .save-state .link { text-decoration: underline; text-underline-offset: 2px; font-weight: 500; flex: none; }
2872
+ .save-state .link::after { content: ''; position: absolute; inset: -14px -6px; }
2873
+
2874
+ .launcher {
2875
+ position: fixed; left: 20px; bottom: 20px; z-index: 2147483647;
2876
+ display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 999px;
2877
+ background: rgb(23 22 20); color: rgb(214 211 204); box-shadow: 0 8px 24px rgb(0 0 0 / .3);
2878
+ }
2879
+ @media (prefers-color-scheme: dark) { .launcher { box-shadow: 0 0 0 1px rgb(255 255 255 / .12), 0 8px 24px rgb(0 0 0 / .5); } }
2880
+ @media (max-width: 767px) { .launcher { left: 8px; bottom: 8px; } }
2881
+ .launcher button { height: 44px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; }
2882
+ .launcher button:hover { background: rgb(255 255 255 / .08); color: rgb(244 243 240); }
2883
+ .launcher :focus-visible { outline-color: rgb(212 130 30); }
2884
+ .launch { gap: 8px; padding: 0 12px 0 6px; color: rgb(244 243 240); font-size: 13px; font-weight: 500; }
2885
+ .launch-tile, .reading .launch-tile {
2886
+ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%;
2887
+ background: rgb(44 42 39);
2888
+ }
2889
+ .launch-tile .mark svg { width: 15px; height: 15px; }
2890
+ .reading { display: inline-flex; align-items: center; gap: 8px; padding: 0 8px 0 6px; color: rgb(244 243 240); font-size: 13px; font-weight: 500; }
2891
+ .eye { width: 44px; }
2892
+ .exit { padding: 0 14px; font-size: 13px; }
2893
+
2894
+ .layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483645; }
2895
+ .box { position: absolute; border-radius: 4px; }
2896
+ .box.hover { border: 1.5px dashed rgb(var(--border)); }
2897
+ .box.preview { border: 2px solid rgb(var(--accent)); }
2898
+ /* Its own layer, multiplied onto the page (a blend inside the marks layer
2899
+ wouldn't reach it): the wash tints the block's background, not its text. */
2900
+ .wash { position: fixed; inset: 0; pointer-events: none; z-index: 2147483644; mix-blend-mode: multiply; }
2901
+ .box.fill { background: rgb(255 243 236); }
2902
+ .name-tab {
2903
+ position: absolute; height: 20px; padding: 0 8px; border-radius: 6px 6px 0 0; white-space: nowrap;
2904
+ background: rgb(105 100 94); color: rgb(255 255 255); font-size: 11px; font-weight: 500; line-height: 20px;
2905
+ }
2906
+ .name-tab.inside { border-radius: 0 0 6px 0; }
2907
+ .chips { position: absolute; display: flex; gap: 4px; }
2908
+ .chips span {
2909
+ display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 999px;
2910
+ font-size: 12px; white-space: nowrap;
2911
+ }
2912
+ .chips .accent-chip { background: rgb(212 130 30); color: rgb(23 22 20); font-weight: 600; }
2913
+ .chips .accent-chip .mark { color: rgb(23 22 20); }
2914
+ .chips .accent-chip .mark svg { width: 11px; height: 11px; }
2915
+ .chips .ink-chip { background: rgb(23 22 20); color: rgb(255 255 255); font-weight: 500; }
2916
+ `;
2917
+ // Outlines on the page: the selection in the accent, hover drawn by the
2918
+ // layer instead (only the innermost block, with its name).
2919
+ // A site's own fixed or sticky elements stay on the viewport: they make room
2920
+ // for the panel with `right: var(--obelism-dock, 0px)` in their CSS, or with
2921
+ // the `data-obelism-dock` attribute.
2922
+ const pageCss = ()=>{
2923
+ const block = `[${preview.UID_ATTRIBUTE}]`;
2924
+ return `html[data-obelism-editor]{transition:margin-right .3s cubic-bezier(.2,.8,.2,1)}` + `html[data-obelism-docked]{margin-right:${DOCK_WIDTH_PX}px!important}` + `html[data-obelism-docked] [data-obelism-dock]{right:${DOCK_WIDTH_PX}px!important;max-width:calc(100vw - ${DOCK_WIDTH_PX}px)!important}` + `html[data-obelism-editor][data-cms-selecting] ${block}:hover:not([data-cms-selected]){outline:none}` + `html[data-obelism-editor][data-cms-selecting] ${block}[data-cms-selected]{outline:2px solid rgb(212 130 30);outline-offset:2px}` + `@media (prefers-reduced-motion:reduce){html[data-obelism-editor]{transition:none}}`;
2925
+ };
2926
+ // 'heroTitle', 'hero_title' → 'Hero title': block types are apiIds, and the
2927
+ // site API doesn't send their names.
2928
+ const humanize = (apiId)=>{
2929
+ const words = apiId.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim().toLowerCase();
2930
+ return words.charAt(0).toUpperCase() + words.slice(1);
2931
+ };
2932
+ const isRecord = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
2933
+ // Every text in a view (bridge.view()), by a path that follows blocks by
2934
+ // uid, so a block moving or one added doesn't read as everything changed.
2935
+ const leaves = (value, label, path, out)=>{
2936
+ if (value === null || value === undefined || value === '') return;
2937
+ if (typeof value !== 'object') {
2938
+ out.set(path, {
2939
+ label,
2940
+ text: String(value)
2941
+ });
2942
+ return;
2943
+ }
2944
+ if (Array.isArray(value)) {
2945
+ if (value.every((item)=>item === null || typeof item !== 'object')) {
2946
+ if (value.length) out.set(path, {
2947
+ label,
2948
+ text: value.join(', ')
2949
+ });
2950
+ return;
2951
+ }
2952
+ value.forEach((item, index)=>{
2953
+ const uid = isRecord(item) && typeof item._uid === 'string' ? item._uid : index;
2954
+ const type = isRecord(item) && typeof item._type === 'string' ? item._type : null;
2955
+ leaves(item, type ? humanize(type) : label, `${path}/${uid}`, out);
2956
+ });
2957
+ return;
2958
+ }
2959
+ for (const [key, child] of Object.entries(value)){
2960
+ if (key === '_uid' || key === '_type') continue;
2961
+ leaves(child, label ? `${label} · ${humanize(key)}` : humanize(key), `${path}/${key}`, out);
2962
+ }
2963
+ };
2964
+ // What a prompt changed, from the view before and after it.
2965
+ const diffViews = (before, after)=>{
2966
+ const was = new Map();
2967
+ const is = new Map();
2968
+ leaves(before, '', '', was);
2969
+ leaves(after, '', '', is);
2970
+ const diffs = [];
2971
+ for (const path of new Set([
2972
+ ...was.keys(),
2973
+ ...is.keys()
2974
+ ])){
2975
+ const old = was.get(path);
2976
+ const now = is.get(path);
2977
+ if (old?.text === now?.text) continue;
2978
+ diffs.push({
2979
+ label: (now ?? old).label,
2980
+ before: old?.text ?? '',
2981
+ after: now?.text ?? ''
2982
+ });
2983
+ }
2984
+ return diffs;
2985
+ };
2986
+ const readOpen = (win)=>{
2987
+ try {
2988
+ return win?.localStorage.getItem(OPEN_KEY) !== 'minimised';
2989
+ } catch {
2990
+ return true;
2991
+ }
2992
+ };
2993
+ // Mounts the editor on the page; returns a function that removes it.
2994
+ const mountSiteEditor = (doc, bridge, options = {})=>{
2995
+ const labels = {
2996
+ ...DEFAULT_SITE_EDITOR_LABELS,
2997
+ ...options.labels
2998
+ };
2999
+ const win = doc.defaultView;
3000
+ const html = doc.documentElement;
3001
+ const icon = (name)=>icon$1(doc, name);
3002
+ const mark = ()=>svgSpan(doc, 'mark', MARKS.obelism);
3003
+ const working = ()=>svgSpan(doc, 'working', MARKS.working);
3004
+ const button = (className, ...children)=>element(doc, 'button', {
3005
+ className,
3006
+ type: 'button'
3007
+ }, ...children);
3008
+ const host = element(doc, 'div');
3009
+ host.setAttribute('data-obelism-content-editor', '');
3010
+ const shadow = host.attachShadow({
3011
+ mode: 'open'
3012
+ });
3013
+ const pageStyle = element(doc, 'style', {
3014
+ textContent: pageCss()
3015
+ });
3016
+ const tile = element(doc, 'span', {
3017
+ className: 'tile'
3018
+ }, mark());
3019
+ const pageButtonText = element(doc, 'span');
3020
+ const pageButton = button('page-button hit', pageButtonText, element(doc, 'span', {
3021
+ className: 'visually-hidden',
3022
+ textContent: `, ${labels.pages}`
3023
+ }), icon('chevrons'));
3024
+ pageButton.setAttribute('aria-haspopup', 'listbox');
3025
+ pageButton.setAttribute('aria-expanded', 'false');
3026
+ pageButton.setAttribute('aria-controls', 'obelism-pages');
3027
+ const pageName = element(doc, 'span', {
3028
+ className: 'page-name'
3029
+ });
3030
+ const envText = element(doc, 'span');
3031
+ const env = element(doc, 'span', {
3032
+ className: 'env'
3033
+ }, element(doc, 'span', {
3034
+ className: 'visually-hidden',
3035
+ textContent: `${labels.environment}: `
3036
+ }), envText);
3037
+ const minimise = button('icon-button hit', icon('dash'));
3038
+ minimise.setAttribute('aria-label', labels.minimize);
3039
+ minimise.title = labels.minimize;
3040
+ const head = element(doc, 'div', {
3041
+ className: 'head'
3042
+ }, tile, pageButton, pageName, env, element(doc, 'span', {
3043
+ className: 'spacer'
3044
+ }), minimise);
3045
+ const picker = createPagesPicker(doc, bridge, labels, {
3046
+ onLoad: ()=>refresh(),
3047
+ onClose: ()=>setPicking(false)
3048
+ });
3049
+ const newPage = button('button quiet new-page', icon('plus'), labels.newPage);
3050
+ const pagesBox = element(doc, 'div', {
3051
+ className: 'pages'
3052
+ }, picker.searchRow, picker.list, picker.note, newPage);
3053
+ const tab = (id, controls, ...children)=>{
3054
+ const node = button('tab', ...children);
3055
+ node.id = id;
3056
+ node.setAttribute('role', 'tab');
3057
+ node.setAttribute('aria-controls', controls);
3058
+ return node;
3059
+ };
3060
+ const askTab = tab('obelism-tab-ask', 'obelism-ask', mark(), labels.modePrompt);
3061
+ const fieldsTab = tab('obelism-tab-fields', 'obelism-fields', labels.form);
3062
+ const tabs = element(doc, 'div', {
3063
+ className: 'tabs'
3064
+ }, askTab, fieldsTab);
3065
+ tabs.setAttribute('role', 'tablist');
3066
+ tabs.setAttribute('aria-label', labels.modes);
3067
+ // Edit fields asked for while a suggestion waits: it's kept or undone
3068
+ // first, never by the switch itself.
3069
+ const gateKeep = button('button primary', labels.keep);
3070
+ const gateUndo = button('button quiet', labels.undo);
3071
+ const gate = element(doc, 'div', {
3072
+ className: 'gate'
3073
+ }, element(doc, 'p', {
3074
+ textContent: labels.keepBeforeFields
3075
+ }), element(doc, 'div', {
3076
+ className: 'actions'
3077
+ }, gateKeep, gateUndo));
3078
+ gate.setAttribute('role', 'alert');
3079
+ const noteText = element(doc, 'span');
3080
+ const wholePage = button('', labels.scopeEntry);
3081
+ const cancelNew = button('', labels.newPageCancel);
3082
+ const note = element(doc, 'div', {
3083
+ className: 'note'
3084
+ }, icon('scope'), noteText, wholePage, cancelNew);
3085
+ const intro = element(doc, 'p', {
3086
+ className: 'intro',
3087
+ textContent: labels.askIntro
3088
+ });
3089
+ const shopText = element(doc, 'p', {
3090
+ className: 'summary'
3091
+ });
3092
+ const shopButtonText = element(doc, 'span');
3093
+ const shopButton = button('button primary', icon('plus'), shopButtonText);
3094
+ const shop = element(doc, 'div', {
3095
+ className: 'shop'
3096
+ }, shopText, shopButton);
3097
+ const log = element(doc, 'div', {
3098
+ className: 'log'
3099
+ });
3100
+ log.setAttribute('role', 'log');
3101
+ const scroll = element(doc, 'div', {
3102
+ className: 'scroll'
3103
+ }, note, intro, shop, log);
3104
+ const pathInput = element(doc, 'input', {
3105
+ type: 'text',
3106
+ placeholder: labels.pathPlaceholder,
3107
+ autocomplete: 'off',
3108
+ spellcheck: false
3109
+ });
3110
+ const pathRow = element(doc, 'label', {
3111
+ className: 'path'
3112
+ }, element(doc, 'span', {
3113
+ textContent: labels.path
3114
+ }), pathInput);
3115
+ const input = element(doc, 'textarea', {
3116
+ rows: 1
3117
+ });
3118
+ input.setAttribute('aria-label', labels.open);
3119
+ const sendIcon = icon('arrowUp');
3120
+ const sendWorking = working();
3121
+ const send = element(doc, 'button', {
3122
+ className: 'send',
3123
+ type: 'submit',
3124
+ title: labels.send
3125
+ }, element(doc, 'span', {
3126
+ className: 'visually-hidden',
3127
+ textContent: labels.send
3128
+ }), sendIcon, sendWorking);
3129
+ const bar = element(doc, 'div', {
3130
+ className: 'bar'
3131
+ }, mark(), input, send);
3132
+ const composer = element(doc, 'form', {
3133
+ className: 'composer'
3134
+ }, pathRow, bar);
3135
+ const ask = element(doc, 'div', {
3136
+ className: 'ask',
3137
+ id: 'obelism-ask'
3138
+ }, scroll, composer);
3139
+ ask.setAttribute('role', 'tabpanel');
3140
+ ask.setAttribute('aria-labelledby', askTab.id);
3141
+ const formNote = element(doc, 'p', {
3142
+ className: 'form-note'
3143
+ });
3144
+ formNote.setAttribute('role', 'status');
3145
+ const formBox = element(doc, 'div', {
3146
+ className: 'form'
3147
+ }, formNote);
3148
+ const fields = element(doc, 'div', {
3149
+ className: 'fields',
3150
+ id: 'obelism-fields'
3151
+ }, formBox);
3152
+ fields.setAttribute('role', 'tabpanel');
3153
+ fields.setAttribute('aria-labelledby', fieldsTab.id);
3154
+ const saveState = element(doc, 'p', {
3155
+ className: 'save-state'
3156
+ });
3157
+ saveState.setAttribute('aria-live', 'polite');
3158
+ const footerActions = element(doc, 'slot');
3159
+ footerActions.name = 'actions-footer';
3160
+ const undo = button('button quiet', icon('undo'), labels.undo);
3161
+ const save = button('button secondary', labels.saveDraft);
3162
+ const publish = button('button primary', labels.publish);
3163
+ const foot = element(doc, 'div', {
3164
+ className: 'foot'
3165
+ }, saveState, footerActions, undo, save, publish);
3166
+ const announcer = element(doc, 'p', {
3167
+ className: 'visually-hidden'
3168
+ });
3169
+ announcer.setAttribute('role', 'status');
3170
+ const panel = element(doc, 'aside', {
3171
+ className: 'panel',
3172
+ id: 'obelism-editor'
3173
+ }, head, pagesBox, tabs, gate, ask, fields, foot, announcer);
3174
+ panel.setAttribute('aria-label', labels.editor);
3175
+ const launch = button('launch', element(doc, 'span', {
3176
+ className: 'launch-tile'
3177
+ }, mark()), labels.editing);
3178
+ launch.setAttribute('aria-controls', panel.id);
3179
+ const reading = element(doc, 'span', {
3180
+ className: 'reading'
3181
+ }, element(doc, 'span', {
3182
+ className: 'launch-tile'
3183
+ }, mark()), labels.viewingDrafts);
3184
+ const eyeOn = icon('eye');
3185
+ const eyeOff = icon('eyeOff');
3186
+ const eye = button('eye', eyeOn, eyeOff);
3187
+ eye.setAttribute('aria-label', labels.hideOutlines);
3188
+ eye.title = labels.hideOutlines;
3189
+ const barActions = element(doc, 'slot');
3190
+ barActions.name = 'actions-bar';
3191
+ const exit = button('exit', labels.signOut);
3192
+ const launcher = element(doc, 'div', {
3193
+ className: 'launcher'
3194
+ }, launch, reading, eye, barActions, exit);
3195
+ if (options.actions) host.appendChild(options.actions);
3196
+ const layer = element(doc, 'div', {
3197
+ className: 'layer'
3198
+ });
3199
+ layer.setAttribute('aria-hidden', 'true');
3200
+ const wash = element(doc, 'div', {
3201
+ className: 'wash'
3202
+ });
3203
+ wash.setAttribute('aria-hidden', 'true');
3204
+ const root = element(doc, 'div', {
3205
+ className: 'root'
3206
+ }, wash, layer, panel, launcher);
3207
+ shadow.append(element(doc, 'style', {
3208
+ textContent: CSS
3209
+ }), root);
3210
+ let open = readOpen(win);
3211
+ let mode = 'ask';
3212
+ let gated = false;
3213
+ let creating = false;
3214
+ let outlines = true;
3215
+ let picking = false;
3216
+ let stored = null;
3217
+ let wasUnsaved = false;
3218
+ let shopMessage = null;
3219
+ const answers = [];
3220
+ let wide = true;
3221
+ let hoverUid = null;
3222
+ // Block names by uid, from the views; null when it couldn't be read.
3223
+ const names = new Map();
3224
+ let pageNames = 'idle';
3225
+ let destroyed = false;
3226
+ const formSlot = createFormSlot(bridge, formBox, labels.formTitle, {
3227
+ onReady: ()=>refresh(),
3228
+ onError: ()=>refresh(),
3229
+ onEscape: ()=>stepBack()
3230
+ });
3231
+ const pending = ()=>answers.filter(({ status })=>status === 'pending');
3232
+ const latestPending = ()=>pending().slice(-1)[0] ?? null;
3233
+ // Decided on while nothing else runs: undo would take the running one's
3234
+ // step.
3235
+ const decidable = ()=>answers.some(({ status })=>status === 'working') ? null : latestPending();
3236
+ const announce = (text)=>{
3237
+ announcer.textContent = '';
3238
+ announcer.textContent = text;
3239
+ };
3240
+ const nameOf = (uid)=>{
3241
+ if (!names.has(uid) && pageNames !== 'loading') {
3242
+ names.set(uid, null);
3243
+ bridge.view({
3244
+ uid
3245
+ }).then((view)=>{
3246
+ if (typeof view._type === 'string') names.set(uid, humanize(view._type));
3247
+ refresh();
3248
+ }, ()=>{});
3249
+ }
3250
+ return names.get(uid) ?? labels.block;
3251
+ };
3252
+ const readNames = (value)=>{
3253
+ if (Array.isArray(value)) value.forEach(readNames);
3254
+ else if (isRecord(value)) {
3255
+ if (typeof value._uid === 'string' && typeof value._type === 'string') names.set(value._uid, humanize(value._type));
3256
+ Object.values(value).forEach(readNames);
3257
+ }
3258
+ };
3259
+ const readPageNames = ()=>{
3260
+ if (pageNames !== 'idle' || bridge.entryId === null) return;
3261
+ pageNames = 'loading';
3262
+ const done = ()=>{
3263
+ pageNames = 'done';
3264
+ refresh();
3265
+ };
3266
+ bridge.view({
3267
+ uid: null
3268
+ }).then((view)=>{
3269
+ readNames(view);
3270
+ done();
3271
+ }, done);
3272
+ };
3273
+ // The page's own title: its entry's name, else the document's.
3274
+ const knownTitle = ()=>picker.current()?.name || doc.title.trim();
3275
+ const pageTitle = ()=>knownTitle() || labels.pages;
3276
+ let frame = null;
3277
+ const drawMarks = (state)=>{
3278
+ layer.replaceChildren();
3279
+ wash.replaceChildren();
3280
+ if (!marksShown(state)) return;
3281
+ const rectOf = (selector)=>{
3282
+ const target = doc.querySelector(selector);
3283
+ const rect = target?.getBoundingClientRect();
3284
+ return rect && (rect.width || rect.height) ? rect : null;
3285
+ };
3286
+ const box = (rect, kind, into = layer)=>{
3287
+ const node = element(doc, 'div', {
3288
+ className: `box ${kind}`
3289
+ });
3290
+ Object.assign(node.style, {
3291
+ top: `${rect.top - 3}px`,
3292
+ left: `${rect.left - 3}px`,
3293
+ width: `${rect.width + 6}px`,
3294
+ height: `${rect.height + 6}px`
3295
+ });
3296
+ into.appendChild(node);
3297
+ };
3298
+ const chips = (rect, ...nodes)=>{
3299
+ const row = element(doc, 'div', {
3300
+ className: 'chips'
3301
+ }, ...nodes);
3302
+ // Above the block, or inside its top when the screen's edge is
3303
+ // there.
3304
+ const top = rect.top - 34 < 0 ? rect.top + 6 : rect.top - 34;
3305
+ Object.assign(row.style, {
3306
+ top: `${top}px`,
3307
+ left: `${rect.left - 3}px`
3308
+ });
3309
+ layer.appendChild(row);
3310
+ };
3311
+ const accentChip = (text, preview)=>{
3312
+ const chip = element(doc, 'span', {
3313
+ className: 'accent-chip'
3314
+ });
3315
+ if (preview) chip.appendChild(mark());
3316
+ chip.appendChild(doc.createTextNode(text));
3317
+ return chip;
3318
+ };
3319
+ const previewing = latestPending();
3320
+ const previewSelector = previewing ? previewing.uid ? blockSelector(previewing.uid) : `[${preview.ENTRY_ATTRIBUTE}]` : null;
3321
+ const previewRect = previewSelector ? rectOf(previewSelector) : null;
3322
+ if (previewRect) box(previewRect, 'fill', wash);
3323
+ if (hoverUid && hoverUid !== state.selectedUid) {
3324
+ const rect = rectOf(blockSelector(hoverUid));
3325
+ if (rect) {
3326
+ box(rect, 'hover');
3327
+ const inside = rect.top - 23 < 0;
3328
+ const tab = element(doc, 'span', {
3329
+ className: `name-tab${inside ? ' inside' : ''}`,
3330
+ textContent: nameOf(hoverUid)
3331
+ });
3332
+ Object.assign(tab.style, {
3333
+ top: `${inside ? rect.top - 1.5 : rect.top - 23}px`,
3334
+ left: `${rect.left - 3}px`
3335
+ });
3336
+ layer.appendChild(tab);
3337
+ }
3338
+ }
3339
+ const selectedPreviewing = !!previewing && previewing.uid === state.selectedUid;
3340
+ if (state.selectedUid) {
3341
+ const rect = rectOf(blockSelector(state.selectedUid));
3342
+ if (rect) {
3343
+ const name = nameOf(state.selectedUid);
3344
+ const title = knownTitle();
3345
+ chips(rect, accentChip(selectedPreviewing ? fill(labels.previewChip, {
3346
+ name
3347
+ }) : name, selectedPreviewing), ...title ? [
3348
+ element(doc, 'span', {
3349
+ className: 'ink-chip',
3350
+ textContent: title
3351
+ })
3352
+ ] : []);
3353
+ }
3354
+ }
3355
+ if (previewRect && !selectedPreviewing) {
3356
+ box(previewRect, 'preview');
3357
+ const name = previewing.uid ? nameOf(previewing.uid) : pageTitle();
3358
+ chips(previewRect, accentChip(fill(labels.previewChip, {
3359
+ name
3360
+ }), true));
3361
+ }
3362
+ };
3363
+ const scheduleMarks = ()=>{
3364
+ if (frame !== null || !win) return;
3365
+ frame = win.requestAnimationFrame(()=>{
3366
+ frame = null;
3367
+ drawMarks(bridge.getState());
3368
+ });
3369
+ };
3370
+ const panelShown = (state)=>state.features.includes('prompt') && open;
3371
+ const marksShown = (state)=>panelShown(state) && outlines && !creating;
3372
+ const value = (text)=>element(doc, 'span', {
3373
+ className: `value${text ? '' : ' none'}`,
3374
+ textContent: text || labels.emptyValue
3375
+ });
3376
+ // Fills an answer's card once it's answered; afterwards only its eyebrow
3377
+ // and actions change, so the log reads out only what's new.
3378
+ const answered = (answer)=>{
3379
+ const card = answer.node.querySelector('.card');
3380
+ card.querySelector('.status-line')?.remove();
3381
+ if (answer.status === 'failed') {
3382
+ card.appendChild(element(doc, 'p', {
3383
+ className: 'failure'
3384
+ }, icon('alert'), answer.summary));
3385
+ return;
3386
+ }
3387
+ card.appendChild(answer.eyebrow);
3388
+ if (answer.summary) card.appendChild(element(doc, 'p', {
3389
+ className: 'summary',
3390
+ textContent: answer.summary
3391
+ }));
3392
+ for (const diff of answer.diffs.slice(0, MAX_DIFFS)){
3393
+ const field = element(doc, 'div', {
3394
+ className: 'diff-field'
3395
+ }, element(doc, 'span', {
3396
+ className: 'diff-label',
3397
+ textContent: diff.label
3398
+ }), element(doc, 'div', {
3399
+ className: 'diff'
3400
+ }, element(doc, 'div', {
3401
+ className: 'before'
3402
+ }, element(doc, 'span', {
3403
+ className: 'tag',
3404
+ textContent: labels.before
3405
+ }), value(diff.before)), element(doc, 'div', {
3406
+ className: 'after'
3407
+ }, element(doc, 'span', {
3408
+ className: 'tag',
3409
+ textContent: labels.after
3410
+ }), value(diff.after))));
3411
+ answer.details.push(field);
3412
+ card.appendChild(field);
3413
+ }
3414
+ if (answer.diffs.length > MAX_DIFFS) {
3415
+ const more = element(doc, 'p', {
3416
+ className: 'more',
3417
+ textContent: fill(labels.moreChanges, {
3418
+ count: answer.diffs.length - MAX_DIFFS
3419
+ })
3420
+ });
3421
+ answer.details.push(more);
3422
+ card.appendChild(more);
3423
+ }
3424
+ if (answer.status === 'pending') {
3425
+ const keep = button('button primary', labels.keep);
3426
+ const retry = button('button secondary', labels.retry);
3427
+ const back = button('button quiet', labels.undo);
3428
+ keep.addEventListener('click', ()=>keepAll());
3429
+ retry.addEventListener('click', ()=>void tryAnother(answer));
3430
+ back.addEventListener('click', ()=>undoAnswer(answer));
3431
+ answer.actions = element(doc, 'div', {
3432
+ className: 'actions'
3433
+ }, keep, retry, back);
3434
+ card.appendChild(answer.actions);
3435
+ }
3436
+ };
3437
+ // Brings each card's eyebrow, actions and details in line with its
3438
+ // status, touching only what changed.
3439
+ const syncAnswers = ()=>{
3440
+ const deciding = decidable();
3441
+ for (const answer of answers){
3442
+ if (answer.status === 'working' || answer.status === 'failed') continue;
3443
+ answer.node.dataset.status = answer.status;
3444
+ const eyebrow = answer.status === 'pending' ? labels.suggested : answer.status === 'kept' ? labels.kept : labels.undone;
3445
+ if (answer.eyebrow.textContent !== eyebrow) answer.eyebrow.textContent = eyebrow;
3446
+ if (answer.actions) answer.actions.hidden = answer !== deciding;
3447
+ for (const detail of answer.details)detail.hidden = answer.status === 'undone';
3448
+ }
3449
+ };
3450
+ const addAnswer = (prompt, uid)=>{
3451
+ const card = element(doc, 'div', {
3452
+ className: 'card'
3453
+ }, element(doc, 'span', {
3454
+ className: 'visually-hidden',
3455
+ textContent: `${labels.obelism}: `
3456
+ }), element(doc, 'p', {
3457
+ className: 'status-line'
3458
+ }, working(), creating ? labels.creating : labels.working));
3459
+ const node = element(doc, 'div', {
3460
+ className: 'answer'
3461
+ }, element(doc, 'span', {
3462
+ className: 'chip'
3463
+ }, mark()), card);
3464
+ node.dataset.status = 'working';
3465
+ const answer = {
3466
+ prompt,
3467
+ uid,
3468
+ status: 'working',
3469
+ summary: '',
3470
+ diffs: [],
3471
+ step: null,
3472
+ node,
3473
+ eyebrow: element(doc, 'p', {
3474
+ className: 'eyebrow'
3475
+ }),
3476
+ actions: null,
3477
+ details: []
3478
+ };
3479
+ answers.push(answer);
3480
+ log.appendChild(node);
3481
+ return answer;
3482
+ };
3483
+ const render = (state)=>{
3484
+ if (destroyed) return;
3485
+ const available = state.features.includes('prompt');
3486
+ const storing = state.saving || state.publishing;
3487
+ const busy = state.prompting || storing;
3488
+ const shown = available && open;
3489
+ const { site, canCreate, pageless, shopHere, onlyNew } = pageContext(bridge, state, options.promptWithoutPage);
3490
+ if (!canCreate) creating = false;
3491
+ if (onlyNew) creating = true;
3492
+ const hasForm = site && !creating && !pageless && state.features.includes('form');
3493
+ if (!hasForm) mode = 'ask';
3494
+ const waiting = pending().length > 0;
3495
+ if (!waiting || mode === 'fields') gated = false;
3496
+ // Steps undone elsewhere (or saved away) aren't any answer's any more.
3497
+ for (const answer of answers)if (answer.step !== null && answer.step > (state.undoSteps ?? 0)) answer.step = null;
3498
+ root.hidden = !available && !site;
3499
+ panel.toggleAttribute('data-closed', !shown);
3500
+ panel.inert = !shown;
3501
+ launch.hidden = !available;
3502
+ launch.setAttribute('aria-expanded', String(shown));
3503
+ reading.hidden = available;
3504
+ eye.hidden = !shown;
3505
+ eye.setAttribute('aria-pressed', String(!outlines));
3506
+ eyeOn.hidden = !outlines;
3507
+ eyeOff.hidden = outlines;
3508
+ exit.hidden = options.signOut === false;
3509
+ // The site beside the panel, wide enough; a sheet over it otherwise.
3510
+ const docked = shown && wide;
3511
+ html.toggleAttribute('data-obelism-docked', docked);
3512
+ html.style.setProperty('--obelism-dock', `${docked ? DOCK_WIDTH_PX : 0}px`);
3513
+ const selecting = shown && outlines && !creating;
3514
+ if (state.selecting !== selecting) bridge.setSelecting(selecting);
3515
+ const showForm = shown && mode === 'fields';
3516
+ if (showForm && !formSlot.mounted) formSlot.mount();
3517
+ else if (!showForm && formSlot.mounted) formSlot.unmount();
3518
+ if (picking && !shown) picking = false;
3519
+ pageButtonText.textContent = pageTitle();
3520
+ pageButton.hidden = picker.pages === null && picker.error !== null;
3521
+ pageName.hidden = !pageButton.hidden;
3522
+ pageName.textContent = pageTitle();
3523
+ pageButton.setAttribute('aria-expanded', String(picking));
3524
+ pagesBox.hidden = !picking;
3525
+ newPage.hidden = !canCreate || creating;
3526
+ newPage.disabled = busy;
3527
+ const environment = bridge.environment;
3528
+ env.hidden = !environment;
3529
+ env.dataset.env = environment ?? '';
3530
+ envText.textContent = environment === 'production' ? labels.envProduction : environment ?? '';
3531
+ tabs.hidden = !hasForm;
3532
+ askTab.setAttribute('aria-selected', String(mode === 'ask'));
3533
+ fieldsTab.setAttribute('aria-selected', String(mode === 'fields'));
3534
+ askTab.disabled = fieldsTab.disabled = busy;
3535
+ askTab.tabIndex = mode === 'ask' ? 0 : -1;
3536
+ fieldsTab.tabIndex = mode === 'fields' ? 0 : -1;
3537
+ gate.hidden = !gated;
3538
+ gateKeep.disabled = gateUndo.disabled = busy;
3539
+ ask.hidden = mode !== 'ask';
3540
+ fields.hidden = mode !== 'fields';
3541
+ const offerShop = shopHere !== null && !creating;
3542
+ if (creating) {
3543
+ noteText.textContent = labels.newPage;
3544
+ } else if (state.selectedUid) {
3545
+ const [beforeName, afterName = ''] = labels.scopeNoteBlock.split('{name}');
3546
+ noteText.replaceChildren(beforeName ?? '', element(doc, 'strong', {
3547
+ textContent: nameOf(state.selectedUid)
3548
+ }), afterName);
3549
+ } else {
3550
+ noteText.textContent = labels.scopeNotePage;
3551
+ }
3552
+ note.hidden = offerShop || pageless && !creating && !options.promptWithoutPage;
3553
+ wholePage.hidden = creating || !state.selectedUid;
3554
+ cancelNew.hidden = !creating || onlyNew;
3555
+ intro.hidden = answers.length > 0 || offerShop || creating;
3556
+ shop.hidden = !offerShop;
3557
+ const shopWords = shopCopy(labels, shopHere);
3558
+ shopButtonText.textContent = state.prompting && offerShop ? shopWords.adding : shopWords.add;
3559
+ shopButton.disabled = busy;
3560
+ shopText.textContent = shopMessage?.text ?? shopWords.hint;
3561
+ shopText.className = shopMessage?.error ? 'failure' : 'summary';
3562
+ composer.hidden = offerShop;
3563
+ pathRow.hidden = !creating;
3564
+ pathInput.disabled = busy;
3565
+ input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
3566
+ input.disabled = busy;
3567
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
3568
+ send.setAttribute('aria-busy', String(state.prompting));
3569
+ sendIcon.hidden = state.prompting;
3570
+ sendWorking.hidden = !state.prompting;
3571
+ undo.hidden = waiting || !state.canUndo || creating;
3572
+ undo.disabled = busy;
3573
+ save.hidden = !state.features.includes('save') || pageless;
3574
+ save.disabled = busy || waiting || !state.unsaved;
3575
+ publish.hidden = !state.features.includes('publish') || pageless;
3576
+ publish.disabled = busy || waiting;
3577
+ if (options.actions) options.actions.slot = shown ? 'actions-footer' : 'actions-bar';
3578
+ foot.hidden = !site;
3579
+ // New changes make the last save's word stale.
3580
+ if (state.unsaved && !wasUnsaved) stored = null;
3581
+ wasUnsaved = state.unsaved;
3582
+ const saveIcon = storing ? working() : stored?.kind === 'error' ? icon('alert') : waiting || state.unsaved ? element(doc, 'span', {
3583
+ className: 'dot'
3584
+ }) : icon('check');
3585
+ const saveText = state.saving ? labels.saving : state.publishing ? labels.publishing : stored?.kind === 'error' ? `${stored.action === 'save' ? labels.saveFailed : labels.publishFailed}: ${stored.text}` : waiting ? labels.keepFirst : state.unsaved ? labels.unsaved : stored?.kind === 'saved' ? labels.saved : stored?.kind === 'published' ? labels.published : labels.upToDate;
3586
+ const saveKind = storing ? 'saving' : stored?.kind === 'error' ? 'error' : waiting || state.unsaved ? 'unsaved' : 'saved';
3587
+ // Rewritten only when it says something new: it's a live region.
3588
+ if (saveState.dataset.state !== saveKind || saveState.dataset.text !== saveText) {
3589
+ saveState.dataset.state = saveKind;
3590
+ saveState.dataset.text = saveText;
3591
+ saveState.replaceChildren(saveIcon, element(doc, 'span', {
3592
+ textContent: saveText
3593
+ }));
3594
+ if (stored?.kind === 'error' && !storing) {
3595
+ const action = stored.action;
3596
+ const retry = button('link', labels.tryAgain);
3597
+ retry.addEventListener('click', ()=>void store(action));
3598
+ saveState.appendChild(retry);
3599
+ }
3600
+ }
3601
+ formBox.classList.toggle('loading', formSlot.loading || formSlot.error !== null);
3602
+ formNote.hidden = !formSlot.loading && formSlot.error === null;
3603
+ formNote.className = `form-note${formSlot.error !== null ? ' error' : ''}`;
3604
+ formNote.textContent = formSlot.error ?? labels.formLoading;
3605
+ syncAnswers();
3606
+ if (shown) {
3607
+ readPageNames();
3608
+ if (picker.pages === null && picker.error === null) void picker.fetch();
3609
+ }
3610
+ scheduleMarks();
3611
+ };
3612
+ const refresh = ()=>render(bridge.getState());
3613
+ const grow = ()=>{
3614
+ input.style.height = 'auto';
3615
+ input.style.height = `${input.scrollHeight}px`;
3616
+ };
3617
+ const scrollDown = ()=>{
3618
+ scroll.scrollTop = scroll.scrollHeight;
3619
+ };
3620
+ const remember = ()=>{
3621
+ try {
3622
+ win?.localStorage.setItem(OPEN_KEY, open ? 'open' : 'minimised');
3623
+ } catch {}
3624
+ };
3625
+ const setOpen = (next)=>{
3626
+ open = next;
3627
+ remember();
3628
+ if (!open) picking = false;
3629
+ refresh();
3630
+ if (open) {
3631
+ (mode === 'fields' ? fieldsTab : creating ? pathInput : input).focus();
3632
+ } else {
3633
+ launch.focus();
3634
+ }
3635
+ };
3636
+ const setMode = (next)=>{
3637
+ if (next === 'fields' && pending().length > 0) {
3638
+ gated = true;
3639
+ refresh();
3640
+ gateKeep.focus();
3641
+ return;
3642
+ }
3643
+ gated = false;
3644
+ if (mode === next) return refresh();
3645
+ mode = next;
3646
+ refresh();
3647
+ };
3648
+ const setCreating = (next)=>{
3649
+ creating = next;
3650
+ picking = false;
3651
+ if (creating) bridge.select(null);
3652
+ refresh();
3653
+ (creating ? pathInput : input).focus();
3654
+ };
3655
+ const setPicking = (next)=>{
3656
+ picking = next;
3657
+ if (picking) picker.reset();
3658
+ refresh();
3659
+ (picking ? picker.search : pageButton).focus();
3660
+ };
3661
+ // Escape, on the page or in the form: from the pages, from a block to the
3662
+ // whole page. True when it did something.
3663
+ const stepBack = ()=>{
3664
+ const state = bridge.getState();
3665
+ if (!panelShown(state)) return false;
3666
+ if (picking) setPicking(false);
3667
+ else if (state.selectedUid) bridge.select(null);
3668
+ else return false;
3669
+ return true;
3670
+ };
3671
+ const keepAll = (focus = true)=>{
3672
+ const waiting = pending();
3673
+ if (!waiting.length || !decidable()) return;
3674
+ waiting.forEach((answer)=>{
3675
+ answer.status = 'kept';
3676
+ });
3677
+ refresh();
3678
+ if (focus) input.focus();
3679
+ };
3680
+ const undoAnswer = (answer, focus = true)=>{
3681
+ if (answer !== decidable()) return;
3682
+ bridge.undo();
3683
+ answer.status = 'undone';
3684
+ refresh();
3685
+ if (focus) input.focus();
3686
+ };
3687
+ // Runs a prompt on `uid` (null: the page) and shows what it changed. The
3688
+ // change is on the page straight away (the bridge's unsaved content):
3689
+ // that is the preview, until it's kept or undone.
3690
+ const runPrompt = async (answer, text)=>{
3691
+ const scope = {
3692
+ uid: answer.uid
3693
+ };
3694
+ const before = await bridge.view(scope).catch(()=>null);
3695
+ try {
3696
+ const { summary } = await bridge.prompt(text, scope);
3697
+ answer.step = bridge.getState().undoSteps ?? null;
3698
+ const after = before ? await bridge.view(scope).catch(()=>null) : null;
3699
+ answer.summary = summary;
3700
+ answer.diffs = before && after ? diffViews(before, after) : [];
3701
+ answer.status = 'pending';
3702
+ } catch (error) {
3703
+ answer.summary = failure(error);
3704
+ answer.status = 'failed';
3705
+ }
3706
+ answered(answer);
3707
+ refresh();
3708
+ scrollDown();
3709
+ };
3710
+ const tryAnother = async (answer)=>{
3711
+ if (answer !== decidable()) return;
3712
+ bridge.undo();
3713
+ answer.status = 'undone';
3714
+ const next = addAnswer(answer.prompt, answer.uid);
3715
+ refresh();
3716
+ scrollDown();
3717
+ input.focus();
3718
+ await runPrompt(next, fill(labels.retryPrompt, {
3719
+ prompt: answer.prompt
3720
+ }));
3721
+ };
3722
+ const submit = async ()=>{
3723
+ const prompt = input.value.trim();
3724
+ const state = bridge.getState();
3725
+ if (!prompt || state.prompting) return;
3726
+ const path = pathInput.value.trim();
3727
+ if (creating && !path) return pathInput.focus();
3728
+ const uid = creating ? null : state.selectedUid;
3729
+ const scopeName = creating ? `${labels.newPage} · ${path}` : uid ? nameOf(uid) : labels.scopeEntry;
3730
+ log.appendChild(element(doc, 'p', {
3731
+ className: 'bubble'
3732
+ }, element(doc, 'span', {
3733
+ className: 'visually-hidden',
3734
+ textContent: `${labels.you} (${scopeName}): `
3735
+ }), prompt));
3736
+ input.value = '';
3737
+ grow();
3738
+ const answer = addAnswer(prompt, uid);
3739
+ refresh();
3740
+ scrollDown();
3741
+ if (creating) {
3742
+ try {
3743
+ // The bridge opens the new page; this shows until it does.
3744
+ await bridge.createPage(path, prompt);
3745
+ picker.forget();
3746
+ answer.summary = labels.created;
3747
+ answer.status = 'kept';
3748
+ } catch (error) {
3749
+ answer.summary = failure(error);
3750
+ answer.status = 'failed';
3751
+ }
3752
+ answered(answer);
3753
+ refresh();
3754
+ } else {
3755
+ await runPrompt(answer, prompt);
3756
+ }
3757
+ input.focus();
3758
+ };
3759
+ const store = async (action)=>{
3760
+ stored = null;
3761
+ const error = await storeChanges(bridge, action);
3762
+ stored = error ? {
3763
+ kind: 'error',
3764
+ action,
3765
+ text: error
3766
+ } : {
3767
+ kind: action === 'save' ? 'saved' : 'published'
3768
+ };
3769
+ refresh();
3770
+ };
3771
+ launch.addEventListener('click', ()=>setOpen(!open));
3772
+ minimise.addEventListener('click', ()=>setOpen(false));
3773
+ eye.addEventListener('click', ()=>{
3774
+ outlines = !outlines;
3775
+ refresh();
3776
+ });
3777
+ exit.addEventListener('click', ()=>bridge.signOut());
3778
+ pageButton.addEventListener('click', ()=>setPicking(!picking));
3779
+ newPage.addEventListener('click', ()=>setCreating(true));
3780
+ cancelNew.addEventListener('click', ()=>setCreating(false));
3781
+ wholePage.addEventListener('click', ()=>{
3782
+ bridge.select(null);
3783
+ input.focus();
3784
+ });
3785
+ shopButton.addEventListener('click', async ()=>{
3786
+ shopMessage = null;
3787
+ shopMessage = await addShopPage(bridge, labels);
3788
+ refresh();
3789
+ });
3790
+ askTab.addEventListener('click', ()=>setMode('ask'));
3791
+ fieldsTab.addEventListener('click', ()=>setMode('fields'));
3792
+ // Arrows move focus between the tabs; Enter or Space (a click) opens one.
3793
+ tabs.addEventListener('keydown', (event)=>{
3794
+ if (![
3795
+ 'ArrowLeft',
3796
+ 'ArrowRight',
3797
+ 'Home',
3798
+ 'End'
3799
+ ].includes(event.key)) return;
3800
+ event.preventDefault();
3801
+ const onAsk = shadow.activeElement === askTab;
3802
+ const target = event.key === 'Home' ? askTab : event.key === 'End' ? fieldsTab : onAsk ? fieldsTab : askTab;
3803
+ target.focus();
3804
+ });
3805
+ gateKeep.addEventListener('click', ()=>{
3806
+ keepAll(false);
3807
+ setMode('fields');
3808
+ fieldsTab.focus();
3809
+ });
3810
+ gateUndo.addEventListener('click', ()=>{
3811
+ for(let answer = decidable(); answer; answer = decidable())undoAnswer(answer, false);
3812
+ setMode('fields');
3813
+ fieldsTab.focus();
3814
+ });
3815
+ undo.addEventListener('click', ()=>{
3816
+ const step = bridge.getState().undoSteps;
3817
+ const answer = step === undefined ? undefined : answers.find((candidate)=>candidate.status === 'kept' && candidate.step === step);
3818
+ bridge.undo();
3819
+ if (answer) {
3820
+ answer.status = 'undone';
3821
+ refresh();
3822
+ announce(`${labels.undone}: ${answer.summary}`);
3823
+ }
3824
+ if (undo.hidden) input.focus();
3825
+ });
3826
+ save.addEventListener('click', ()=>void store('save'));
3827
+ publish.addEventListener('click', ()=>void store('publish'));
3828
+ composer.addEventListener('submit', (event)=>{
3829
+ event.preventDefault();
3830
+ void submit();
3831
+ });
3832
+ input.addEventListener('input', ()=>{
3833
+ grow();
3834
+ refresh();
3835
+ });
3836
+ input.addEventListener('keydown', (event)=>{
3837
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
3838
+ event.preventDefault();
3839
+ void submit();
3840
+ }
3841
+ });
3842
+ pathInput.addEventListener('input', refresh);
3843
+ pathInput.addEventListener('keydown', (event)=>{
3844
+ if (event.key === 'Enter' && !event.isComposing) {
3845
+ event.preventDefault();
3846
+ input.focus();
3847
+ }
3848
+ });
3849
+ const onKeydown = (event)=>{
3850
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
3851
+ if (stepBack()) event.preventDefault();
3852
+ };
3853
+ doc.addEventListener('keydown', onKeydown);
3854
+ const onPointerOver = (event)=>{
3855
+ const target = event.target;
3856
+ const block = target?.closest?.(`[${preview.UID_ATTRIBUTE}]`);
3857
+ const uid = block?.getAttribute(preview.UID_ATTRIBUTE) ?? null;
3858
+ if (uid === hoverUid) return;
3859
+ hoverUid = uid;
3860
+ scheduleMarks();
3861
+ };
3862
+ const onPointerLeave = ()=>{
3863
+ hoverUid = null;
3864
+ scheduleMarks();
3865
+ };
3866
+ doc.addEventListener('pointerover', onPointerOver);
3867
+ html.addEventListener('pointerleave', onPointerLeave);
3868
+ win?.addEventListener('scroll', scheduleMarks, {
3869
+ capture: true,
3870
+ passive: true
3871
+ });
3872
+ win?.addEventListener('resize', scheduleMarks);
3873
+ const Mutations = win?.MutationObserver;
3874
+ const mutations = typeof Mutations === 'function' ? new Mutations(scheduleMarks) : null;
3875
+ mutations?.observe(doc.body, {
3876
+ childList: true,
3877
+ subtree: true
3878
+ });
3879
+ // The page reflows as the panel docks.
3880
+ const Resizes = win?.ResizeObserver;
3881
+ const resizes = typeof Resizes === 'function' ? new Resizes(scheduleMarks) : null;
3882
+ resizes?.observe(html);
3883
+ const query = win?.matchMedia?.(DOCK_QUERY) ?? null;
3884
+ wide = query?.matches ?? true;
3885
+ const onQuery = ()=>{
3886
+ wide = query?.matches ?? true;
3887
+ refresh();
3888
+ };
3889
+ query?.addEventListener?.('change', onQuery);
3890
+ const unsubscribe = bridge.on('change', render);
3891
+ html.setAttribute('data-obelism-editor', '');
3892
+ doc.head.appendChild(pageStyle);
3893
+ doc.body.appendChild(host);
3894
+ refresh();
3895
+ return ()=>{
3896
+ destroyed = true;
3897
+ unsubscribe();
3898
+ formSlot.unmount();
3899
+ if (frame !== null) win?.cancelAnimationFrame?.(frame);
3900
+ doc.removeEventListener('keydown', onKeydown);
3901
+ doc.removeEventListener('pointerover', onPointerOver);
3902
+ html.removeEventListener('pointerleave', onPointerLeave);
3903
+ win?.removeEventListener('scroll', scheduleMarks, {
3904
+ capture: true
3905
+ });
3906
+ win?.removeEventListener('resize', scheduleMarks);
3907
+ query?.removeEventListener?.('change', onQuery);
3908
+ mutations?.disconnect();
3909
+ resizes?.disconnect();
3910
+ bridge.setSelecting(false);
3911
+ html.removeAttribute('data-obelism-editor');
3912
+ html.removeAttribute('data-obelism-docked');
3913
+ html.style.removeProperty('--obelism-dock');
3914
+ pageStyle.remove();
3915
+ host.remove();
3916
+ };
3917
+ };
3918
+
2436
3919
  // Kept on the window, so two copies of this package on one page still share
2437
3920
  // one bridge.
2438
3921
  const KEY = Symbol.for('@obelism/content/bridge');
@@ -2474,9 +3957,10 @@ const getContentBridge = ({ url, prompt = true, pagePath, productPath, collectio
2474
3957
  const doc = win.document;
2475
3958
  let unmount = ()=>{};
2476
3959
  let destroyed = false;
3960
+ const mountUi = sitePath ? mountSiteEditor : mountPromptBox;
2477
3961
  const mount = ()=>{
2478
3962
  if (destroyed) return;
2479
- unmount = mountPromptBox(doc, bridge, prompt === true ? {} : prompt);
3963
+ unmount = mountUi(doc, bridge, prompt === true ? {} : prompt);
2480
3964
  };
2481
3965
  // A script tag in <head> runs before there is a body to mount into.
2482
3966
  if (doc.body) mount();
@@ -2497,9 +3981,11 @@ const getContentBridge = ({ url, prompt = true, pagePath, productPath, collectio
2497
3981
  };
2498
3982
 
2499
3983
  exports.DEFAULT_PROMPT_LABELS = DEFAULT_PROMPT_LABELS;
3984
+ exports.DEFAULT_SITE_EDITOR_LABELS = DEFAULT_SITE_EDITOR_LABELS;
2500
3985
  exports.DEFAULT_STATS_LABELS = DEFAULT_STATS_LABELS;
2501
3986
  exports.INITIAL_STATE = INITIAL_STATE;
2502
3987
  exports.getContentBridge = getContentBridge;
2503
3988
  exports.mountPromptBox = mountPromptBox;
3989
+ exports.mountSiteEditor = mountSiteEditor;
2504
3990
  exports.mountStatsOverlay = mountStatsOverlay;
2505
3991
  exports.peekContentBridge = peekContentBridge;