emdash-vibe-editor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,9 @@ Node and on Cloudflare Workers.
33
33
  editor or admin any entry, the same rule EmDash's admin uses. The checks run
34
34
  before an AI call, so a change that would be refused costs no credits.
35
35
  - **Content only.** Text fields, rich-text blocks, image fields, alt text and
36
- the SEO title and description — never templates, settings or other fields.
36
+ the SEO title and description, also inside page-builder sections — never
37
+ templates, settings or other fields, and never a section's type, version
38
+ or key.
37
39
  `allow` narrows it to an explicit list.
38
40
  - **No fetching by address.** The picture an AI picture builds on is read from
39
41
  EmDash's media library, never fetched from a URL, and only PNG, JPEG or WebP
@@ -116,7 +118,87 @@ it off again. In edit mode the site shows drafts, as EmDash's editor does.
116
118
  | ---- | ---- |
117
119
  | `string`, `text` | Rewrite with AI or type, then save |
118
120
  | `portableText` | The same, one paragraph, heading, list item or quote at a time; the rest of the field is untouched |
119
- | `image` | New AI picture (built on the one there), a picture from the media library, a pasted `https://` address, and alt text |
121
+ | `image` | New AI picture (built on the one there), a picture from the media library, a pasted `https://` address (kept as EmDash's editor keeps one, `{ id: '', provider: 'external', src }`), and alt text |
122
+ | `blocks` | The `string`/`text`, `portableText` and `image` fields inside page-builder sections (and `string`/`text`/`image` fields of repeater rows), as above, when the template marks them (see below); other field types in a section are not editable |
123
+
124
+ ## Page-builder sections (blocks)
125
+
126
+ Texts, rich text and pictures inside a `blocks` field are marked with a
127
+ dotted path in the same `edit` proxy: the blocks field, the block's `_key`,
128
+ and the field's slug. A repeater item's field adds the repeater's slug and
129
+ the item's index (from 0):
130
+
131
+ ```astro
132
+ ---
133
+ const { entry: page } = await getEmDashEntry('pages', slug)
134
+ ---
135
+ {page.data.seksjoner.map((b) => (
136
+ <section>
137
+ <h2 {...page.edit[`seksjoner.${b._key}.title`]}>{b.title}</h2>
138
+
139
+ <div class="prose" {...page.edit[`seksjoner.${b._key}.intro`]}>
140
+ <PortableText value={b.intro} />
141
+ </div>
142
+
143
+ <figure {...page.edit[`seksjoner.${b._key}.bilde`]}>
144
+ <Image image={b.bilde} />
145
+ </figure>
146
+
147
+ <ol>
148
+ {b.steps.map((step, i) => (
149
+ <li>
150
+ <h3 {...page.edit[`seksjoner.${b._key}.steps.${i}.title`]}>{step.title}</h3>
151
+ <p {...page.edit[`seksjoner.${b._key}.steps.${i}.text`]}>{step.text}</p>
152
+ </li>
153
+ ))}
154
+ </ol>
155
+ </section>
156
+ ))}
157
+ ```
158
+
159
+ Put the mark on the element that shows exactly that text; for rich text on
160
+ the wrapper whose direct children are the rendered paragraphs, headings and
161
+ lists (one is edited at a time, as for a top-level rich-text field); for a
162
+ picture on the element (a `figure` or wrapper) around its single `<img>`.
163
+
164
+ - **What a field is** comes from the block type's definition in EmDash (the
165
+ version the block was saved with), read from the site's own block type
166
+ registry. Only `string`, `text`, `portableText` and `image` fields, and
167
+ `string`, `text` and `image` fields of a repeater's rows, can be edited;
168
+ links, choices, dates, files, numbers and the rest are refused before any
169
+ AI call. A request that doesn't fit the field (a picture for a text) is
170
+ refused with `422`.
171
+ - **Where the block types can't be read.** The plugin reads them through
172
+ EmDash's runtime, which works where the site's build bundles the plugin:
173
+ on Cloudflare Workers, and on Node with
174
+ `vite: { ssr: { noExternal: ['emdash-vibe-editor'] } }` in `astro.config`.
175
+ Otherwise (Node loading the plugin on its own) declare the editable fields
176
+ per block type in the `blocks` option (see Options); only those are then
177
+ edited, and EmDash's own check of the value (below) is skipped, so declare
178
+ only text, rich-text and picture fields, with their `maxLength`. With
179
+ neither, nothing inside a section is edited (`503`); top-level fields work
180
+ as before. Where the block types can be read, `blocks` only narrows them.
181
+ - **EmDash's rules hold.** A text can't be emptied and must keep to its
182
+ field's length limits (the AI is told the maximum); then the whole new
183
+ blocks value goes through EmDash's own check (`normalizeBlocksData`, as in
184
+ its admin) before it is written, and anything it refuses is refused (`422`).
185
+ A picture by address is stored as EmDash's editor stores one
186
+ (`{ id: '', provider: 'external', src }`).
187
+ - **Saving** reads the entry again just before the write and makes the change
188
+ from it (a picture swapped meanwhile keeps its alt text, another paragraph
189
+ changed meanwhile keeps its change), then writes the whole blocks field back
190
+ with only that one value changed: every block's `_key`, `_version` and
191
+ `_type`, and every other block, stay as they were. If the block or row was
192
+ removed in the meantime nothing is written (`409`). Drafts and publishing
193
+ work as for any other field (see below). EmDash gives plugins no
194
+ compare-and-set write, so a save in EmDash's admin landing in the few
195
+ milliseconds between that read and the write can still be overwritten.
196
+ - **The allow list** names the blocks field: `{ collection: 'pages', field:
197
+ 'seksjoner' }` allows everything editable inside it. Paths can't be listed
198
+ one by one.
199
+ - Only the paths above: a block's own field, and a field of a repeater row.
200
+ `_type`, `_version`, `_key`, rich text's blocks and deeper nesting are
201
+ refused.
120
202
 
121
203
  ## Saving and publishing
122
204
 
@@ -150,11 +232,19 @@ without it gets the button only when all its marks belong to one entry.
150
232
  vibeEditor({
151
233
  // Only these fields; `seo` is the title and description. Default: all.
152
234
  allow: [
235
+ { collection: 'pages', field: 'seksjoner' }, // a blocks field: everything inside it
153
236
  { collection: 'posts', field: 'title' },
154
237
  { collection: 'posts', field: 'content' },
155
238
  { collection: 'posts', field: 'featured_image' },
156
239
  { collection: 'posts', field: 'seo' },
157
240
  ],
241
+ // Fields inside page-builder sections, per block type: a text, rich text
242
+ // or picture, a text with its limits, or a repeater as its rows' fields.
243
+ // Needed only where EmDash's block types can't be read (see above).
244
+ blocks: {
245
+ hero: { title: { type: 'text', maxLength: 80, required: true }, intro: 'portableText', bilde: 'image' },
246
+ prosess: { steps: { title: 'text', text: 'text' } },
247
+ },
158
248
  publish: true, // false: changes always stay drafts
159
249
  baseUrl: 'https://vibe-editor.app', // another Vibe Editor, for testing
160
250
  })
@@ -1,3 +1,3 @@
1
1
  /* GENERATED by scripts/bundle-client.mjs — do not edit by hand. */
2
- export const overlayJs = "/*\n * GENERATED FILE – do not edit. Built by tools/build-overlays.mjs from\n * overlay-src/core.js, overlay-src/platform/next-payload.js, overlay-src/platform/emdash.js;\n * change those and run `node tools/build-overlays.mjs`.\n */\n/*\n * Vibe Editor – the browser overlay of the EmDash plugin (emdash-vibe-editor).\n *\n * How it works:\n * The plugin's page hook gives every page a small bootstrap. For a browser\n * EmDash's admin has marked as an editor's, it checks the session and loads\n * this overlay from the plugin's private `client` route, with\n * window.VibeEditor.apiBase set to /_emdash/api/plugins/vibe-editor.\n *\n * What is editable is what the site already marks for EmDash's visual\n * editing: {...entry.edit.title} in an Astro template puts\n * data-emdash-ref='{\"collection\":\"posts\",\"id\":\"…\",\"field\":\"title\"}' on the\n * element. EmDash writes those only in its edit mode, so turning Vibe's edit\n * mode on sets EmDash's edit-mode cookie (and reloads once if the page has\n * no marks yet); turning it off clears the cookie. In edit mode EmDash shows\n * drafts, as its own editor does.\n *\n * Which fields are text, rich text or images comes from GET status\n * ({ collections: { posts: { seo, fields: { title: 'string', … } } } }).\n * A rich-text (Portable Text) field is edited one block at a time: the\n * paragraph, heading, list item or quote that was clicked. Explicit\n * data-vibe-field marks work too, as on Next.js and Payload.\n *\n * The page's title and description are edited from the magnifier button on\n * a page that shows one entry of a collection with SEO fields (the page\n * hook names that entry in window.VibeEditor.page). They are kept in the\n * entry's SEO block, which EmDash saves at once, not as a draft.\n *\n * Auth: EmDash's own session cookie, plus the X-EmDash-Request header EmDash\n * wants on every call. The Vibe Editor connection lives server-side only.\n */\n( function () {\n\t'use strict';\n\n\t/*\n\t * Configuration every overlay reads, via a global set before this script loads:\n\t * window.VibeEditor = {\n\t * apiBase: '/api/vibe', // where the editor's API is (default /api/vibe)\n\t * mediaBase: '/api/vibe/media', // media-list route (default: apiBase + '/media')\n\t * enabled: true, // gate to admins in your own app logic\n\t * frame: true, // full-page edit-mode frame (default)\n\t * settings: true, // show the in-page settings (gear) button\n\t * label: 'Vibe Editor', // the switch's name for screen readers, off …\n\t * labelActive: 'Redigerer …', // … and on\n\t * size: 'medium', // bubble size: 'small' | 'medium' | 'large'\n\t * position: 'bottom-right',// 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'custom'\n\t * hSide: 'right', // for position:'custom' – 'right' | 'left'\n\t * hOffset: 24, // for position:'custom' – px from that side\n\t * vSide: 'bottom', // for position:'custom' – 'bottom' | 'top'\n\t * vOffset: 24, // for position:'custom' – px from that side\n\t * };\n\t * Size and position are also editable in the in-page settings popup and\n\t * persisted per-browser to localStorage, as are the edit frame and «Marker\n\t * redigerbare felt».\n\t */\n\tvar CFG = window.VibeEditor || {};\n\tif ( CFG.enabled === false ) {\n\t\treturn;\n\t}\n\n\tvar API = ( CFG.apiBase || '/api/vibe' ).replace( /\\/$/, '' );\n\tvar MEDIA = ( CFG.mediaBase || ( API + '/media' ) ).replace( /\\/$/, '' );\n\tvar SHOW_SETTINGS = CFG.settings !== false;\n\tvar SETTINGS_KEY = 'wpve-settings';\n\tvar LABEL_IDLE = CFG.label || 'Vibe Editor';\n\tvar LABEL_ACTIVE = CFG.labelActive || 'Redigerer …';\n\n\tvar editMode = false;\n\tvar bubble = null;\n\tvar settingsBtn = null;\n\tvar pageBtn = null;\n\tvar settingsPanel = null;\n\tvar panel = null;\n\tvar hovered = null;\n\tvar clickPos = null;\n\t// What is being edited: it keeps its outline while its panel is open.\n\tvar selected = null;\n\t// Allowed values for size/position — declared here (before loadSettings runs)\n\t// so settings validation never sees them undefined.\n\tvar SIZES = [ 'small', 'medium', 'large' ];\n\tvar POSITIONS = [ 'bottom-right', 'bottom-left', 'top-right', 'top-left', 'custom' ];\n\tvar settings = loadSettings();\n\t// The Vibe Editor account the site's AI runs on, once the platform has asked:\n\t// { balance, costs: { image, imageEdit }, error, … }. Null when it hasn't.\n\tvar account = null;\n\t// What the page panel says about titles and descriptions, on every platform.\n\tvar PAGE_GUIDE = 'Vises i søketreff og når siden deles. Tittel: helst under 60 tegn. Beskrivelse: 120–160 tegn.';\n\n\t/* ---------- Plattformdelen (overlay-src/platform/) ---------- */\n\n\t// What this platform does its own way: its settings from CFG, its helpers,\n\t// and `platform`, which gives the hooks in HOOKS below.\n\t/*\n\t * What the Next.js adapter and the Payload plugin share: their own server\n\t * holds the site's Vibe Editor key and asks Vibe Editor for AI. GET /status\n\t * says whether AI pictures can be made here ({ images }), the credits left and\n\t * what a picture costs, and for Next.js whether the page's title and\n\t * description can be edited ({ pageMeta }). A page's title and description go\n\t * to /suggest and /save as { meta } along with what the page is.\n\t */\n\n\t// Whether AI pictures can be made here. Gates the \"generate with AI\" panel.\n\t// Refreshed on load and whenever the settings popup opens.\n\tvar hasImageKey = false;\n\n\t/**\n\t * Ask the server whether AI pictures can be made here, and how many credits\n\t * are left. Best-effort: failure just leaves generation disabled (the\n\t * URL/library picker still works).\n\t */\n\tfunction refreshStatus() {\n\t\t// Sent the platform's way: EmDash wants its request header on a GET too.\n\t\tvar req = platform.request( 'status' );\n\t\tfetch( req.url, { credentials: req.credentials, headers: req.headers } )\n\t\t\t.then( function ( res ) {\n\t\t\t\treturn res.json();\n\t\t\t} )\n\t\t\t.then( function ( data ) {\n\t\t\t\taccount = data || null;\n\t\t\t\thasImageKey = !! ( data && data.images );\n\t\t\t\t// Title and description, once the server says where they are kept.\n\t\t\t\tif ( data && data.pageMeta ) {\n\t\t\t\t\taddPageButton();\n\t\t\t\t}\n\t\t\t\tvar note = settingsPanel && settingsPanel.querySelector( '.wpve-setting-note' );\n\t\t\t\tif ( note ) {\n\t\t\t\t\tnote.textContent = creditsNote();\n\t\t\t\t}\n\t\t\t} )\n\t\t\t.catch( function () {\n\t\t\t\thasImageKey = false;\n\t\t\t} );\n\t}\n\n\t// One line for the settings: whose credits the AI uses, and how many are left.\n\tfunction creditsNote() {\n\t\tif ( account && account.error ) {\n\t\t\treturn String( account.error );\n\t\t}\n\t\treturn 'AI bruker kredittene på Vibe Editor-kontoen.' + creditsLeft();\n\t}\n\n\tfunction creditsHtml() {\n\t\treturn '<div class=\"wpve-setting-note\"></div>';\n\t}\n\n\tfunction creditsShown() {\n\t\tsettingsPanel.querySelector( '.wpve-setting-note' ).textContent = creditsNote();\n\t\trefreshStatus();\n\t}\n\n\tfunction canMakePictures() {\n\t\treturn hasImageKey;\n\t}\n\n\t// Under the picture bar: what a picture costs, or why none can be made.\n\tfunction pictureHint( canGen ) {\n\t\tif ( canGen ) {\n\t\t\treturn imageCost();\n\t\t}\n\t\treturn account && account.error ? String( account.error ) : 'AI-bilder er ikke satt opp på denne nettsiden.';\n\t}\n\n\tfunction aiOffReason() {\n\t\treturn account && false === account.connected ? ( account.error || 'AI er ikke satt opp på denne nettsiden.' ) : '';\n\t}\n\n\t// Ett forslag per felt, hvert til prisen av et tekstforslag.\n\tfunction suggestPageMeta( kind, original, instruction, target ) {\n\t\treturn api( 'suggest', Object.assign( {\n\t\t\tmeta: kind,\n\t\t\toriginal: original,\n\t\t\tinstruction: instruction,\n\t\t}, target ) );\n\t}\n\n\t// Both in one call; an empty one goes back to the original.\n\tfunction savePageMeta( changes, target ) {\n\t\treturn api( 'save', Object.assign( { meta: changes }, target ) );\n\t}\n\n\tvar EMDASH_COOKIE = 'emdash-edit-mode';\n\t// Set before the reload that turns EmDash's edit mode on, so the overlay comes back editing.\n\tvar EMDASH_RESUME = 'vibe-emdash-resume';\n\t// The elements of a rich-text field that are edited one at a time.\n\tvar EMDASH_BLOCKS = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';\n\t// Field types that can be edited here; others (numbers, dates, references, …) are left alone.\n\tvar EMDASH_KINDS = { string: 1, text: 1, portableText: 1, image: 1 };\n\t// EmDash's media library, as its admin lists it.\n\tvar EMDASH_MEDIA = '/_emdash/api/media';\n\n\tvar platform = {\n\t\tstart: startEmDash,\n\t\tpageAtStart: true,\n\t\tpageShown: function () {\n\t\t\treturn !! pageTarget();\n\t\t},\n\t\tsettingsHtml: creditsHtml,\n\t\tsettingsOpened: creditsShown,\n\t\tcanGenerate: canMakePictures,\n\t\timageHint: pictureHint,\n\t\tfindAuto: emdashField,\n\t\trequest: function ( endpoint, payload ) {\n\t\t\treturn {\n\t\t\t\turl: API + '/' + endpoint,\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { 'Content-Type': 'application/json', 'X-EmDash-Request': '1' },\n\t\t\t\tbody: payload,\n\t\t\t};\n\t\t},\n\t\t// The block's text as it was goes along, so the server finds it in the rich text.\n\t\tsavePayload: function ( ctx, value ) {\n\t\t\treturn {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\tvalue: value,\n\t\t\t\toriginal: ctx.original,\n\t\t\t};\n\t\t},\n\t\tsaveAlt: function ( ctx, text ) {\n\t\t\treturn api( 'save', {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\talt: text,\n\t\t\t} );\n\t\t},\n\t\t// The library through EmDash's own admin API, which (unlike the plugin's) has\n\t\t// each picture's storage key: the save route sends \"<id>|<storageKey>\".\n\t\tlistMedia: function () {\n\t\t\treturn fetch( EMDASH_MEDIA + '?mimeType=image%2F&limit=24', {\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { 'X-EmDash-Request': '1' },\n\t\t\t} )\n\t\t\t\t.then( function ( res ) {\n\t\t\t\t\treturn res.json();\n\t\t\t\t} )\n\t\t\t\t.then( function ( body ) {\n\t\t\t\t\tvar items = ( body && body.data && body.data.items ) || [];\n\t\t\t\t\tvar docs = items\n\t\t\t\t\t\t.filter( function ( m ) {\n\t\t\t\t\t\t\treturn m && m.id && m.storageKey;\n\t\t\t\t\t\t} )\n\t\t\t\t\t\t.map( function ( m ) {\n\t\t\t\t\t\t\treturn { id: m.id + '|' + m.storageKey, url: m.url, alt: m.alt || m.filename || '' };\n\t\t\t\t\t\t} );\n\t\t\t\t\treturn {\n\t\t\t\t\t\tjson: function () {\n\t\t\t\t\t\t\treturn docs;\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t} );\n\t\t},\n\t\tpageTarget: pageTarget,\n\t\t// The entry's SEO block, and what an empty title or description falls back to.\n\t\treadPage: function ( target ) {\n\t\t\tvar req = platform.request( 'seo?collection=' + encodeURIComponent( target.collection ) + '&id=' + encodeURIComponent( target.id ) );\n\t\t\treturn fetch( req.url, { credentials: req.credentials, headers: req.headers } ).then( answerOf );\n\t\t},\n\t\tpageHint: function () {\n\t\t\treturn 'Lagres i innleggets SEO-felt og vises med en gang. ' + PAGE_GUIDE;\n\t\t},\n\t\taiUnavailable: aiOffReason,\n\t\tsuggestMeta: suggestPageMeta,\n\t\tsavePage: function ( changes, target ) {\n\t\t\treturn api( 'seo', Object.assign( { meta: changes }, target ) );\n\t\t},\n\t\tpageEmptied: 'Lagret ✓ – last siden på nytt for å se standardteksten',\n\t};\n\n\tfunction startEmDash() {\n\t\trefreshStatus();\n\t\t// Turning Vibe's edit mode off ends EmDash's too, so the site shows what is published again.\n\t\tbubble.addEventListener( 'click', function () {\n\t\t\tif ( editMode ) {\n\t\t\t\temdashEditMode();\n\t\t\t} else {\n\t\t\t\tsetEmDashCookie( false );\n\t\t\t}\n\t\t} );\n\t\tvar resume = false;\n\t\ttry {\n\t\t\tresume = sessionStorage.getItem( EMDASH_RESUME ) === location.pathname;\n\t\t\tsessionStorage.removeItem( EMDASH_RESUME );\n\t\t} catch ( e ) {}\n\t\tif ( resume && ! editMode ) {\n\t\t\ttoggleEditMode();\n\t\t}\n\t}\n\n\t// Vibe's edit mode came on: EmDash's must be on for the page to carry its marks.\n\tfunction emdashEditMode() {\n\t\tvar had = hasEmDashCookie();\n\t\tsetEmDashCookie( true );\n\t\tif ( ! had && ! document.querySelector( '[data-emdash-ref]' ) ) {\n\t\t\ttry {\n\t\t\t\tsessionStorage.setItem( EMDASH_RESUME, location.pathname );\n\t\t\t} catch ( e ) {}\n\t\t\tlocation.reload();\n\t\t}\n\t}\n\n\tfunction hasEmDashCookie() {\n\t\treturn ( '; ' + document.cookie ).indexOf( '; ' + EMDASH_COOKIE + '=true' ) !== -1;\n\t}\n\n\tfunction setEmDashCookie( on ) {\n\t\tdocument.cookie = EMDASH_COOKIE + '=' + ( on ? 'true' : '; Max-Age=0' ) + '; Path=/; SameSite=Lax';\n\t}\n\n\tfunction emdashRef( el ) {\n\t\ttry {\n\t\t\treturn JSON.parse( el.getAttribute( 'data-emdash-ref' ) );\n\t\t} catch ( e ) {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t// A field's type, from GET status; '' while it hasn't answered.\n\tfunction emdashKind( collection, field ) {\n\t\tvar c = account && account.collections && account.collections[ collection ];\n\t\tif ( ! account || ! account.collections ) {\n\t\t\treturn '';\n\t\t}\n\t\treturn ( c && c.fields && c.fields[ field ] ) || 'none';\n\t}\n\n\t/** What a click edits: the nearest data-emdash-ref with a field, and for rich text the block clicked. */\n\tfunction emdashField( el ) {\n\t\tvar refEl = el.closest( '[data-emdash-ref]' );\n\t\twhile ( refEl ) {\n\t\t\tvar ref = emdashRef( refEl );\n\t\t\tif ( ref && ref.collection && ref.id && ref.field ) {\n\t\t\t\tvar kind = emdashKind( ref.collection, ref.field );\n\t\t\t\tif ( kind && ! EMDASH_KINDS[ kind ] ) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tvar target = refEl;\n\t\t\t\tif ( 'portableText' === kind ) {\n\t\t\t\t\ttarget = el.closest( EMDASH_BLOCKS );\n\t\t\t\t\tif ( ! target || ! refEl.contains( target ) || ! target.textContent.trim() ) {\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn { el: target, collection: ref.collection, id: ref.id, field: ref.field, kind: kind };\n\t\t\t}\n\t\t\trefEl = refEl.parentElement ? refEl.parentElement.closest( '[data-emdash-ref]' ) : null;\n\t\t}\n\t\treturn null;\n\t}\n\n\t// The entry the page shows: as the plugin's page hook named it, else an\n\t// entry-level mark (no field), else the one entry all the page's marks belong\n\t// to. Only for a collection whose SEO fields can be edited here.\n\tfunction pageTarget() {\n\t\tvar named = CFG.page;\n\t\tif ( named && named.collection && named.id ) {\n\t\t\treturn seoTarget( named );\n\t\t}\n\t\tvar marks = document.querySelectorAll( '[data-emdash-ref]' );\n\t\tvar entry = null;\n\t\tvar only = null;\n\t\tvar many = false;\n\t\tfor ( var i = 0; i < marks.length; i++ ) {\n\t\t\tif ( isOwnUi( marks[ i ] ) ) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tvar ref = emdashRef( marks[ i ] );\n\t\t\tif ( ! ref || ! ref.collection || ! ref.id ) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( ! ref.field && ! entry ) {\n\t\t\t\tentry = ref;\n\t\t\t}\n\t\t\tif ( only && ( only.collection !== ref.collection || only.id !== ref.id ) ) {\n\t\t\t\tmany = true;\n\t\t\t}\n\t\t\tonly = only || ref;\n\t\t}\n\t\tvar found = entry || ( many ? null : only );\n\t\treturn found ? seoTarget( found ) : null;\n\t}\n\n\tfunction seoTarget( ref ) {\n\t\tvar c = account && account.collections && account.collections[ ref.collection ];\n\t\treturn c && c.seo ? { collection: ref.collection, id: ref.id } : null;\n\t}\n\n\t/* ---------- Plattformens kroker ---------- */\n\n\t/*\n\t * What a platform can change, and what the overlay does when it doesn't.\n\t * A hook given as null has no default: every platform must give it, which\n\t * tools/build-overlays.mjs checks (and that no platform names a hook that\n\t * isn't here).\n\t */\n\tvar HOOKS = {\n\t\t// Once the switch and its buttons are on the page: ask the server what works here.\n\t\tstart: function () {},\n\t\t// Whether the page button (title and description) is made at the start;\n\t\t// a platform that learns it later calls addPageButton(). And whether it shows now.\n\t\tpageAtStart: false,\n\t\tpageShown: function () {\n\t\t\treturn true;\n\t\t},\n\t\t// The platform's part of the settings panel, below the overlay's own; then wire it up.\n\t\tsettingsHtml: function () {\n\t\t\treturn '';\n\t\t},\n\t\tsettingsOpened: function () {},\n\t\t// «Marker redigerbare felt» turned on or off.\n\t\thighlight: function () {},\n\t\t// What a click edits when it isn't inside a data-vibe-field: { el, collection, id, field } or null.\n\t\tfindAuto: function () {\n\t\t\treturn null;\n\t\t},\n\t\t// More about a data-vibe-field the server needs, added to its ctx.\n\t\tfieldContext: function () {},\n\t\t// How a POST to one of the API's endpoints is sent.\n\t\trequest: function ( endpoint, payload ) {\n\t\t\treturn {\n\t\t\t\turl: API + '/' + endpoint,\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tbody: payload,\n\t\t\t};\n\t\t},\n\n\t\t// A text's panel opened (ctx.original is not set yet).\n\t\ttextOpened: function () {},\n\t\t// An AI suggestion for a text: a promise of { suggestion }.\n\t\tsuggestText: function ( ctx, original, instruction ) {\n\t\t\treturn api( 'suggest', {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\toriginal: original,\n\t\t\t\tinstruction: instruction,\n\t\t\t} );\n\t\t},\n\t\t// What /save is sent for a text (ctx.original is what it was when the panel opened).\n\t\tsavePayload: function ( ctx, value ) {\n\t\t\treturn {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\tvalue: value,\n\t\t\t};\n\t\t},\n\t\t// Show a saved text on the page; data is what /save answered.\n\t\tshowText: function ( ctx, value ) {\n\t\t\tctx.el.textContent = value;\n\t\t},\n\t\t// An AI call went through (credits were spent).\n\t\taiUsed: function () {},\n\n\t\t// Whether AI pictures can be made here, and the line under the picture bar.\n\t\tcanGenerate: function () {\n\t\t\treturn false;\n\t\t},\n\t\timageHint: function () {\n\t\t\treturn '';\n\t\t},\n\t\t// An image's panel opened.\n\t\timageOpened: function () {},\n\t\t// Whether the image is an icon (drawn anew as SVG, not painted).\n\t\tisIcon: function () {\n\t\t\treturn false;\n\t\t},\n\t\timageWait: 'Lager bilde … (kan ta 10–30 sek)',\n\t\timageTimeout: 110000,\n\t\t// Make a picture for the image and save it: a promise of { url, mode, warning }.\n\t\tgenerateImage: function ( ctx, prompt, img ) {\n\t\t\treturn api( 'generate-image', {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\tprompt: prompt,\n\t\t\t\toldUrl: img ? ( img.currentSrc || img.src || '' ) : '',\n\t\t\t\talt: img ? ( img.getAttribute( 'alt' ) || '' ) : '',\n\t\t\t\tsize: '1024x1024',\n\t\t\t}, platform.imageTimeout );\n\t\t},\n\t\t// Whether the image's alt text can be edited, and saving it (a promise).\n\t\thasAlt: function () {\n\t\t\treturn true;\n\t\t},\n\t\tsaveAlt: null,\n\t\t// The media library's list: a promise of the fetch() response.\n\t\tlistMedia: function () {\n\t\t\treturn fetch( MEDIA + '?limit=24&depth=0', { credentials: 'include' } );\n\t\t},\n\n\t\t// What the page panel edits: an object handed to the hooks below, or\n\t\t// null when this page has no title and description to edit.\n\t\tpageTarget: null,\n\t\t// The page's title and description as saved: { title, description } and,\n\t\t// where an empty field falls back to something, titleDefault and\n\t\t// descriptionDefault; titleEditable false when the title can't be changed.\n\t\treadPage: function () {\n\t\t\treturn { title: document.title || '', description: currentDescription() };\n\t\t},\n\t\tpageHint: function ( target ) {\n\t\t\treturn 'Gjelder bare denne siden (' + target.page + '). ' + PAGE_GUIDE;\n\t\t},\n\t\t// Why AI can't be asked here now, or ''.\n\t\taiUnavailable: function () {\n\t\t\treturn '';\n\t\t},\n\t\t// Before the page's two AI suggestions go out together.\n\t\taiReady: function () {},\n\t\t// One AI suggestion for the title or the description: a promise of { suggestion }.\n\t\tsuggestMeta: null,\n\t\t// Save what changed, { title?, description? } ('' = back to the original): a promise.\n\t\tsavePage: null,\n\t\tpageEmptied: 'Lagret ✓ – last siden på nytt for å se originalen',\n\t};\n\tfor ( var hook in HOOKS ) {\n\t\tif ( Object.prototype.hasOwnProperty.call( HOOKS, hook ) && ! Object.prototype.hasOwnProperty.call( platform, hook ) ) {\n\t\t\tplatform[ hook ] = HOOKS[ hook ];\n\t\t}\n\t}\n\n\tfunction init() {\n\t\tbubble = document.createElement( 'div' );\n\t\tbubble.id = 'wpve-bubble';\n\t\tbubble.title = 'Vibe Editor';\n\t\tbubble.setAttribute( 'role', 'switch' );\n\t\tbubble.setAttribute( 'aria-checked', 'false' );\n\t\tbubble.setAttribute( 'aria-label', LABEL_IDLE );\n\t\tbubble.innerHTML =\n\t\t\t'<span class=\"wpve-knob\">' +\n\t\t\t'<svg class=\"wpve-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\t'<path fill=\"currentColor\" d=\"M12 3 L13.6 10.4 L21 12 L13.6 13.6 L12 21 L10.4 13.6 L3 12 L10.4 10.4 Z\"/>' +\n\t\t\t'<path fill=\"currentColor\" opacity=\"0.8\" d=\"M6 15.5 L6.6 17.4 L8.5 18 L6.6 18.6 L6 20.5 L5.4 18.6 L3.5 18 L5.4 17.4 Z\"/>' +\n\t\t\t'</svg>' +\n\t\t\t'</span>';\n\t\tdocument.body.appendChild( bubble );\n\t\tbubble.addEventListener( 'click', toggleEditMode );\n\n\t\tif ( SHOW_SETTINGS ) {\n\t\t\tsettingsBtn = createSettingsButton();\n\t\t}\n\t\tif ( platform.pageAtStart ) {\n\t\t\tpageBtn = createPageButton();\n\t\t}\n\n\t\tapplyLayout();\n\t\tplatform.start();\n\t}\n\n\t/* ---------- Størrelse + posisjon ---------- */\n\n\t/**\n\t * Bobla, tannhjul-knappen og innstillingspanelet forankres til samme hjørne.\n\t * @return {{hSide:string,hVal:number,vSide:string,vVal:number}}\n\t */\n\tfunction currentAnchor() {\n\t\tif ( settings.position === 'custom' ) {\n\t\t\treturn {\n\t\t\t\thSide: settings.hSide === 'left' ? 'left' : 'right',\n\t\t\t\thVal: settings.hOffset,\n\t\t\t\tvSide: settings.vSide === 'top' ? 'top' : 'bottom',\n\t\t\t\tvVal: settings.vOffset,\n\t\t\t};\n\t\t}\n\t\tvar map = {\n\t\t\t'bottom-right': { hSide: 'right', vSide: 'bottom' },\n\t\t\t'bottom-left': { hSide: 'left', vSide: 'bottom' },\n\t\t\t'top-right': { hSide: 'right', vSide: 'top' },\n\t\t\t'top-left': { hSide: 'left', vSide: 'top' },\n\t\t};\n\t\tvar m = map[ settings.position ] || map[ 'bottom-right' ];\n\t\treturn { hSide: m.hSide, hVal: 24, vSide: m.vSide, vVal: 24 };\n\t}\n\n\tfunction anchorEl( el, hSide, hVal, vSide, vVal ) {\n\t\tif ( ! el ) {\n\t\t\treturn;\n\t\t}\n\t\tvar hOpp = hSide === 'left' ? 'right' : 'left';\n\t\tvar vOpp = vSide === 'top' ? 'bottom' : 'top';\n\t\tel.style[ hSide ] = hVal + 'px';\n\t\tel.style[ hOpp ] = 'auto';\n\t\tel.style[ vSide ] = vVal + 'px';\n\t\tel.style[ vOpp ] = 'auto';\n\t}\n\n\tfunction applyLayout() {\n\t\tif ( ! bubble ) {\n\t\t\treturn;\n\t\t}\n\t\tbubble.classList.remove( 'wpve-size-small', 'wpve-size-medium', 'wpve-size-large' );\n\t\tbubble.classList.add( 'wpve-size-' + settings.size );\n\n\t\tvar a = currentAnchor();\n\t\tanchorEl( bubble, a.hSide, a.hVal, a.vSide, a.vVal );\n\t\tbubble.style.transformOrigin = a.vSide + ' ' + a.hSide;\n\n\t\t// De små knappene (36 px) står over bryteren (76 × 40 px), ved kanten.\n\t\tvar over = a.vVal + Math.round( 40 * bubbleScale() ) + 12;\n\t\tanchorEl( settingsBtn, a.hSide, a.hVal + 2, a.vSide, over );\n\t\tanchorEl( pageBtn, a.hSide, a.hVal + 2, a.vSide, over + ( settingsBtn ? 44 : 0 ) );\n\t}\n\n\tfunction toggleEditMode() {\n\t\teditMode = ! editMode;\n\t\tdocument.body.classList.toggle( 'wpve-edit-mode', editMode );\n\t\tbubble.classList.toggle( 'wpve-active', editMode );\n\t\tbubble.setAttribute( 'aria-checked', editMode ? 'true' : 'false' );\n\t\tbubble.setAttribute( 'aria-label', editMode ? LABEL_ACTIVE : LABEL_IDLE );\n\n\t\tapplyFrame();\n\t\tapplyHighlight();\n\n\t\tif ( settingsBtn ) {\n\t\t\tsettingsBtn.style.display = editMode ? 'flex' : 'none';\n\t\t}\n\t\tshowPageButton();\n\n\t\tif ( editMode ) {\n\t\t\tdocument.addEventListener( 'mouseover', onHover, true );\n\t\t\tdocument.addEventListener( 'click', onPick, true );\n\t\t\ttoast( 'Redigeringsmodus på – klikk på en tekst eller et bilde.' );\n\t\t} else {\n\t\t\tdocument.removeEventListener( 'mouseover', onHover, true );\n\t\t\tdocument.removeEventListener( 'click', onPick, true );\n\t\t\tclearHover();\n\t\t\tclosePanel();\n\t\t\tcloseSettings();\n\t\t}\n\t}\n\n\t/* ---------- Innstillinger ---------- */\n\n\tfunction oneOf( value, allowed, fallback ) {\n\t\treturn allowed.indexOf( value ) !== -1 ? value : fallback;\n\t}\n\n\tfunction numOr( value, fallback ) {\n\t\tvar n = parseInt( value, 10 );\n\t\treturn isNaN( n ) || n < 0 ? fallback : Math.min( n, 4000 );\n\t}\n\n\tfunction loadSettings() {\n\t\tvar def = {\n\t\t\tframe: CFG.frame !== false,\n\t\t\thighlight: false,\n\t\t\tsize: oneOf( CFG.size, SIZES, 'medium' ),\n\t\t\tposition: oneOf( CFG.position, POSITIONS, 'bottom-right' ),\n\t\t\thSide: CFG.hSide === 'left' ? 'left' : 'right',\n\t\t\thOffset: numOr( CFG.hOffset, 24 ),\n\t\t\tvSide: CFG.vSide === 'top' ? 'top' : 'bottom',\n\t\t\tvOffset: numOr( CFG.vOffset, 24 ),\n\t\t};\n\t\ttry {\n\t\t\tvar raw = window.localStorage.getItem( SETTINGS_KEY );\n\t\t\tif ( raw ) {\n\t\t\t\tvar p = JSON.parse( raw );\n\t\t\t\treturn {\n\t\t\t\t\tframe: typeof p.frame === 'boolean' ? p.frame : def.frame,\n\t\t\t\t\thighlight: typeof p.highlight === 'boolean' ? p.highlight : def.highlight,\n\t\t\t\t\tsize: oneOf( p.size, SIZES, def.size ),\n\t\t\t\t\tposition: oneOf( p.position, POSITIONS, def.position ),\n\t\t\t\t\thSide: p.hSide === 'left' ? 'left' : ( p.hSide === 'right' ? 'right' : def.hSide ),\n\t\t\t\t\thOffset: numOr( p.hOffset, def.hOffset ),\n\t\t\t\t\tvSide: p.vSide === 'top' ? 'top' : ( p.vSide === 'bottom' ? 'bottom' : def.vSide ),\n\t\t\t\t\tvOffset: numOr( p.vOffset, def.vOffset ),\n\t\t\t\t};\n\t\t\t}\n\t\t} catch ( e ) {\n\t\t\t/* localStorage utilgjengelig – bruk standard. */\n\t\t}\n\t\treturn def;\n\t}\n\n\tfunction saveSettings() {\n\t\ttry {\n\t\t\twindow.localStorage.setItem( SETTINGS_KEY, JSON.stringify( settings ) );\n\t\t} catch ( e ) {\n\t\t\t/* ignorer – innstillingene gjelder fortsatt for denne økten. */\n\t\t}\n\t}\n\n\tfunction applyFrame() {\n\t\tdocument.body.classList.remove( 'wpve-frame-active' );\n\t\tif ( editMode && settings.frame ) {\n\t\t\tvoid document.body.offsetWidth;\n\t\t\tdocument.body.classList.add( 'wpve-frame-active' );\n\t\t}\n\t}\n\n\tfunction applyHighlight() {\n\t\tvar on = editMode && settings.highlight;\n\t\tdocument.body.classList.toggle( 'wpve-show-fields', on );\n\t\tplatform.highlight( on );\n\t}\n\n\tfunction createSettingsButton() {\n\t\tvar btn = document.createElement( 'button' );\n\t\tbtn.id = 'wpve-settings-btn';\n\t\tbtn.type = 'button';\n\t\tbtn.title = 'Innstillinger';\n\t\tbtn.style.display = 'none';\n\t\tbtn.innerHTML =\n\t\t\t'<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\t'<path fill=\"currentColor\" d=\"M19.14 12.94c.04-.31.06-.62.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.61-.22l-2.39.96a7.3 7.3 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54c-.58.24-1.12.56-1.62.94l-2.39-.96a.5.5 0 0 0-.61.22L2.74 8.84a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.62-.06.94s.02.63.06.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .61.22l2.39-.96c.5.38 1.04.7 1.62.94l.36 2.54a.5.5 0 0 0 .5.42h3.84a.5.5 0 0 0 .5-.42l.36-2.54c.58-.24 1.12-.56 1.62-.94l2.39.96a.5.5 0 0 0 .61-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58ZM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z\"/>' +\n\t\t\t'</svg>';\n\t\tdocument.body.appendChild( btn );\n\t\tbtn.addEventListener( 'click', toggleSettings );\n\t\treturn btn;\n\t}\n\n\tfunction toggleSettings() {\n\t\tif ( settingsPanel ) {\n\t\t\tcloseSettings();\n\t\t} else {\n\t\t\topenSettings();\n\t\t}\n\t}\n\n\tfunction openSettings() {\n\t\tclosePanel();\n\t\tcloseSettings();\n\t\tsettingsPanel = document.createElement( 'div' );\n\t\tsettingsPanel.className = 'wpve-panel wpve-settings';\n\t\tsettingsPanel.innerHTML =\n\t\t\t'<div class=\"wpve-panel-head\">Innstillinger<span class=\"wpve-close\">×</span></div>' +\n\t\t\t'<label class=\"wpve-setting\"><input type=\"checkbox\" class=\"wpve-set-frame\"><span>Redigeringsramme</span></label>' +\n\t\t\t'<label class=\"wpve-setting\"><input type=\"checkbox\" class=\"wpve-set-highlight\"><span>Marker redigerbare felt</span></label>' +\n\t\t\t'<label class=\"wpve-setting wpve-setting-select\"><span>Størrelse</span>' +\n\t\t\t'<select class=\"wpve-set-size\">' +\n\t\t\t'<option value=\"small\">Liten</option>' +\n\t\t\t'<option value=\"medium\">Medium</option>' +\n\t\t\t'<option value=\"large\">Stor</option>' +\n\t\t\t'</select></label>' +\n\t\t\t'<label class=\"wpve-setting wpve-setting-select\"><span>Posisjon</span>' +\n\t\t\t'<select class=\"wpve-set-position\">' +\n\t\t\t'<option value=\"bottom-right\">Nede til høyre</option>' +\n\t\t\t'<option value=\"bottom-left\">Nede til venstre</option>' +\n\t\t\t'<option value=\"top-right\">Oppe til høyre</option>' +\n\t\t\t'<option value=\"top-left\">Oppe til venstre</option>' +\n\t\t\t'<option value=\"custom\">Egendefinert …</option>' +\n\t\t\t'</select></label>' +\n\t\t\t'<div class=\"wpve-custom-pos\">' +\n\t\t\t'<div class=\"wpve-pos-row\"><span>Vannrett</span>' +\n\t\t\t'<select class=\"wpve-set-hside\"><option value=\"right\">Fra høyre</option><option value=\"left\">Fra venstre</option></select>' +\n\t\t\t'<input type=\"number\" class=\"wpve-set-hoffset\" min=\"0\" max=\"4000\"><em>px</em></div>' +\n\t\t\t'<div class=\"wpve-pos-row\"><span>Loddrett</span>' +\n\t\t\t'<select class=\"wpve-set-vside\"><option value=\"bottom\">Fra bunnen</option><option value=\"top\">Fra toppen</option></select>' +\n\t\t\t'<input type=\"number\" class=\"wpve-set-voffset\" min=\"0\" max=\"4000\"><em>px</em></div>' +\n\t\t\t'</div>' +\n\t\t\tplatform.settingsHtml();\n\t\tdocument.body.appendChild( settingsPanel );\n\n\t\t// Panelet står ved siden av de små knappene, over bryteren.\n\t\tvar a = currentAnchor();\n\t\tanchorEl( settingsPanel, a.hSide, a.hVal + 50, a.vSide, a.vVal + 40 * bubbleScale() + 12 );\n\n\t\tvar frameBox = settingsPanel.querySelector( '.wpve-set-frame' );\n\t\tvar hlBox = settingsPanel.querySelector( '.wpve-set-highlight' );\n\t\tvar sizeSel = settingsPanel.querySelector( '.wpve-set-size' );\n\t\tvar posSel = settingsPanel.querySelector( '.wpve-set-position' );\n\t\tvar customBox = settingsPanel.querySelector( '.wpve-custom-pos' );\n\t\tvar hSideSel = settingsPanel.querySelector( '.wpve-set-hside' );\n\t\tvar hOffIn = settingsPanel.querySelector( '.wpve-set-hoffset' );\n\t\tvar vSideSel = settingsPanel.querySelector( '.wpve-set-vside' );\n\t\tvar vOffIn = settingsPanel.querySelector( '.wpve-set-voffset' );\n\n\t\tframeBox.checked = settings.frame;\n\t\thlBox.checked = settings.highlight;\n\t\tsizeSel.value = settings.size;\n\t\tposSel.value = settings.position;\n\t\thSideSel.value = settings.hSide;\n\t\thOffIn.value = settings.hOffset;\n\t\tvSideSel.value = settings.vSide;\n\t\tvOffIn.value = settings.vOffset;\n\t\tcustomBox.style.display = settings.position === 'custom' ? 'block' : 'none';\n\n\t\tsettingsPanel.querySelector( '.wpve-close' ).addEventListener( 'click', closeSettings );\n\t\tframeBox.addEventListener( 'change', function () {\n\t\t\tsettings.frame = frameBox.checked;\n\t\t\tsaveSettings();\n\t\t\tapplyFrame();\n\t\t} );\n\t\thlBox.addEventListener( 'change', function () {\n\t\t\tsettings.highlight = hlBox.checked;\n\t\t\tsaveSettings();\n\t\t\tapplyHighlight();\n\t\t} );\n\n\t\tfunction relayout() {\n\t\t\tsaveSettings();\n\t\t\tapplyLayout();\n\t\t\t// Hold panelet ved boblas (nye) hjørne mens man justerer.\n\t\t\tvar na = currentAnchor();\n\t\t\tsettingsPanel.style.left = 'auto';\n\t\t\tsettingsPanel.style.right = 'auto';\n\t\t\tsettingsPanel.style.top = 'auto';\n\t\t\tsettingsPanel.style.bottom = 'auto';\n\t\t\tanchorEl( settingsPanel, na.hSide, na.hVal + 50, na.vSide, na.vVal + 40 * bubbleScale() + 12 );\n\t\t}\n\n\t\tsizeSel.addEventListener( 'change', function () {\n\t\t\tsettings.size = oneOf( sizeSel.value, SIZES, settings.size );\n\t\t\trelayout();\n\t\t} );\n\t\tposSel.addEventListener( 'change', function () {\n\t\t\tsettings.position = oneOf( posSel.value, POSITIONS, settings.position );\n\t\t\tcustomBox.style.display = settings.position === 'custom' ? 'block' : 'none';\n\t\t\trelayout();\n\t\t} );\n\t\thSideSel.addEventListener( 'change', function () {\n\t\t\tsettings.hSide = hSideSel.value === 'left' ? 'left' : 'right';\n\t\t\trelayout();\n\t\t} );\n\t\tvSideSel.addEventListener( 'change', function () {\n\t\t\tsettings.vSide = vSideSel.value === 'top' ? 'top' : 'bottom';\n\t\t\trelayout();\n\t\t} );\n\t\thOffIn.addEventListener( 'input', function () {\n\t\t\tsettings.hOffset = numOr( hOffIn.value, 24 );\n\t\t\trelayout();\n\t\t} );\n\t\tvOffIn.addEventListener( 'input', function () {\n\t\t\tsettings.vOffset = numOr( vOffIn.value, 24 );\n\t\t\trelayout();\n\t\t} );\n\n\t\tplatform.settingsOpened();\n\t}\n\n\tfunction closeSettings() {\n\t\tif ( settingsPanel ) {\n\t\t\tsettingsPanel.remove();\n\t\t\tsettingsPanel = null;\n\t\t}\n\t}\n\n\t/* ---------- Kontoen AI-en bruker ---------- */\n\n\t// How many credits are left, as the end of a sentence, when the account has said.\n\tfunction creditsLeft() {\n\t\treturn account && 'number' === typeof account.balance\n\t\t\t? ' ' + ( 1 === account.balance ? '1 kreditt' : account.balance + ' kreditter' ) + ' igjen.'\n\t\t\t: '';\n\t}\n\n\t// What an AI picture costs, when the account has said.\n\tfunction imageCost() {\n\t\tvar c = account && account.costs;\n\t\tvar gen = c ? parseInt( c.image, 10 ) : 0;\n\t\tvar edit = c ? parseInt( c.imageEdit, 10 ) || gen : 0;\n\t\tif ( ! gen ) {\n\t\t\treturn '';\n\t\t}\n\t\tvar low = Math.min( gen, edit );\n\t\tvar high = Math.max( gen, edit );\n\t\treturn 'Et AI-bilde koster ' + ( low === high ? low : low + '–' + high ) + ' kreditter.';\n\t}\n\n\t// An error in a panel, with a link to buy more credits when they ran out.\n\tfunction showError( box, err ) {\n\t\tbox.innerHTML = '<div class=\"wpve-error\"></div>';\n\t\tvar el = box.firstChild;\n\t\tel.textContent = String( err );\n\t\tvar url = err && err.topupUrl;\n\t\tif ( url && /^https?:\\/\\//.test( url ) ) {\n\t\t\tvar a = document.createElement( 'a' );\n\t\t\ta.href = url;\n\t\t\ta.target = '_blank';\n\t\t\ta.rel = 'noopener';\n\t\t\ta.textContent = 'Kjøp flere kreditter';\n\t\t\tel.appendChild( document.createTextNode( ' ' ) );\n\t\t\tel.appendChild( a );\n\t\t}\n\t}\n\n\t/* ---------- Element-utvelgelse ---------- */\n\n\tfunction isOwnUi( el ) {\n\t\treturn el.closest( '#wpve-bubble' ) || el.closest( '#wpve-settings-btn' ) || el.closest( '#wpve-page-btn' ) ||\n\t\t\tel.closest( '.wpve-panel' ) || el.closest( '.wpve-toast' );\n\t}\n\n\t/**\n\t * Finn det redigerbare elementet og dets collection/id. Eksplisitt\n\t * data-vibe-field vinner alltid (stabil, forfatter-valgt nøkkel), med\n\t * collection/id på elementet selv eller en stamfar; ellers det plattformen\n\t * selv finner (platform.findAuto).\n\t */\n\tfunction editableFrom( el ) {\n\t\tif ( ! el || isOwnUi( el ) ) {\n\t\t\treturn null;\n\t\t}\n\t\tvar fieldEl = el.closest( '[data-vibe-field]' );\n\t\tif ( fieldEl && ! isOwnUi( fieldEl ) ) {\n\t\t\tvar box = fieldEl.closest( '[data-vibe-collection][data-vibe-id]' );\n\t\t\tvar collection = fieldEl.getAttribute( 'data-vibe-collection' ) || ( box && box.getAttribute( 'data-vibe-collection' ) );\n\t\t\tvar id = fieldEl.getAttribute( 'data-vibe-id' ) || ( box && box.getAttribute( 'data-vibe-id' ) );\n\t\t\tif ( ! collection || ! id ) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tvar ctx = {\n\t\t\t\tel: fieldEl,\n\t\t\t\tcollection: collection,\n\t\t\t\tid: id,\n\t\t\t\tfield: fieldEl.getAttribute( 'data-vibe-field' ),\n\t\t\t};\n\t\t\tplatform.fieldContext( ctx, fieldEl );\n\t\t\treturn ctx;\n\t\t}\n\t\treturn platform.findAuto( el );\n\t}\n\n\tfunction onHover( e ) {\n\t\tvar ctx = editableFrom( e.target );\n\t\tvar el = ctx ? ctx.el : null;\n\t\tif ( ! el || el === hovered ) {\n\t\t\treturn;\n\t\t}\n\t\tclearHover();\n\t\thovered = el;\n\t\thovered.classList.add( 'wpve-hover' );\n\t}\n\n\tfunction clearHover() {\n\t\tif ( hovered ) {\n\t\t\thovered.classList.remove( 'wpve-hover' );\n\t\t\thovered = null;\n\t\t}\n\t}\n\n\tfunction onPick( e ) {\n\t\tvar ctx = editableFrom( e.target );\n\t\tif ( ! ctx ) {\n\t\t\t// Et klikk utenfor lukker panelet, så lenge ingenting er skrevet i det.\n\t\t\tif ( panel && ! panel.hasAttribute( 'data-dirty' ) && ! isOwnUi( e.target ) ) {\n\t\t\t\tclosePanel();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\tclickPos = { x: e.clientX, y: e.clientY };\n\n\t\tvar isImage = ctx.el.tagName === 'IMG' || !! ctx.el.querySelector( 'img' );\n\t\tif ( isImage ) {\n\t\t\teditImage( ctx );\n\t\t} else {\n\t\t\teditText( ctx );\n\t\t}\n\t}\n\n\t/* ---------- Tekst-redigering ---------- */\n\n\tfunction editText( ctx ) {\n\t\tplatform.textOpened( ctx );\n\t\tvar original = ctx.el.textContent.trim();\n\t\tctx.original = original;\n\t\topenPanel();\n\n\t\tpanel.classList.add( 'wpve-cmd' );\n\t\tpanel.innerHTML = textBarHtml();\n\t\tmarkSelected( ctx.el );\n\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\n\t\tvar promptEl = panel.querySelector( '.wpve-prompt' );\n\t\tvar toggle = panel.querySelector( '.ai-toggle' );\n\t\tvar suggestBtn = panel.querySelector( '.wpve-suggest' );\n\n\t\t// Egne tekstbuffere per modus, så man ikke mister det man har skrevet ved bytte.\n\t\t// Linjeskift og innrykk fra HTML-kilden vises ikke på siden, så de skal ikke inn i teksten man redigerer.\n\t\tvar buffers = { ai: '', manual: original.replace( /\\s*\\n\\s*/g, ' ' ) };\n\n\t\tfunction applyMode( mode ) {\n\t\t\ttoggle.dataset.mode = mode;\n\t\t\tpromptEl.value = buffers[ mode ];\n\t\t\tif ( mode === 'ai' ) {\n\t\t\t\tpromptEl.placeholder = 'Hva vil du endre?';\n\t\t\t\tsuggestBtn.textContent = 'Foreslå endring';\n\t\t\t} else {\n\t\t\t\tpromptEl.placeholder = 'Skriv inn teksten som skal vises';\n\t\t\t\tsuggestBtn.textContent = 'Lagre tekst';\n\t\t\t}\n\t\t\tpanel.querySelector( '.wpve-result' ).innerHTML = '';\n\t\t\tgrowPrompt( promptEl );\n\t\t\tpositionPanel();\n\t\t\tpromptEl.focus();\n\t\t}\n\n\t\ttoggle.addEventListener( 'click', function () {\n\t\t\tbuffers[ toggle.dataset.mode ] = promptEl.value;\n\t\t\tvar mode = toggle.dataset.mode === 'ai' ? 'manual' : 'ai';\n\t\t\t// Den som skriver selv, vil gjerne gjøre det neste gang også.\n\t\t\ttry { localStorage.setItem( 'vibe_text_mode', mode ); } catch ( e ) {}\n\t\t\tapplyMode( mode );\n\t\t} );\n\n\t\tsuggestBtn.addEventListener( 'click', function () {\n\t\t\tvar value = promptEl.value.trim();\n\t\t\tif ( ! value ) {\n\t\t\t\tpromptEl.focus();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif ( toggle.dataset.mode === 'manual' ) {\n\t\t\t\tsaveManualText( ctx, value );\n\t\t\t} else {\n\t\t\t\trequestSuggestion( ctx, original, value );\n\t\t\t}\n\t\t} );\n\n\t\t// AI er på som standard, med mindre man sist skrev selv.\n\t\tvar lastMode = 'ai';\n\t\ttry { lastMode = localStorage.getItem( 'vibe_text_mode' ) === 'manual' ? 'manual' : 'ai'; } catch ( e ) {}\n\t\tapplyMode( lastMode );\n\t}\n\n\t// Lagrer brukerens egen tekst direkte, uten AI-forslag (manuell modus). Koster ingen kreditter.\n\tfunction saveManualText( ctx, newText ) {\n\t\tvar resultBox = panel.querySelector( '.wpve-result' );\n\t\tvar btn = panel.querySelector( '.wpve-suggest' );\n\t\tbtn.disabled = true;\n\t\tbtn.textContent = 'Lagrer …';\n\t\tresultBox.innerHTML = '';\n\n\t\tpersistText( ctx, newText ).catch( function ( err ) {\n\t\t\tbtn.disabled = false;\n\t\t\tbtn.textContent = 'Lagre tekst';\n\t\t\tshowError( resultBox, err );\n\t\t} );\n\t}\n\n\t// Felles lagring – brukes av både AI-forslag og manuell tekst.\n\tfunction persistText( ctx, newText ) {\n\t\treturn api( 'save', platform.savePayload( ctx, newText ) ).then( function ( data ) {\n\t\t\tplatform.showText( ctx, newText, data );\n\t\t\tclosePanel();\n\t\t\ttoast( ( data && ( data.warning || data.message ) ) || 'Lagret ✓' );\n\t\t} );\n\t}\n\n\tfunction requestSuggestion( ctx, original, prompt ) {\n\t\tvar resultBox = panel.querySelector( '.wpve-result' );\n\t\tvar btn = panel.querySelector( '.wpve-suggest' );\n\t\tbtn.disabled = true;\n\t\tresultBox.innerHTML = '<div class=\"wpve-loading\">Skriver forslag …</div>';\n\n\t\tplatform.suggestText( ctx, original, prompt )\n\t\t\t.then( function ( data ) {\n\t\t\t\tbtn.disabled = false;\n\t\t\t\trenderSuggestion( resultBox, ctx, original, String( data.suggestion || '' ).trim() );\n\t\t\t\tplatform.aiUsed();\n\t\t\t} )\n\t\t\t.catch( function ( err ) {\n\t\t\t\tbtn.disabled = false;\n\t\t\t\tshowError( resultBox, err );\n\t\t\t} );\n\t}\n\n\tfunction renderSuggestion( box, ctx, original, suggestion ) {\n\t\tbox.innerHTML =\n\t\t\t'<div class=\"wpve-diff-label\">Forslag:</div>' +\n\t\t\t'<div class=\"wpve-suggestion\"></div>' +\n\t\t\t'<div class=\"wpve-actions\">' +\n\t\t\t'<button class=\"wpve-btn wpve-approve\">Godkjenn og lagre</button>' +\n\t\t\t'<button class=\"wpve-btn wpve-cancel-link\">Avbryt</button>' +\n\t\t\t'</div>';\n\t\tbox.querySelector( '.wpve-suggestion' ).textContent = suggestion;\n\t\tbox.querySelector( '.wpve-cancel-link' ).addEventListener( 'click', closePanel );\n\t\tpositionPanel();\n\n\t\tbox.querySelector( '.wpve-approve' ).addEventListener( 'click', function () {\n\t\t\tvar approveBtn = box.querySelector( '.wpve-approve' );\n\t\t\tapproveBtn.disabled = true;\n\t\t\tapproveBtn.textContent = 'Lagrer …';\n\n\t\t\tpersistText( ctx, suggestion ).catch( function ( err ) {\n\t\t\t\tapproveBtn.disabled = false;\n\t\t\t\tapproveBtn.textContent = 'Godkjenn og lagre';\n\t\t\t\tbox.querySelector( '.wpve-suggestion' ).insertAdjacentHTML(\n\t\t\t\t\t'afterend',\n\t\t\t\t\t'<div class=\"wpve-error\"></div>'\n\t\t\t\t);\n\t\t\t\tbox.querySelector( '.wpve-error' ).textContent = err;\n\t\t\t} );\n\t\t} );\n\t}\n\n\t/* ---------- Bilde-redigering ---------- */\n\n\tfunction imageOf( ctx ) {\n\t\treturn ctx.el.tagName === 'IMG' ? ctx.el : ctx.el.querySelector( 'img' );\n\t}\n\n\tfunction editImage( ctx ) {\n\t\tvar imgEl = imageOf( ctx );\n\t\tvar canGen = !! platform.canGenerate();\n\t\tvar isIcon = !! platform.isIcon( ctx );\n\t\tvar hint = platform.imageHint( canGen ) || '';\n\t\tvar withAlt = !! platform.hasAlt( ctx, imgEl );\n\n\t\topenPanel();\n\t\tpanel.classList.add( 'wpve-cmd' );\n\t\tpanel.innerHTML =\n\t\t\t'<div class=\"wpve-bar\">' +\n\t\t\t\t'<span class=\"wpve-bar-icon\">' + icon( 'image' ) + '</span>' +\n\t\t\t\t'<textarea class=\"wpve-prompt wpve-img-prompt\" rows=\"1\" placeholder=\"' + ( isIcon ? 'Beskriv ikonet du vil ha' : 'Beskriv bildet du vil ha' ) + '\"' + ( canGen ? '' : ' disabled' ) + '></textarea>' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-send wpve-gen\"' + ( canGen ? '' : ' disabled' ) + '>' + ( isIcon ? 'Lag ikon' : 'Generer bilde' ) + '</button>' +\n\t\t\t'</div>' +\n\t\t\t// What a picture costs, or why none can be made; a platform may fill it in later.\n\t\t\t'<div class=\"wpve-hint wpve-cost\"' + ( hint ? '' : ' style=\"display:none\"' ) + '>' + escapeHtml( hint ) + '</div>' +\n\t\t\t'<details class=\"wpve-more\"' + ( canGen ? '' : ' open' ) + '><summary>Flere valg</summary><div class=\"wpve-more-body\">' +\n\t\t\t\t'<div class=\"wpve-field-label\">Lim inn bilde-URL</div>' +\n\t\t\t\t'<div class=\"wpve-url-row\">' +\n\t\t\t\t\t'<input type=\"url\" class=\"wpve-url-input\" placeholder=\"https://…/bilde.jpg\" />' +\n\t\t\t\t\t'<button type=\"button\" class=\"wpve-url-btn\">Bruk</button>' +\n\t\t\t\t'</div>' +\n\t\t\t\t( withAlt ?\n\t\t\t\t\t'<div class=\"wpve-field-label\">Alt-tekst</div>' +\n\t\t\t\t\t'<div class=\"wpve-url-row\">' +\n\t\t\t\t\t\t'<input type=\"text\" class=\"wpve-url-input wpve-alt-input\" maxlength=\"300\" placeholder=\"Hva viser bildet? Leses av skjermlesere og søkemotorer\" />' +\n\t\t\t\t\t\t'<button type=\"button\" class=\"wpve-url-btn wpve-alt-btn\">Lagre</button>' +\n\t\t\t\t\t'</div>' : '' ) +\n\t\t\t\t'<div class=\"wpve-field-label\">Velg fra mediebiblioteket</div>' +\n\t\t\t\t'<div class=\"wpve-result\"><div class=\"wpve-loading\">Laster bilder …</div></div>' +\n\t\t\t'</div></details>' +\n\t\t\t'<button type=\"button\" class=\"wpve-close wpve-close-link\">Lukk</button>' +\n\t\t\t'<div class=\"wpve-status\"></div>';\n\t\tmarkSelected( imgEl || ctx.el );\n\t\tplatform.imageOpened( canGen );\n\t\tpanel.querySelector( '.wpve-more' ).addEventListener( 'toggle', positionPanel );\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\n\t\t// AI generation: builds on the current image when relevant (the service\n\t\t// decides), otherwise makes a brand-new one.\n\t\tif ( canGen ) {\n\t\t\tvar genBtn = panel.querySelector( '.wpve-gen' );\n\t\t\tvar genPrompt = panel.querySelector( '.wpve-img-prompt' );\n\t\t\tgenPrompt.focus();\n\t\t\tgenBtn.addEventListener( 'click', function () {\n\t\t\t\tvar p = ( genPrompt.value || '' ).trim();\n\t\t\t\tif ( ! p ) {\n\t\t\t\t\tgenPrompt.focus();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tvar resultBox = panel.querySelector( '.wpve-status' );\n\t\t\t\tgenBtn.disabled = true;\n\t\t\t\tresultBox.innerHTML = '<div class=\"wpve-loading\">' + escapeHtml( isIcon ? 'Lager ikon …' : platform.imageWait ) + '</div>';\n\t\t\t\tvar loader = imgEl ? showImageLoader( imgEl ) : null;\n\n\t\t\t\tplatform.generateImage( ctx, p, imgEl )\n\t\t\t\t\t.then( function ( data ) {\n\t\t\t\t\t\tif ( loader ) {\n\t\t\t\t\t\t\tloader.remove();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tapplyNew( ctx, data && data.url );\n\t\t\t\t\t\tclosePanel();\n\t\t\t\t\t\ttoast( ( data && data.warning ) || imageDone( data, isIcon ) );\n\t\t\t\t\t\tplatform.aiUsed();\n\t\t\t\t\t} )\n\t\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\t\tif ( loader ) {\n\t\t\t\t\t\t\tloader.remove();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tgenBtn.disabled = false;\n\t\t\t\t\t\tshowError( resultBox, err );\n\t\t\t\t\t} );\n\t\t\t} );\n\t\t}\n\n\t\tvar urlInput = panel.querySelector( '.wpve-url-input' );\n\t\tfunction useUrl() {\n\t\t\tvar url = ( urlInput.value || '' ).trim();\n\t\t\tif ( ! url ) {\n\t\t\t\turlInput.focus();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tswapImage( ctx, url, url );\n\t\t}\n\t\tpanel.querySelector( '.wpve-url-btn' ).addEventListener( 'click', useUrl );\n\t\turlInput.addEventListener( 'keydown', function ( e ) {\n\t\t\tif ( 'Enter' === e.key ) {\n\t\t\t\te.preventDefault();\n\t\t\t\tuseUrl();\n\t\t\t}\n\t\t} );\n\n\t\tif ( withAlt ) {\n\t\t\tvar altInput = panel.querySelector( '.wpve-alt-input' );\n\t\t\tvar altBtn = panel.querySelector( '.wpve-alt-btn' );\n\t\t\taltInput.value = imgEl ? ( imgEl.getAttribute( 'alt' ) || '' ) : '';\n\t\t\tvar saveAlt = function () {\n\t\t\t\tvar text = altInput.value.trim();\n\t\t\t\taltBtn.disabled = true;\n\t\t\t\tplatform.saveAlt( ctx, text, imgEl )\n\t\t\t\t\t.then( function ( data ) {\n\t\t\t\t\t\tif ( imgEl ) {\n\t\t\t\t\t\t\timgEl.setAttribute( 'alt', text );\n\t\t\t\t\t\t}\n\t\t\t\t\t\taltBtn.disabled = false;\n\t\t\t\t\t\ttoast( ( data && data.warning ) || 'Alt-tekst lagret ✓' );\n\t\t\t\t\t} )\n\t\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\t\taltBtn.disabled = false;\n\t\t\t\t\t\ttoast( err );\n\t\t\t\t\t} );\n\t\t\t};\n\t\t\taltBtn.addEventListener( 'click', saveAlt );\n\t\t\taltInput.addEventListener( 'keydown', function ( e ) {\n\t\t\t\tif ( 'Enter' === e.key ) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tsaveAlt();\n\t\t\t\t}\n\t\t\t} );\n\t\t}\n\n\t\tpositionPanel();\n\n\t\tplatform.listMedia()\n\t\t\t.then( function ( res ) {\n\t\t\t\treturn res.json();\n\t\t\t} )\n\t\t\t.then( function ( data ) {\n\t\t\t\trenderImagePicker( ctx, extractMediaDocs( data ) );\n\t\t\t} )\n\t\t\t.catch( function () {\n\t\t\t\tvar box = panel && panel.querySelector( '.wpve-result' );\n\t\t\t\tif ( box ) {\n\t\t\t\t\tbox.innerHTML =\n\t\t\t\t\t\t'<div class=\"wpve-hint\">Mediebiblioteket er ikke tilgjengelig – bruk URL-feltet over.</div>';\n\t\t\t\t\tpositionPanel();\n\t\t\t\t}\n\t\t\t} );\n\t}\n\n\t// What the toast says once a picture is made and saved.\n\tfunction imageDone( data, isIcon ) {\n\t\tif ( isIcon ) {\n\t\t\treturn 'Ikon laget og lagret ✓';\n\t\t}\n\t\tvar mode = data && data.mode;\n\t\tif ( 'edit' === mode ) {\n\t\t\treturn 'Bildet er endret og lagret ✓';\n\t\t}\n\t\treturn 'new' === mode ? 'Nytt bilde laget og lagret ✓' : 'Bilde generert og lagret ✓';\n\t}\n\n\t/**\n\t * Point the on-page image at a new URL without a reload. Themes often use\n\t * <picture>/<source srcset>, responsive `srcset`/`sizes`, and lazy-load\n\t * `data-*` attributes that all win over `img.src`; clear them so the new\n\t * image actually shows. Shared by AI generation and the URL/library swap.\n\t */\n\tfunction applyNew( ctx, url ) {\n\t\tif ( ! url ) {\n\t\t\treturn;\n\t\t}\n\t\tvar img = imageOf( ctx );\n\t\tif ( ! img ) {\n\t\t\treturn;\n\t\t}\n\t\t// Den opprinnelige src-en, som en innholdsnøkkel (apply.js) bygger på; ta vare på den før den byttes.\n\t\tif ( ! img.getAttribute( 'data-vibe-src' ) ) {\n\t\t\timg.setAttribute( 'data-vibe-src', img.getAttribute( 'src' ) || '' );\n\t\t}\n\t\tvar pic = img.closest ? img.closest( 'picture' ) : null;\n\t\tif ( pic ) {\n\t\t\tvar sources = pic.querySelectorAll( 'source' );\n\t\t\tfor ( var i = 0; i < sources.length; i++ ) {\n\t\t\t\tsources[ i ].setAttribute( 'srcset', url );\n\t\t\t}\n\t\t}\n\t\timg.removeAttribute( 'srcset' );\n\t\timg.removeAttribute( 'sizes' );\n\t\timg.removeAttribute( 'data-src' );\n\t\timg.removeAttribute( 'data-lazy-src' );\n\t\timg.removeAttribute( 'data-srcset' );\n\t\timg.removeAttribute( 'data-lazy-srcset' );\n\t\timg.srcset = '';\n\t\timg.src = url;\n\t}\n\n\t/* Accept whatever list shape the host's media endpoint happens to return. */\n\tfunction extractMediaDocs( data ) {\n\t\tif ( ! data ) {\n\t\t\treturn [];\n\t\t}\n\t\tif ( Array.isArray( data ) ) {\n\t\t\treturn data;\n\t\t}\n\t\treturn data.docs || data.media || data.results || data.items || data.data || [];\n\t}\n\n\t/* Pick the best thumbnail URL across common field names. */\n\tfunction mediaThumb( doc ) {\n\t\treturn (\n\t\t\t( doc.sizes && doc.sizes.thumbnail && doc.sizes.thumbnail.url ) ||\n\t\t\tdoc.thumbnailUrl ||\n\t\t\tdoc.url ||\n\t\t\tdoc.src ||\n\t\t\t''\n\t\t);\n\t}\n\n\tfunction renderImagePicker( ctx, docs ) {\n\t\tvar box = panel && panel.querySelector( '.wpve-result' );\n\t\tif ( ! box ) {\n\t\t\treturn;\n\t\t}\n\t\tif ( ! docs.length ) {\n\t\t\tbox.innerHTML =\n\t\t\t\t'<div class=\"wpve-hint\">Ingen bilder i biblioteket ennå – bruk URL-feltet over.</div>';\n\t\t\tpositionPanel();\n\t\t\treturn;\n\t\t}\n\t\tvar html = '<div class=\"wpve-media-grid\">';\n\t\tdocs.forEach( function ( doc ) {\n\t\t\tvar id = ( null != doc.id ) ? doc.id : doc._id;\n\t\t\tvar thumb = mediaThumb( doc );\n\t\t\tvar alt = doc.alt || doc.filename || '';\n\t\t\thtml += '<button type=\"button\" class=\"wpve-media-item\" data-id=\"' + escapeHtml( String( id ) ) + '\" title=\"' +\n\t\t\t\tescapeHtml( alt ) + '\"><img src=\"' + escapeHtml( thumb ) + '\" alt=\"' + escapeHtml( alt ) + '\"></button>';\n\t\t} );\n\t\thtml += '</div>';\n\t\tbox.innerHTML = html;\n\t\tpositionPanel();\n\n\t\tbox.querySelectorAll( '.wpve-media-item' ).forEach( function ( btn ) {\n\t\t\tbtn.addEventListener( 'click', function () {\n\t\t\t\tswapImage( ctx, btn.getAttribute( 'data-id' ), btn.querySelector( 'img' ).src );\n\t\t\t} );\n\t\t} );\n\t}\n\n\tfunction swapImage( ctx, uploadId, previewUrl ) {\n\t\tapi( 'swap-image', {\n\t\t\tcollection: ctx.collection,\n\t\t\tid: ctx.id,\n\t\t\tfield: ctx.field,\n\t\t\tuploadId: uploadId,\n\t\t} )\n\t\t\t.then( function ( data ) {\n\t\t\t\tif ( previewUrl ) {\n\t\t\t\t\tapplyNew( ctx, previewUrl );\n\t\t\t\t}\n\t\t\t\tclosePanel();\n\t\t\t\ttoast( ( data && data.warning ) || 'Bilde byttet ✓' );\n\t\t\t} )\n\t\t\t.catch( function ( err ) {\n\t\t\t\ttoast( err );\n\t\t\t} );\n\t}\n\n\t/* ---------- Sidens tittel og beskrivelse ---------- */\n\n\t// The page button, for a platform that learns only later that this page has a title to edit.\n\tfunction addPageButton() {\n\t\tif ( pageBtn ) {\n\t\t\treturn;\n\t\t}\n\t\tpageBtn = createPageButton();\n\t\tapplyLayout();\n\t\tshowPageButton();\n\t}\n\n\t// The button shows in edit mode, where the platform says the page has a title to edit.\n\tfunction showPageButton() {\n\t\tif ( pageBtn ) {\n\t\t\tpageBtn.style.display = editMode && platform.pageShown() ? 'flex' : 'none';\n\t\t}\n\t}\n\n\tfunction createPageButton() {\n\t\tvar btn = document.createElement( 'button' );\n\t\tbtn.id = 'wpve-page-btn';\n\t\tbtn.type = 'button';\n\t\tbtn.title = 'Sidetittel og beskrivelse';\n\t\tbtn.setAttribute( 'aria-label', 'Sidetittel og beskrivelse' );\n\t\tbtn.style.display = 'none';\n\t\tbtn.innerHTML =\n\t\t\t'<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\t'<path fill=\"currentColor\" d=\"M10.5 3a7.5 7.5 0 0 1 5.96 12.05l4.5 4.5-1.41 1.41-4.5-4.5A7.5 7.5 0 1 1 10.5 3Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3 3.5h6v1.5h-6V8.5Zm0 3h4.5V13H7.5v-1.5Z\"/>' +\n\t\t\t'</svg>';\n\t\tdocument.body.appendChild( btn );\n\t\tbtn.addEventListener( 'click', function () {\n\t\t\tif ( panel && panel.classList.contains( 'wpve-page-panel' ) ) {\n\t\t\t\tclosePanel();\n\t\t\t} else {\n\t\t\t\teditPage();\n\t\t\t}\n\t\t} );\n\t\treturn btn;\n\t}\n\n\tfunction metaTags( which ) {\n\t\tvar out = [];\n\t\tvar all = document.getElementsByTagName( 'meta' );\n\t\tfor ( var i = 0; i < all.length; i++ ) {\n\t\t\tvar n = ( all[ i ].getAttribute( 'name' ) || '' ).toLowerCase();\n\t\t\tvar p = ( all[ i ].getAttribute( 'property' ) || '' ).toLowerCase();\n\t\t\tif ( which.indexOf( n ) !== -1 || which.indexOf( p ) !== -1 ) {\n\t\t\t\tout.push( all[ i ] );\n\t\t\t}\n\t\t}\n\t\treturn out;\n\t}\n\n\tfunction currentDescription() {\n\t\tvar tag = metaTags( [ 'description' ] )[ 0 ];\n\t\treturn tag ? ( tag.getAttribute( 'content' ) || '' ) : '';\n\t}\n\n\t// Vis endringen med en gang; ved neste visning skriver siden den selv.\n\tfunction showPageValue( kind, value ) {\n\t\tif ( 'title' === kind ) {\n\t\t\tdocument.title = value;\n\t\t\tmetaTags( [ 'og:title', 'twitter:title' ] ).forEach( function ( m ) {\n\t\t\t\tm.setAttribute( 'content', value );\n\t\t\t} );\n\t\t\treturn;\n\t\t}\n\t\tvar tags = metaTags( [ 'description', 'og:description', 'twitter:description' ] );\n\t\tif ( ! metaTags( [ 'description' ] ).length ) {\n\t\t\tvar m = document.createElement( 'meta' );\n\t\t\tm.setAttribute( 'name', 'description' );\n\t\t\tdocument.head.appendChild( m );\n\t\t\ttags.push( m );\n\t\t}\n\t\ttags.forEach( function ( tag ) {\n\t\t\ttag.setAttribute( 'content', value );\n\t\t} );\n\t}\n\n\t// Det siden handler om, som grunnlag for AI-forslag.\n\tfunction pageSummary() {\n\t\tvar parts = [];\n\t\tvar h1 = document.querySelector( 'h1' );\n\t\tif ( h1 && ! isOwnUi( h1 ) ) {\n\t\t\tparts.push( h1.textContent.trim() );\n\t\t}\n\t\tvar ps = document.querySelectorAll( 'p' );\n\t\tfor ( var i = 0; i < ps.length && parts.length < 5; i++ ) {\n\t\t\tvar t = ( ps[ i ].textContent || '' ).trim();\n\t\t\tif ( t.length > 30 && ! isOwnUi( ps[ i ] ) ) {\n\t\t\t\tparts.push( t );\n\t\t\t}\n\t\t}\n\t\treturn parts.join( '\\n' ).replace( /[ \\t]+/g, ' ' ).slice( 0, 900 );\n\t}\n\n\tfunction editPage() {\n\t\t// Read when the panel opens, since client-side navigation changes the\n\t\t// page without loading this script again.\n\t\tvar target = platform.pageTarget();\n\t\tif ( ! target ) {\n\t\t\ttoast( 'Denne siden har ingen tittel og beskrivelse å endre.' );\n\t\t\treturn;\n\t\t}\n\t\tcloseSettings();\n\t\t// Beside the button, so the panel doesn't cover it.\n\t\tvar r = pageBtn.getBoundingClientRect();\n\t\tclickPos = { x: r.left, y: r.top };\n\t\topenPanel();\n\t\tpanel.classList.add( 'wpve-page-panel' );\n\t\tpanel.innerHTML =\n\t\t\t'<div class=\"wpve-panel-head\">Side i søkemotorer<span class=\"wpve-close\">×</span></div>' +\n\t\t\t'<div class=\"wpve-result\"><div class=\"wpve-loading\">Henter sidetittel og beskrivelse …</div></div>';\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\t\tpositionPanel();\n\n\t\t// What the page has saved: the panel shows its own values, and what an empty field gives.\n\t\tvar mine = panel;\n\t\tnew Promise( function ( resolve ) {\n\t\t\tresolve( platform.readPage( target ) );\n\t\t} )\n\t\t\t.then( function ( seo ) {\n\t\t\t\tif ( panel === mine ) {\n\t\t\t\t\trenderPagePanel( target, seo || {} );\n\t\t\t\t}\n\t\t\t} )\n\t\t\t.catch( function ( err ) {\n\t\t\t\tif ( panel === mine ) {\n\t\t\t\t\tshowError( panel.querySelector( '.wpve-result' ), err );\n\t\t\t\t\tpositionPanel();\n\t\t\t\t}\n\t\t\t} );\n\t}\n\n\tfunction renderPagePanel( target, seo ) {\n\t\tvar mine = panel;\n\t\tvar was = { title: String( seo.title || '' ), description: String( seo.description || '' ) };\n\t\tvar titleEditable = seo.titleEditable !== false;\n\n\t\tpanel.innerHTML =\n\t\t\t'<div class=\"wpve-panel-head\">Side i søkemotorer<span class=\"wpve-close\">×</span></div>' +\n\t\t\t'<div class=\"wpve-field-label\">Sidetittel <span class=\"wpve-count\" data-kind=\"title\"></span></div>' +\n\t\t\t'<input type=\"text\" class=\"wpve-prompt wpve-page-title\" maxlength=\"200\" />' +\n\t\t\t'<div class=\"wpve-field-label\">Metabeskrivelse <span class=\"wpve-count\" data-kind=\"description\"></span></div>' +\n\t\t\t'<textarea class=\"wpve-prompt wpve-page-desc\" rows=\"4\" maxlength=\"400\"></textarea>' +\n\t\t\t'<div class=\"wpve-hint\"></div>' +\n\t\t\t'<div class=\"wpve-actions\">' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-btn wpve-page-ai\">Foreslå med AI</button>' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-btn wpve-page-save\">Lagre</button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"wpve-result\"></div>';\n\n\t\tvar titleEl = panel.querySelector( '.wpve-page-title' );\n\t\tvar descEl = panel.querySelector( '.wpve-page-desc' );\n\t\tvar resultBox = panel.querySelector( '.wpve-result' );\n\t\tvar aiBtn = panel.querySelector( '.wpve-page-ai' );\n\t\tvar saveBtn = panel.querySelector( '.wpve-page-save' );\n\t\tpanel.querySelector( '.wpve-hint' ).textContent = platform.pageHint( target, seo );\n\t\ttitleEl.value = was.title;\n\t\tdescEl.value = was.description;\n\t\t// Det et tomt felt gir, der plattformen har noe å falle tilbake på.\n\t\ttitleEl.placeholder = String( seo.titleDefault || '' );\n\t\tdescEl.placeholder = String( seo.descriptionDefault || '' );\n\t\ttitleEl.disabled = ! titleEditable;\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\n\t\tvar limits = { title: 60, description: 160 };\n\t\tfunction count() {\n\t\t\t[ [ 'title', titleEl ], [ 'description', descEl ] ].forEach( function ( pair ) {\n\t\t\t\tvar el = mine.querySelector( '.wpve-count[data-kind=\"' + pair[ 0 ] + '\"]' );\n\t\t\t\tvar n = pair[ 1 ].value.trim().length;\n\t\t\t\tel.textContent = n + ' / ' + limits[ pair[ 0 ] ];\n\t\t\t\tel.classList.toggle( 'wpve-count-long', n > limits[ pair[ 0 ] ] );\n\t\t\t} );\n\t\t}\n\t\ttitleEl.addEventListener( 'input', count );\n\t\tdescEl.addEventListener( 'input', count );\n\t\tcount();\n\n\t\tfunction note( cls, text ) {\n\t\t\tif ( 'wpve-error' === cls ) {\n\t\t\t\tshowError( resultBox, text );\n\t\t\t} else {\n\t\t\t\tresultBox.innerHTML = '<div class=\"' + cls + '\"></div>';\n\t\t\t\tresultBox.firstChild.textContent = text;\n\t\t\t}\n\t\t\tpositionPanel();\n\t\t}\n\n\t\t// Ett forslag per felt, hvert til prisen av et tekstforslag.\n\t\taiBtn.addEventListener( 'click', function () {\n\t\t\tvar off = platform.aiUnavailable();\n\t\t\tif ( off ) {\n\t\t\t\tnote( 'wpve-error', off );\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar about = pageSummary() || was.title || titleEl.placeholder || document.title;\n\t\t\tvar first = about.split( '\\n' )[ 0 ] || document.title;\n\t\t\taiBtn.disabled = true;\n\t\t\tnote( 'wpve-loading', 'Skriver forslag …' );\n\t\t\tnew Promise( function ( resolve ) {\n\t\t\t\tresolve( platform.aiReady() );\n\t\t\t} )\n\t\t\t\t.then( function () {\n\t\t\t\t\treturn Promise.all( [\n\t\t\t\t\t\ttitleEditable ? platform.suggestMeta( 'title',\n\t\t\t\t\t\t\ttitleEl.value.trim() || titleEl.placeholder || first,\n\t\t\t\t\t\t\t'Write the <title> for this web page instead: at most 60 characters, in the same language as the page, ' +\n\t\t\t\t\t\t\t\t'plain text, no quotes. The page says:\\n' + about,\n\t\t\t\t\t\t\ttarget\n\t\t\t\t\t\t) : null,\n\t\t\t\t\t\tplatform.suggestMeta( 'description',\n\t\t\t\t\t\t\tdescEl.value.trim() || descEl.placeholder || first,\n\t\t\t\t\t\t\t'Write the meta description for this web page instead: 120 to 155 characters, in the same language as the page, ' +\n\t\t\t\t\t\t\t\t'plain text, no quotes, one or two sentences that make a searcher want to click. The page says:\\n' + about,\n\t\t\t\t\t\t\ttarget\n\t\t\t\t\t\t),\n\t\t\t\t\t] );\n\t\t\t\t} )\n\t\t\t\t.then( function ( out ) {\n\t\t\t\t\taiBtn.disabled = false;\n\t\t\t\t\tif ( out[ 0 ] ) {\n\t\t\t\t\t\ttitleEl.value = String( out[ 0 ].suggestion || '' ).trim();\n\t\t\t\t\t}\n\t\t\t\t\tdescEl.value = String( out[ 1 ].suggestion || '' ).trim();\n\t\t\t\t\tcount();\n\t\t\t\t\t// Forslagene er ulagret tekst: et klikk utenfor skal ikke kaste dem.\n\t\t\t\t\tmine.setAttribute( 'data-dirty', '1' );\n\t\t\t\t\tnote( 'wpve-hint', 'Forslagene er satt inn. Se over og trykk Lagre.' );\n\t\t\t\t\tplatform.aiUsed();\n\t\t\t\t} )\n\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\taiBtn.disabled = false;\n\t\t\t\t\tnote( 'wpve-error', err );\n\t\t\t\t} );\n\t\t} );\n\n\t\tsaveBtn.addEventListener( 'click', function () {\n\t\t\tvar changes = {};\n\t\t\tvar changed = [];\n\t\t\t[ [ 'title', titleEl ], [ 'description', descEl ] ].forEach( function ( pair ) {\n\t\t\t\tvar value = pair[ 1 ].value.trim();\n\t\t\t\tif ( ! pair[ 1 ].disabled && value !== was[ pair[ 0 ] ].trim() ) {\n\t\t\t\t\tchanges[ pair[ 0 ] ] = value; // tomt = tilbake til originalen\n\t\t\t\t\tchanged.push( [ pair[ 0 ], value ] );\n\t\t\t\t}\n\t\t\t} );\n\t\t\tif ( ! changed.length ) {\n\t\t\t\tclosePanel();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsaveBtn.disabled = true;\n\t\t\tsaveBtn.textContent = 'Lagrer …';\n\t\t\tplatform.savePage( changes, target )\n\t\t\t\t.then( function ( data ) {\n\t\t\t\t\tvar emptied = false;\n\t\t\t\t\tchanged.forEach( function ( c ) {\n\t\t\t\t\t\tif ( c[ 1 ] ) {\n\t\t\t\t\t\t\tshowPageValue( c[ 0 ], c[ 1 ] );\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\temptied = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t} );\n\t\t\t\t\tclosePanel();\n\t\t\t\t\ttoast( ( data && data.warning ) || ( emptied ? platform.pageEmptied : 'Lagret ✓' ) );\n\t\t\t\t} )\n\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\tsaveBtn.disabled = false;\n\t\t\t\t\tsaveBtn.textContent = 'Lagre';\n\t\t\t\t\tnote( 'wpve-error', err );\n\t\t\t\t} );\n\t\t} );\n\n\t\tpositionPanel();\n\t\t( titleEditable ? titleEl : descEl ).focus();\n\t}\n\n\t/* ---------- Ikoner og kommandolinje ---------- */\n\n\t// Function declarations are hoisted, so init() can use these wherever it runs.\n\tfunction icon( name, cls ) {\n\t\tvar shapes = {\n\t\t\tpencil: '<path d=\"M4 20h4L19 9l-4-4L4 16v4z\"/><path d=\"M13.5 6.5l4 4\"/>',\n\t\t\timage: '<rect x=\"3.5\" y=\"5\" width=\"17\" height=\"14\" rx=\"2.5\"/><circle cx=\"9\" cy=\"10\" r=\"1.5\"/><path d=\"M4 17l5-4.5 3.5 3 3-2.5 4.5 4\"/>',\n\t\t\tsignout: '<path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4\"/><path d=\"M15 16l4-4-4-4\"/><path d=\"M19 12H9\"/>',\n\t\t\tspark: '<path fill=\"currentColor\" stroke=\"none\" d=\"M12 3l1.7 6.3L20 11l-6.3 1.7L12 19l-1.7-6.3L4 11l6.3-1.7L12 3z\"/>' +\n\t\t\t\t'<path fill=\"currentColor\" stroke=\"none\" d=\"M19 16l.6 1.9 1.9.6-1.9.6L19 21l-.6-1.9-1.9-.6 1.9-.6L19 16z\"/>',\n\t\t};\n\t\treturn '<svg class=\"' + ( cls || '' ) + '\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" ' +\n\t\t\t'stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\tshapes[ name ] + '</svg>';\n\t}\n\n\t// The command bar for a text. The switch between asking AI and typing comes\n\t// first in the markup so its state can style the bar after it (overlay.css).\n\tfunction textBarHtml() {\n\t\treturn '<button type=\"button\" class=\"ai-toggle\" data-mode=\"ai\">' +\n\t\t\t\t'<span class=\"wpve-when-ai\">Skriv teksten selv</span><span class=\"wpve-when-manual\">Bruk AI i stedet</span>' +\n\t\t\t'</button>' +\n\t\t\t'<div class=\"wpve-bar\">' +\n\t\t\t\t'<span class=\"wpve-bar-icon\">' + icon( 'spark', 'wpve-when-ai' ) + icon( 'pencil', 'wpve-when-manual' ) + '</span>' +\n\t\t\t\t'<textarea class=\"wpve-prompt\" rows=\"1\"></textarea>' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-send wpve-suggest\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<button type=\"button\" class=\"wpve-close wpve-close-link\">Lukk</button>' +\n\t\t\t'<div class=\"wpve-result\"></div>';\n\t}\n\n\t// The bar's text field grows with what is typed, up to eight lines.\n\tfunction growPrompt( field ) {\n\t\tif ( ! field || 'TEXTAREA' !== field.tagName ) {\n\t\t\treturn;\n\t\t}\n\t\tfield.rows = 1;\n\t\tfield.rows = Math.max( 1, Math.min( 8, Math.ceil( ( field.scrollHeight - 18 ) / 20 ) ) );\n\t}\n\n\tfunction onPanelInput( e ) {\n\t\t// Something typed: a stray click outside must not throw it away (see onPick).\n\t\te.currentTarget.setAttribute( 'data-dirty', '1' );\n\t\tif ( e.target.closest && e.target.closest( '.wpve-bar' ) ) {\n\t\t\tgrowPrompt( e.target );\n\t\t\tpositionPanel();\n\t\t}\n\t}\n\n\t// Enter sends what is in the bar (Shift+Enter makes a new line); Escape closes.\n\tfunction onPanelKey( e ) {\n\t\tif ( 'Escape' === e.key ) {\n\t\t\te.preventDefault();\n\t\t\tclosePanel();\n\t\t\treturn;\n\t\t}\n\t\tvar bar = e.target.closest ? e.target.closest( '.wpve-bar' ) : null;\n\t\tif ( 'Enter' === e.key && ! e.shiftKey && ! e.isComposing && bar ) {\n\t\t\te.preventDefault();\n\t\t\tvar send = bar.querySelector( '.wpve-send' );\n\t\t\tif ( send && ! send.disabled ) {\n\t\t\t\tsend.click();\n\t\t\t}\n\t\t}\n\t}\n\n\t// How much the switch is scaled at each size (overlay.css), so what sits around it lines up.\n\tfunction bubbleScale() {\n\t\treturn { small: 0.82, large: 1.22 }[ settings.size ] || 1;\n\t}\n\n\t// Keep the outline on what is being edited while its panel is open.\n\tfunction markSelected( el ) {\n\t\tif ( selected ) {\n\t\t\tselected.classList.remove( 'wpve-selected' );\n\t\t}\n\t\tselected = el || null;\n\t\tif ( selected ) {\n\t\t\tselected.classList.add( 'wpve-selected' );\n\t\t}\n\t}\n\n\t/* ---------- Panel + toast ---------- */\n\n\tfunction openPanel() {\n\t\tclosePanel();\n\t\tpanel = document.createElement( 'div' );\n\t\tpanel.className = 'wpve-panel';\n\t\tpanel.addEventListener( 'keydown', onPanelKey );\n\t\tpanel.addEventListener( 'input', onPanelInput );\n\t\tdocument.body.appendChild( panel );\n\t\tpositionPanel();\n\t}\n\n\tfunction positionPanel() {\n\t\tif ( ! panel ) {\n\t\t\treturn;\n\t\t}\n\t\tvar margin = 12;\n\t\tvar offset = 18;\n\t\tvar vw = document.documentElement.clientWidth;\n\t\tvar vh = document.documentElement.clientHeight;\n\t\tvar rect = panel.getBoundingClientRect();\n\t\tvar w = rect.width;\n\t\tvar h = rect.height;\n\t\tvar x;\n\t\tvar y;\n\n\t\tif ( selected && selected.getBoundingClientRect ) {\n\t\t\t// Under det som redigeres, så teksten eller bildet er synlig; over hvis det ikke er plass.\n\t\t\tvar box = selected.getBoundingClientRect();\n\t\t\tx = box.left;\n\t\t\ty = box.bottom + 10;\n\t\t\tif ( y + h > vh - margin && box.top - 10 - h >= margin ) {\n\t\t\t\ty = box.top - 10 - h;\n\t\t\t}\n\t\t} else if ( clickPos ) {\n\t\t\tif ( clickPos.x + offset + w <= vw - margin ) {\n\t\t\t\tx = clickPos.x + offset;\n\t\t\t} else {\n\t\t\t\tx = clickPos.x - offset - w;\n\t\t\t}\n\t\t\ty = clickPos.y - 24;\n\t\t} else {\n\t\t\tx = vw - w - 24;\n\t\t\ty = vh - h - 96;\n\t\t}\n\n\t\tx = Math.max( margin, Math.min( x, vw - w - margin ) );\n\t\ty = Math.max( margin, Math.min( y, vh - h - margin ) );\n\n\t\tpanel.style.left = x + 'px';\n\t\tpanel.style.top = y + 'px';\n\t\tpanel.style.right = 'auto';\n\t\tpanel.style.bottom = 'auto';\n\t}\n\n\tfunction closePanel() {\n\t\tmarkSelected( null );\n\t\tif ( panel ) {\n\t\t\tpanel.remove();\n\t\t\tpanel = null;\n\t\t}\n\t}\n\n\t/**\n\t * Lay a spinner overlay over the image while AI works, so it's clear that\n\t * something is happening. Follows the image on scroll/resize, removed when\n\t * the call finishes.\n\t */\n\tfunction showImageLoader( target ) {\n\t\tvar loader = document.createElement( 'div' );\n\t\tloader.className = 'wpve-img-loader';\n\t\tloader.innerHTML = '<div class=\"wpve-spinner\"></div><span>Lager bilde …</span>';\n\t\tdocument.body.appendChild( loader );\n\n\t\tfunction place() {\n\t\t\tvar r = target.getBoundingClientRect();\n\t\t\tloader.style.left = r.left + 'px';\n\t\t\tloader.style.top = r.top + 'px';\n\t\t\tloader.style.width = r.width + 'px';\n\t\t\tloader.style.height = r.height + 'px';\n\t\t}\n\t\tplace();\n\n\t\twindow.addEventListener( 'scroll', place, true );\n\t\twindow.addEventListener( 'resize', place );\n\n\t\treturn {\n\t\t\tremove: function () {\n\t\t\t\twindow.removeEventListener( 'scroll', place, true );\n\t\t\t\twindow.removeEventListener( 'resize', place );\n\t\t\t\tloader.remove();\n\t\t\t},\n\t\t};\n\t}\n\n\tfunction toast( msg ) {\n\t\t// Én melding om gangen: en ny erstatter den som står.\n\t\tvar shown = document.querySelectorAll( '.wpve-toast' );\n\t\tfor ( var i = 0; i < shown.length; i++ ) {\n\t\t\tshown[ i ].remove();\n\t\t}\n\t\tvar t = document.createElement( 'div' );\n\t\tt.className = 'wpve-toast';\n\t\tt.textContent = msg;\n\t\tdocument.body.appendChild( t );\n\t\tsetTimeout( function () {\n\t\t\tt.classList.add( 'wpve-toast-out' );\n\t\t\tsetTimeout( function () {\n\t\t\t\tt.remove();\n\t\t\t}, 400 );\n\t\t}, 2600 );\n\t}\n\n\tfunction escapeHtml( str ) {\n\t\treturn String( str ).replace( /[&<>\"']/g, function ( c ) {\n\t\t\treturn { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[ c ];\n\t\t} );\n\t}\n\n\t/* ---------- API ---------- */\n\n\t// A POST to one of the API's endpoints, sent the platform's way (platform.request).\n\tfunction api( endpoint, payload, timeoutMs ) {\n\t\tvar controller = ( typeof AbortController !== 'undefined' ) ? new AbortController() : null;\n\t\tvar timer = null;\n\t\tif ( controller && timeoutMs ) {\n\t\t\ttimer = setTimeout( function () {\n\t\t\t\tcontroller.abort();\n\t\t\t}, timeoutMs );\n\t\t}\n\n\t\tvar req = platform.request( endpoint, payload );\n\t\treturn fetch( req.url, {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: req.credentials,\n\t\t\tsignal: controller ? controller.signal : undefined,\n\t\t\theaders: req.headers,\n\t\t\tbody: JSON.stringify( req.body ),\n\t\t} ).catch( function ( err ) {\n\t\t\tif ( timer ) {\n\t\t\t\tclearTimeout( timer );\n\t\t\t\ttimer = null;\n\t\t\t}\n\t\t\tif ( err && 'AbortError' === err.name ) {\n\t\t\t\tthrow 'Det tok for lang tid. Prøv igjen om litt.';\n\t\t\t}\n\t\t\tthrow ( err && err.message ) ? err.message : String( err );\n\t\t} ).then( function ( res ) {\n\t\t\tif ( timer ) {\n\t\t\t\tclearTimeout( timer );\n\t\t\t\ttimer = null;\n\t\t\t}\n\t\t\treturn answerOf( res );\n\t\t} );\n\t}\n\n\t/** The JSON answer, or its error as a message to show (with topupUrl when the credits ran out). */\n\tfunction answerOf( res ) {\n\t\treturn res.text().then( function ( text ) {\n\t\t\tvar data = null;\n\t\t\ttry {\n\t\t\t\tdata = JSON.parse( text );\n\t\t\t} catch ( e ) {\n\t\t\t\tdata = null;\n\t\t\t}\n\t\t\tif ( ! res.ok || data === null ) {\n\t\t\t\tvar err = new String( ( data && data.error ) ? data.error : ( 'Serverfeil (' + res.status + ').' ) ); // eslint-disable-line no-new-wrappers\n\t\t\t\tif ( data && data.topupUrl ) {\n\t\t\t\t\terr.topupUrl = data.topupUrl;\n\t\t\t\t}\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t\treturn data;\n\t\t} );\n\t}\n\n\t// Last, so everything above is in place however early this runs.\n\tif ( document.readyState === 'loading' ) {\n\t\tdocument.addEventListener( 'DOMContentLoaded', init );\n\t} else {\n\t\tinit();\n\t}\n} )();\n";
2
+ export const overlayJs = "/*\n * GENERATED FILE – do not edit. Built by tools/build-overlays.mjs from\n * overlay-src/core.js, overlay-src/platform/next-payload.js, overlay-src/platform/emdash.js;\n * change those and run `node tools/build-overlays.mjs`.\n */\n/*\n * Vibe Editor – the browser overlay of the EmDash plugin (emdash-vibe-editor).\n *\n * How it works:\n * The plugin's page hook gives every page a small bootstrap. For a browser\n * EmDash's admin has marked as an editor's, it checks the session and loads\n * this overlay from the plugin's private `client` route, with\n * window.VibeEditor.apiBase set to /_emdash/api/plugins/vibe-editor.\n *\n * What is editable is what the site already marks for EmDash's visual\n * editing: {...entry.edit.title} in an Astro template puts\n * data-emdash-ref='{\"collection\":\"posts\",\"id\":\"…\",\"field\":\"title\"}' on the\n * element. EmDash writes those only in its edit mode, so turning Vibe's edit\n * mode on sets EmDash's edit-mode cookie (and reloads once if the page has\n * no marks yet); turning it off clears the cookie. In edit mode EmDash shows\n * drafts, as its own editor does.\n *\n * Which fields are text, rich text or images comes from GET status\n * ({ collections: { posts: { seo, fields: { title: 'string', … } } } }).\n * A rich-text (Portable Text) field is edited one block at a time: the\n * paragraph, heading, list item or quote that was clicked. Explicit\n * data-vibe-field marks work too, as on Next.js and Payload.\n *\n * Inside a page-builder section (a `blocks` field, kind 'blocks' in\n * status) the mark's field is a dotted path, \"sections.<blockKey>.title\" or\n * \"sections.<blockKey>.steps.<index>.text\". What it is comes from the page:\n * block children (p, h2, ul, …) are rich text, an <img> with no text a\n * picture, anything else a text. The server knows the field's type from\n * EmDash's block types and refuses a mismatch.\n *\n * The page's title and description are edited from the magnifier button on\n * a page that shows one entry of a collection with SEO fields (the page\n * hook names that entry in window.VibeEditor.page). They are kept in the\n * entry's SEO block, which EmDash saves at once, not as a draft.\n *\n * Auth: EmDash's own session cookie, plus the X-EmDash-Request header EmDash\n * wants on every call. The Vibe Editor connection lives server-side only.\n */\n( function () {\n\t'use strict';\n\n\t/*\n\t * Configuration every overlay reads, via a global set before this script loads:\n\t * window.VibeEditor = {\n\t * apiBase: '/api/vibe', // where the editor's API is (default /api/vibe)\n\t * mediaBase: '/api/vibe/media', // media-list route (default: apiBase + '/media')\n\t * enabled: true, // gate to admins in your own app logic\n\t * frame: true, // full-page edit-mode frame (default)\n\t * settings: true, // show the in-page settings (gear) button\n\t * label: 'Vibe Editor', // the switch's name for screen readers, off …\n\t * labelActive: 'Redigerer …', // … and on\n\t * size: 'medium', // bubble size: 'small' | 'medium' | 'large'\n\t * position: 'bottom-right',// 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'custom'\n\t * hSide: 'right', // for position:'custom' – 'right' | 'left'\n\t * hOffset: 24, // for position:'custom' – px from that side\n\t * vSide: 'bottom', // for position:'custom' – 'bottom' | 'top'\n\t * vOffset: 24, // for position:'custom' – px from that side\n\t * };\n\t * Size and position are also editable in the in-page settings popup and\n\t * persisted per-browser to localStorage, as are the edit frame and «Marker\n\t * redigerbare felt».\n\t */\n\tvar CFG = window.VibeEditor || {};\n\tif ( CFG.enabled === false ) {\n\t\treturn;\n\t}\n\n\tvar API = ( CFG.apiBase || '/api/vibe' ).replace( /\\/$/, '' );\n\tvar MEDIA = ( CFG.mediaBase || ( API + '/media' ) ).replace( /\\/$/, '' );\n\tvar SHOW_SETTINGS = CFG.settings !== false;\n\tvar SETTINGS_KEY = 'wpve-settings';\n\tvar LABEL_IDLE = CFG.label || 'Vibe Editor';\n\tvar LABEL_ACTIVE = CFG.labelActive || 'Redigerer …';\n\n\tvar editMode = false;\n\tvar bubble = null;\n\tvar settingsBtn = null;\n\tvar pageBtn = null;\n\tvar settingsPanel = null;\n\tvar panel = null;\n\tvar hovered = null;\n\tvar clickPos = null;\n\t// What is being edited: it keeps its outline while its panel is open.\n\tvar selected = null;\n\t// Allowed values for size/position — declared here (before loadSettings runs)\n\t// so settings validation never sees them undefined.\n\tvar SIZES = [ 'small', 'medium', 'large' ];\n\tvar POSITIONS = [ 'bottom-right', 'bottom-left', 'top-right', 'top-left', 'custom' ];\n\tvar settings = loadSettings();\n\t// The Vibe Editor account the site's AI runs on, once the platform has asked:\n\t// { balance, costs: { image, imageEdit }, error, … }. Null when it hasn't.\n\tvar account = null;\n\t// What the page panel says about titles and descriptions, on every platform.\n\tvar PAGE_GUIDE = 'Vises i søketreff og når siden deles. Tittel: helst under 60 tegn. Beskrivelse: 120–160 tegn.';\n\n\t/* ---------- Plattformdelen (overlay-src/platform/) ---------- */\n\n\t// What this platform does its own way: its settings from CFG, its helpers,\n\t// and `platform`, which gives the hooks in HOOKS below.\n\t/*\n\t * What the Next.js adapter and the Payload plugin share: their own server\n\t * holds the site's Vibe Editor key and asks Vibe Editor for AI. GET /status\n\t * says whether AI pictures can be made here ({ images }), the credits left and\n\t * what a picture costs, and for Next.js whether the page's title and\n\t * description can be edited ({ pageMeta }). A page's title and description go\n\t * to /suggest and /save as { meta } along with what the page is.\n\t */\n\n\t// Whether AI pictures can be made here. Gates the \"generate with AI\" panel.\n\t// Refreshed on load and whenever the settings popup opens.\n\tvar hasImageKey = false;\n\n\t/**\n\t * Ask the server whether AI pictures can be made here, and how many credits\n\t * are left. Best-effort: failure just leaves generation disabled (the\n\t * URL/library picker still works).\n\t */\n\tfunction refreshStatus() {\n\t\t// Sent the platform's way: EmDash wants its request header on a GET too.\n\t\tvar req = platform.request( 'status' );\n\t\tfetch( req.url, { credentials: req.credentials, headers: req.headers } )\n\t\t\t.then( function ( res ) {\n\t\t\t\treturn res.json();\n\t\t\t} )\n\t\t\t.then( function ( data ) {\n\t\t\t\taccount = data || null;\n\t\t\t\thasImageKey = !! ( data && data.images );\n\t\t\t\t// Title and description, once the server says where they are kept.\n\t\t\t\tif ( data && data.pageMeta ) {\n\t\t\t\t\taddPageButton();\n\t\t\t\t}\n\t\t\t\tvar note = settingsPanel && settingsPanel.querySelector( '.wpve-setting-note' );\n\t\t\t\tif ( note ) {\n\t\t\t\t\tnote.textContent = creditsNote();\n\t\t\t\t}\n\t\t\t} )\n\t\t\t.catch( function () {\n\t\t\t\thasImageKey = false;\n\t\t\t} );\n\t}\n\n\t// One line for the settings: whose credits the AI uses, and how many are left.\n\tfunction creditsNote() {\n\t\tif ( account && account.error ) {\n\t\t\treturn String( account.error );\n\t\t}\n\t\treturn 'AI bruker kredittene på Vibe Editor-kontoen.' + creditsLeft();\n\t}\n\n\tfunction creditsHtml() {\n\t\treturn '<div class=\"wpve-setting-note\"></div>';\n\t}\n\n\tfunction creditsShown() {\n\t\tsettingsPanel.querySelector( '.wpve-setting-note' ).textContent = creditsNote();\n\t\trefreshStatus();\n\t}\n\n\tfunction canMakePictures() {\n\t\treturn hasImageKey;\n\t}\n\n\t// Under the picture bar: what a picture costs, or why none can be made.\n\tfunction pictureHint( canGen ) {\n\t\tif ( canGen ) {\n\t\t\treturn imageCost();\n\t\t}\n\t\treturn account && account.error ? String( account.error ) : 'AI-bilder er ikke satt opp på denne nettsiden.';\n\t}\n\n\tfunction aiOffReason() {\n\t\treturn account && false === account.connected ? ( account.error || 'AI er ikke satt opp på denne nettsiden.' ) : '';\n\t}\n\n\t// Ett forslag per felt, hvert til prisen av et tekstforslag.\n\tfunction suggestPageMeta( kind, original, instruction, target ) {\n\t\treturn api( 'suggest', Object.assign( {\n\t\t\tmeta: kind,\n\t\t\toriginal: original,\n\t\t\tinstruction: instruction,\n\t\t}, target ) );\n\t}\n\n\t// Both in one call; an empty one goes back to the original.\n\tfunction savePageMeta( changes, target ) {\n\t\treturn api( 'save', Object.assign( { meta: changes }, target ) );\n\t}\n\n\tvar EMDASH_COOKIE = 'emdash-edit-mode';\n\t// Set before the reload that turns EmDash's edit mode on, so the overlay comes back editing.\n\tvar EMDASH_RESUME = 'vibe-emdash-resume';\n\t// The elements of a rich-text field that are edited one at a time.\n\tvar EMDASH_BLOCKS = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';\n\t// Field types that can be edited here; others (numbers, dates, references, …) are left alone.\n\tvar EMDASH_KINDS = { string: 1, text: 1, portableText: 1, image: 1 };\n\t// EmDash's media library, as its admin lists it.\n\tvar EMDASH_MEDIA = '/_emdash/api/media';\n\t// EmDash's own in-page rich-text editor, which it mounts on Portable Text in edit mode.\n\tvar EMDASH_INLINE = '.emdash-inline-editor';\n\n\tvar platform = {\n\t\tstart: startEmDash,\n\t\tpageAtStart: true,\n\t\tpageShown: function () {\n\t\t\treturn !! pageTarget();\n\t\t},\n\t\tsettingsHtml: creditsHtml,\n\t\tsettingsOpened: creditsShown,\n\t\tcanGenerate: canMakePictures,\n\t\timageHint: pictureHint,\n\t\tfindAuto: emdashField,\n\t\trequest: function ( endpoint, payload ) {\n\t\t\treturn {\n\t\t\t\turl: API + '/' + endpoint,\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { 'Content-Type': 'application/json', 'X-EmDash-Request': '1' },\n\t\t\t\tbody: payload,\n\t\t\t};\n\t\t},\n\t\t// The block's text as it was goes along, so the server finds it in the rich text.\n\t\tsavePayload: function ( ctx, value ) {\n\t\t\treturn {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\tvalue: value,\n\t\t\t\toriginal: ctx.original,\n\t\t\t};\n\t\t},\n\t\t// Rich text shown by EmDash's own editor is its editor's document: changing\n\t\t// its DOM would be read back as typing and saved by EmDash on blur, over\n\t\t// this save. Reload instead; edit mode comes back on.\n\t\tshowText: function ( ctx, value ) {\n\t\t\tif ( ctx.inline ) {\n\t\t\t\ttoast( 'Lagret ✓ – oppdaterer siden …' );\n\t\t\t\tsetTimeout( resumeAfterReload, 900 );\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tctx.el.textContent = value;\n\t\t},\n\t\tsaveAlt: function ( ctx, text ) {\n\t\t\treturn api( 'save', {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\talt: text,\n\t\t\t} );\n\t\t},\n\t\t// The library through EmDash's own admin API, which (unlike the plugin's) has\n\t\t// each picture's storage key: the save route sends \"<id>|<storageKey>\".\n\t\tlistMedia: function () {\n\t\t\treturn fetch( EMDASH_MEDIA + '?mimeType=image%2F&limit=24', {\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { 'X-EmDash-Request': '1' },\n\t\t\t} )\n\t\t\t\t.then( function ( res ) {\n\t\t\t\t\treturn res.json();\n\t\t\t\t} )\n\t\t\t\t.then( function ( body ) {\n\t\t\t\t\tvar items = ( body && body.data && body.data.items ) || [];\n\t\t\t\t\tvar docs = items\n\t\t\t\t\t\t.filter( function ( m ) {\n\t\t\t\t\t\t\treturn m && m.id && m.storageKey;\n\t\t\t\t\t\t} )\n\t\t\t\t\t\t.map( function ( m ) {\n\t\t\t\t\t\t\treturn { id: m.id + '|' + m.storageKey, url: m.url, alt: m.alt || m.filename || '' };\n\t\t\t\t\t\t} );\n\t\t\t\t\treturn {\n\t\t\t\t\t\tjson: function () {\n\t\t\t\t\t\t\treturn docs;\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t} );\n\t\t},\n\t\tpageTarget: pageTarget,\n\t\t// The entry's SEO block, and what an empty title or description falls back to.\n\t\treadPage: function ( target ) {\n\t\t\tvar req = platform.request( 'seo?collection=' + encodeURIComponent( target.collection ) + '&id=' + encodeURIComponent( target.id ) );\n\t\t\treturn fetch( req.url, { credentials: req.credentials, headers: req.headers } ).then( answerOf );\n\t\t},\n\t\tpageHint: function () {\n\t\t\treturn 'Lagres i innleggets SEO-felt og vises med en gang. ' + PAGE_GUIDE;\n\t\t},\n\t\taiUnavailable: aiOffReason,\n\t\tsuggestMeta: suggestPageMeta,\n\t\tsavePage: function ( changes, target ) {\n\t\t\treturn api( 'seo', Object.assign( { meta: changes }, target ) );\n\t\t},\n\t\tpageEmptied: 'Lagret ✓ – last siden på nytt for å se standardteksten',\n\t};\n\n\tfunction startEmDash() {\n\t\trefreshStatus();\n\t\t// In Vibe's edit mode a click in rich text is Vibe's. EmDash's editor would\n\t\t// take focus on mousedown and save on blur, and the browser often fires no\n\t\t// click there at all; so the click is made on mouseup, on the block under\n\t\t// the pointer, and a native click that may still follow is dropped.\n\t\tvar madeAt = 0;\n\t\tvar inEditor = function ( e ) {\n\t\t\treturn editMode && e.target && e.target.closest && e.target.closest( EMDASH_INLINE ) && ! isOwnUi( e.target );\n\t\t};\n\t\t[ 'pointerdown', 'mousedown' ].forEach( function ( type ) {\n\t\t\tdocument.addEventListener( type, function ( e ) {\n\t\t\t\tif ( inEditor( e ) ) {\n\t\t\t\t\te.stopPropagation();\n\t\t\t\t\tif ( 'mousedown' === type ) {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}, true );\n\t\t} );\n\t\tdocument.addEventListener( 'mouseup', function ( e ) {\n\t\t\tif ( ! inEditor( e ) || 0 !== e.button ) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar under = document.elementFromPoint( e.clientX, e.clientY );\n\t\t\tvar block = under && under.closest( EMDASH_BLOCKS );\n\t\t\tif ( ! block ) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\te.stopPropagation();\n\t\t\tmadeAt = Date.now();\n\t\t\tblock.dispatchEvent( new MouseEvent( 'click', { bubbles: true, cancelable: true, clientX: e.clientX, clientY: e.clientY } ) );\n\t\t}, true );\n\t\twindow.addEventListener( 'click', function ( e ) {\n\t\t\tif ( e.isTrusted && Date.now() - madeAt < 500 && inEditor( e ) ) {\n\t\t\t\te.stopImmediatePropagation();\n\t\t\t\te.preventDefault();\n\t\t\t}\n\t\t}, true );\n\t\t// Turning Vibe's edit mode off ends EmDash's too, so the site shows what is published again.\n\t\tbubble.addEventListener( 'click', function () {\n\t\t\tif ( editMode ) {\n\t\t\t\temdashEditMode();\n\t\t\t} else {\n\t\t\t\tsetEmDashCookie( false );\n\t\t\t}\n\t\t} );\n\t\tvar resume = false;\n\t\ttry {\n\t\t\tresume = sessionStorage.getItem( EMDASH_RESUME ) === location.pathname;\n\t\t\tsessionStorage.removeItem( EMDASH_RESUME );\n\t\t} catch ( e ) {}\n\t\tif ( resume && ! editMode ) {\n\t\t\ttoggleEditMode();\n\t\t}\n\t}\n\n\t// Vibe's edit mode came on: EmDash's must be on for the page to carry its marks.\n\tfunction emdashEditMode() {\n\t\tvar had = hasEmDashCookie();\n\t\tsetEmDashCookie( true );\n\t\tif ( ! had && ! document.querySelector( '[data-emdash-ref]' ) ) {\n\t\t\tresumeAfterReload();\n\t\t}\n\t}\n\n\t// Reload the page and come back in edit mode.\n\tfunction resumeAfterReload() {\n\t\ttry {\n\t\t\tsessionStorage.setItem( EMDASH_RESUME, location.pathname );\n\t\t} catch ( e ) {}\n\t\tlocation.reload();\n\t}\n\n\tfunction hasEmDashCookie() {\n\t\treturn ( '; ' + document.cookie ).indexOf( '; ' + EMDASH_COOKIE + '=true' ) !== -1;\n\t}\n\n\tfunction setEmDashCookie( on ) {\n\t\tdocument.cookie = EMDASH_COOKIE + '=' + ( on ? 'true' : '; Max-Age=0' ) + '; Path=/; SameSite=Lax';\n\t}\n\n\tfunction emdashRef( el ) {\n\t\ttry {\n\t\t\treturn JSON.parse( el.getAttribute( 'data-emdash-ref' ) );\n\t\t} catch ( e ) {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t// A field's type, from GET status; '' while it hasn't answered.\n\tfunction emdashKind( collection, field ) {\n\t\tvar c = account && account.collections && account.collections[ collection ];\n\t\tif ( ! account || ! account.collections ) {\n\t\t\treturn '';\n\t\t}\n\t\treturn ( c && c.fields && c.fields[ field ] ) || 'none';\n\t}\n\n\t// The field EmDash's own editor edits, from the props of the island it runs in;\n\t// null when the element isn't in one.\n\tfunction inlineRef( el ) {\n\t\tvar island = el.closest( 'astro-island[component-export=\"InlinePortableTextEditor\"]' );\n\t\tif ( ! island ) {\n\t\t\treturn null;\n\t\t}\n\t\ttry {\n\t\t\t// Astro serialises each prop as [type, value].\n\t\t\tvar props = JSON.parse( island.getAttribute( 'props' ) );\n\t\t\tvar ref = { collection: props.collection[ 1 ], id: props.entryId[ 1 ], field: props.field[ 1 ] };\n\t\t\treturn ref.collection && ref.id && ref.field ? ref : null;\n\t\t} catch ( e ) {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t// What a mark inside a page-builder section shows, read from the page (the\n\t// server knows the field's type and refuses a mismatch): rich text when its\n\t// children are blocks (p, h2, ul, …), a picture when it is an <img> or holds\n\t// one and no text, else a text.\n\tfunction nestedKind( el ) {\n\t\tfor ( var i = 0; i < el.children.length; i++ ) {\n\t\t\tif ( /^(P|H[1-6]|UL|OL|BLOCKQUOTE)$/.test( el.children[ i ].tagName ) ) {\n\t\t\t\treturn 'portableText';\n\t\t\t}\n\t\t}\n\t\tif ( 'IMG' === el.tagName || ( el.querySelector( 'img' ) && ! el.textContent.trim() ) ) {\n\t\t\treturn 'image';\n\t\t}\n\t\treturn 'text';\n\t}\n\n\t// A field's type, also for a dotted path inside a blocks field\n\t// (\"sections.<blockKey>.title\"): '' while status hasn't answered, 'none' when\n\t// it can't be edited here.\n\tfunction emdashRefKind( el, ref ) {\n\t\tvar dot = ref.field.indexOf( '.' );\n\t\tif ( dot < 0 ) {\n\t\t\treturn emdashKind( ref.collection, ref.field );\n\t\t}\n\t\tvar top = emdashKind( ref.collection, ref.field.slice( 0, dot ) );\n\t\treturn top && 'blocks' !== top ? 'none' : nestedKind( el );\n\t}\n\n\t/** What a click edits: the nearest data-emdash-ref with a field, and for rich text the block clicked. */\n\tfunction emdashField( el ) {\n\t\tvar editor = el.closest( EMDASH_INLINE );\n\t\tvar islandRef = editor ? inlineRef( el ) : null;\n\t\tif ( islandRef ) {\n\t\t\t// EmDash's editor knows its field even where the template has no mark.\n\t\t\tvar block = el.closest( EMDASH_BLOCKS );\n\t\t\tvar k = emdashKind( islandRef.collection, islandRef.field );\n\t\t\tif ( ! block || ! editor.contains( block ) || ! block.textContent.trim() || ( k && 'portableText' !== k ) ) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\treturn { el: block, collection: islandRef.collection, id: islandRef.id, field: islandRef.field, kind: 'portableText', inline: true };\n\t\t}\n\t\tvar refEl = el.closest( '[data-emdash-ref]' );\n\t\twhile ( refEl ) {\n\t\t\tvar ref = emdashRef( refEl );\n\t\t\tif ( ref && ref.collection && ref.id && ref.field && 'string' === typeof ref.field ) {\n\t\t\t\tvar kind = emdashRefKind( refEl, ref );\n\t\t\t\tif ( kind && ! EMDASH_KINDS[ kind ] ) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\tvar target = refEl;\n\t\t\t\tif ( 'portableText' === kind ) {\n\t\t\t\t\ttarget = el.closest( EMDASH_BLOCKS );\n\t\t\t\t\tif ( ! target || ! refEl.contains( target ) || ! target.textContent.trim() ) {\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Only EmDash's own editor (top-level rich text) needs the reload after a save.\n\t\t\t\tvar inline = !! editor && -1 === ref.field.indexOf( '.' );\n\t\t\t\treturn { el: target, collection: ref.collection, id: ref.id, field: ref.field, kind: kind, inline: inline };\n\t\t\t}\n\t\t\trefEl = refEl.parentElement ? refEl.parentElement.closest( '[data-emdash-ref]' ) : null;\n\t\t}\n\t\treturn null;\n\t}\n\n\t// The entry the page shows: as the plugin's page hook named it, else an\n\t// entry-level mark (no field), else the one entry all the page's marks belong\n\t// to. Only for a collection whose SEO fields can be edited here.\n\tfunction pageTarget() {\n\t\tvar named = CFG.page;\n\t\tif ( named && named.collection && named.id ) {\n\t\t\treturn seoTarget( named );\n\t\t}\n\t\tvar marks = document.querySelectorAll( '[data-emdash-ref]' );\n\t\tvar entry = null;\n\t\tvar only = null;\n\t\tvar many = false;\n\t\tfor ( var i = 0; i < marks.length; i++ ) {\n\t\t\tif ( isOwnUi( marks[ i ] ) ) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tvar ref = emdashRef( marks[ i ] );\n\t\t\tif ( ! ref || ! ref.collection || ! ref.id ) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( ! ref.field && ! entry ) {\n\t\t\t\tentry = ref;\n\t\t\t}\n\t\t\tif ( only && ( only.collection !== ref.collection || only.id !== ref.id ) ) {\n\t\t\t\tmany = true;\n\t\t\t}\n\t\t\tonly = only || ref;\n\t\t}\n\t\tvar found = entry || ( many ? null : only );\n\t\treturn found ? seoTarget( found ) : null;\n\t}\n\n\tfunction seoTarget( ref ) {\n\t\tvar c = account && account.collections && account.collections[ ref.collection ];\n\t\treturn c && c.seo ? { collection: ref.collection, id: ref.id } : null;\n\t}\n\n\t/* ---------- Plattformens kroker ---------- */\n\n\t/*\n\t * What a platform can change, and what the overlay does when it doesn't.\n\t * A hook given as null has no default: every platform must give it, which\n\t * tools/build-overlays.mjs checks (and that no platform names a hook that\n\t * isn't here).\n\t */\n\tvar HOOKS = {\n\t\t// Once the switch and its buttons are on the page: ask the server what works here.\n\t\tstart: function () {},\n\t\t// Whether the page button (title and description) is made at the start;\n\t\t// a platform that learns it later calls addPageButton(). And whether it shows now.\n\t\tpageAtStart: false,\n\t\tpageShown: function () {\n\t\t\treturn true;\n\t\t},\n\t\t// The platform's part of the settings panel, below the overlay's own; then wire it up.\n\t\tsettingsHtml: function () {\n\t\t\treturn '';\n\t\t},\n\t\tsettingsOpened: function () {},\n\t\t// «Marker redigerbare felt» turned on or off.\n\t\thighlight: function () {},\n\t\t// What a click edits when it isn't inside a data-vibe-field: { el, collection, id, field } or null.\n\t\tfindAuto: function () {\n\t\t\treturn null;\n\t\t},\n\t\t// More about a data-vibe-field the server needs, added to its ctx.\n\t\tfieldContext: function () {},\n\t\t// How a POST to one of the API's endpoints is sent.\n\t\trequest: function ( endpoint, payload ) {\n\t\t\treturn {\n\t\t\t\turl: API + '/' + endpoint,\n\t\t\t\tcredentials: 'include',\n\t\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\t\tbody: payload,\n\t\t\t};\n\t\t},\n\n\t\t// A text's panel opened (ctx.original is not set yet).\n\t\ttextOpened: function () {},\n\t\t// An AI suggestion for a text: a promise of { suggestion }.\n\t\tsuggestText: function ( ctx, original, instruction ) {\n\t\t\treturn api( 'suggest', {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\toriginal: original,\n\t\t\t\tinstruction: instruction,\n\t\t\t} );\n\t\t},\n\t\t// What /save is sent for a text (ctx.original is what it was when the panel opened).\n\t\tsavePayload: function ( ctx, value ) {\n\t\t\treturn {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\tvalue: value,\n\t\t\t};\n\t\t},\n\t\t// Show a saved text on the page; data is what /save answered.\n\t\tshowText: function ( ctx, value ) {\n\t\t\tctx.el.textContent = value;\n\t\t},\n\t\t// An AI call went through (credits were spent).\n\t\taiUsed: function () {},\n\n\t\t// Whether AI pictures can be made here, and the line under the picture bar.\n\t\tcanGenerate: function () {\n\t\t\treturn false;\n\t\t},\n\t\timageHint: function () {\n\t\t\treturn '';\n\t\t},\n\t\t// An image's panel opened.\n\t\timageOpened: function () {},\n\t\t// Whether the image is an icon (drawn anew as SVG, not painted).\n\t\tisIcon: function () {\n\t\t\treturn false;\n\t\t},\n\t\timageWait: 'Lager bilde … (kan ta 10–30 sek)',\n\t\timageTimeout: 110000,\n\t\t// Make a picture for the image and save it: a promise of { url, mode, warning }.\n\t\tgenerateImage: function ( ctx, prompt, img ) {\n\t\t\treturn api( 'generate-image', {\n\t\t\t\tcollection: ctx.collection,\n\t\t\t\tid: ctx.id,\n\t\t\t\tfield: ctx.field,\n\t\t\t\tprompt: prompt,\n\t\t\t\toldUrl: img ? ( img.currentSrc || img.src || '' ) : '',\n\t\t\t\talt: img ? ( img.getAttribute( 'alt' ) || '' ) : '',\n\t\t\t\tsize: '1024x1024',\n\t\t\t}, platform.imageTimeout );\n\t\t},\n\t\t// Whether the image's alt text can be edited, and saving it (a promise).\n\t\thasAlt: function () {\n\t\t\treturn true;\n\t\t},\n\t\tsaveAlt: null,\n\t\t// The media library's list: a promise of the fetch() response.\n\t\tlistMedia: function () {\n\t\t\treturn fetch( MEDIA + '?limit=24&depth=0', { credentials: 'include' } );\n\t\t},\n\n\t\t// What the page panel edits: an object handed to the hooks below, or\n\t\t// null when this page has no title and description to edit.\n\t\tpageTarget: null,\n\t\t// The page's title and description as saved: { title, description } and,\n\t\t// where an empty field falls back to something, titleDefault and\n\t\t// descriptionDefault; titleEditable false when the title can't be changed.\n\t\treadPage: function () {\n\t\t\treturn { title: document.title || '', description: currentDescription() };\n\t\t},\n\t\tpageHint: function ( target ) {\n\t\t\treturn 'Gjelder bare denne siden (' + target.page + '). ' + PAGE_GUIDE;\n\t\t},\n\t\t// Why AI can't be asked here now, or ''.\n\t\taiUnavailable: function () {\n\t\t\treturn '';\n\t\t},\n\t\t// Before the page's two AI suggestions go out together.\n\t\taiReady: function () {},\n\t\t// One AI suggestion for the title or the description: a promise of { suggestion }.\n\t\tsuggestMeta: null,\n\t\t// Save what changed, { title?, description? } ('' = back to the original): a promise.\n\t\tsavePage: null,\n\t\tpageEmptied: 'Lagret ✓ – last siden på nytt for å se originalen',\n\t};\n\tfor ( var hook in HOOKS ) {\n\t\tif ( Object.prototype.hasOwnProperty.call( HOOKS, hook ) && ! Object.prototype.hasOwnProperty.call( platform, hook ) ) {\n\t\t\tplatform[ hook ] = HOOKS[ hook ];\n\t\t}\n\t}\n\n\tfunction init() {\n\t\tbubble = document.createElement( 'div' );\n\t\tbubble.id = 'wpve-bubble';\n\t\tbubble.title = 'Vibe Editor';\n\t\tbubble.setAttribute( 'role', 'switch' );\n\t\tbubble.setAttribute( 'aria-checked', 'false' );\n\t\tbubble.setAttribute( 'aria-label', LABEL_IDLE );\n\t\tbubble.innerHTML =\n\t\t\t'<span class=\"wpve-knob\">' +\n\t\t\t'<svg class=\"wpve-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\t'<path fill=\"currentColor\" d=\"M12 3 L13.6 10.4 L21 12 L13.6 13.6 L12 21 L10.4 13.6 L3 12 L10.4 10.4 Z\"/>' +\n\t\t\t'<path fill=\"currentColor\" opacity=\"0.8\" d=\"M6 15.5 L6.6 17.4 L8.5 18 L6.6 18.6 L6 20.5 L5.4 18.6 L3.5 18 L5.4 17.4 Z\"/>' +\n\t\t\t'</svg>' +\n\t\t\t'</span>';\n\t\tdocument.body.appendChild( bubble );\n\t\tbubble.addEventListener( 'click', toggleEditMode );\n\n\t\tif ( SHOW_SETTINGS ) {\n\t\t\tsettingsBtn = createSettingsButton();\n\t\t}\n\t\tif ( platform.pageAtStart ) {\n\t\t\tpageBtn = createPageButton();\n\t\t}\n\n\t\tapplyLayout();\n\t\tplatform.start();\n\t}\n\n\t/* ---------- Størrelse + posisjon ---------- */\n\n\t/**\n\t * Bobla, tannhjul-knappen og innstillingspanelet forankres til samme hjørne.\n\t * @return {{hSide:string,hVal:number,vSide:string,vVal:number}}\n\t */\n\tfunction currentAnchor() {\n\t\tif ( settings.position === 'custom' ) {\n\t\t\treturn {\n\t\t\t\thSide: settings.hSide === 'left' ? 'left' : 'right',\n\t\t\t\thVal: settings.hOffset,\n\t\t\t\tvSide: settings.vSide === 'top' ? 'top' : 'bottom',\n\t\t\t\tvVal: settings.vOffset,\n\t\t\t};\n\t\t}\n\t\tvar map = {\n\t\t\t'bottom-right': { hSide: 'right', vSide: 'bottom' },\n\t\t\t'bottom-left': { hSide: 'left', vSide: 'bottom' },\n\t\t\t'top-right': { hSide: 'right', vSide: 'top' },\n\t\t\t'top-left': { hSide: 'left', vSide: 'top' },\n\t\t};\n\t\tvar m = map[ settings.position ] || map[ 'bottom-right' ];\n\t\treturn { hSide: m.hSide, hVal: 24, vSide: m.vSide, vVal: 24 };\n\t}\n\n\tfunction anchorEl( el, hSide, hVal, vSide, vVal ) {\n\t\tif ( ! el ) {\n\t\t\treturn;\n\t\t}\n\t\tvar hOpp = hSide === 'left' ? 'right' : 'left';\n\t\tvar vOpp = vSide === 'top' ? 'bottom' : 'top';\n\t\tel.style[ hSide ] = hVal + 'px';\n\t\tel.style[ hOpp ] = 'auto';\n\t\tel.style[ vSide ] = vVal + 'px';\n\t\tel.style[ vOpp ] = 'auto';\n\t}\n\n\tfunction applyLayout() {\n\t\tif ( ! bubble ) {\n\t\t\treturn;\n\t\t}\n\t\tbubble.classList.remove( 'wpve-size-small', 'wpve-size-medium', 'wpve-size-large' );\n\t\tbubble.classList.add( 'wpve-size-' + settings.size );\n\n\t\tvar a = currentAnchor();\n\t\tanchorEl( bubble, a.hSide, a.hVal, a.vSide, a.vVal );\n\t\tbubble.style.transformOrigin = a.vSide + ' ' + a.hSide;\n\n\t\t// De små knappene (36 px) står over bryteren (76 × 40 px), ved kanten.\n\t\tvar over = a.vVal + Math.round( 40 * bubbleScale() ) + 12;\n\t\tanchorEl( settingsBtn, a.hSide, a.hVal + 2, a.vSide, over );\n\t\tanchorEl( pageBtn, a.hSide, a.hVal + 2, a.vSide, over + ( settingsBtn ? 44 : 0 ) );\n\t}\n\n\tfunction toggleEditMode() {\n\t\teditMode = ! editMode;\n\t\tdocument.body.classList.toggle( 'wpve-edit-mode', editMode );\n\t\tbubble.classList.toggle( 'wpve-active', editMode );\n\t\tbubble.setAttribute( 'aria-checked', editMode ? 'true' : 'false' );\n\t\tbubble.setAttribute( 'aria-label', editMode ? LABEL_ACTIVE : LABEL_IDLE );\n\n\t\tapplyFrame();\n\t\tapplyHighlight();\n\n\t\tif ( settingsBtn ) {\n\t\t\tsettingsBtn.style.display = editMode ? 'flex' : 'none';\n\t\t}\n\t\tshowPageButton();\n\n\t\tif ( editMode ) {\n\t\t\tdocument.addEventListener( 'mouseover', onHover, true );\n\t\t\tdocument.addEventListener( 'click', onPick, true );\n\t\t\ttoast( 'Redigeringsmodus på – klikk på en tekst eller et bilde.' );\n\t\t} else {\n\t\t\tdocument.removeEventListener( 'mouseover', onHover, true );\n\t\t\tdocument.removeEventListener( 'click', onPick, true );\n\t\t\tclearHover();\n\t\t\tclosePanel();\n\t\t\tcloseSettings();\n\t\t}\n\t}\n\n\t/* ---------- Innstillinger ---------- */\n\n\tfunction oneOf( value, allowed, fallback ) {\n\t\treturn allowed.indexOf( value ) !== -1 ? value : fallback;\n\t}\n\n\tfunction numOr( value, fallback ) {\n\t\tvar n = parseInt( value, 10 );\n\t\treturn isNaN( n ) || n < 0 ? fallback : Math.min( n, 4000 );\n\t}\n\n\tfunction loadSettings() {\n\t\tvar def = {\n\t\t\tframe: CFG.frame !== false,\n\t\t\thighlight: false,\n\t\t\tsize: oneOf( CFG.size, SIZES, 'medium' ),\n\t\t\tposition: oneOf( CFG.position, POSITIONS, 'bottom-right' ),\n\t\t\thSide: CFG.hSide === 'left' ? 'left' : 'right',\n\t\t\thOffset: numOr( CFG.hOffset, 24 ),\n\t\t\tvSide: CFG.vSide === 'top' ? 'top' : 'bottom',\n\t\t\tvOffset: numOr( CFG.vOffset, 24 ),\n\t\t};\n\t\ttry {\n\t\t\tvar raw = window.localStorage.getItem( SETTINGS_KEY );\n\t\t\tif ( raw ) {\n\t\t\t\tvar p = JSON.parse( raw );\n\t\t\t\treturn {\n\t\t\t\t\tframe: typeof p.frame === 'boolean' ? p.frame : def.frame,\n\t\t\t\t\thighlight: typeof p.highlight === 'boolean' ? p.highlight : def.highlight,\n\t\t\t\t\tsize: oneOf( p.size, SIZES, def.size ),\n\t\t\t\t\tposition: oneOf( p.position, POSITIONS, def.position ),\n\t\t\t\t\thSide: p.hSide === 'left' ? 'left' : ( p.hSide === 'right' ? 'right' : def.hSide ),\n\t\t\t\t\thOffset: numOr( p.hOffset, def.hOffset ),\n\t\t\t\t\tvSide: p.vSide === 'top' ? 'top' : ( p.vSide === 'bottom' ? 'bottom' : def.vSide ),\n\t\t\t\t\tvOffset: numOr( p.vOffset, def.vOffset ),\n\t\t\t\t};\n\t\t\t}\n\t\t} catch ( e ) {\n\t\t\t/* localStorage utilgjengelig – bruk standard. */\n\t\t}\n\t\treturn def;\n\t}\n\n\tfunction saveSettings() {\n\t\ttry {\n\t\t\twindow.localStorage.setItem( SETTINGS_KEY, JSON.stringify( settings ) );\n\t\t} catch ( e ) {\n\t\t\t/* ignorer – innstillingene gjelder fortsatt for denne økten. */\n\t\t}\n\t}\n\n\tfunction applyFrame() {\n\t\tdocument.body.classList.remove( 'wpve-frame-active' );\n\t\tif ( editMode && settings.frame ) {\n\t\t\tvoid document.body.offsetWidth;\n\t\t\tdocument.body.classList.add( 'wpve-frame-active' );\n\t\t}\n\t}\n\n\tfunction applyHighlight() {\n\t\tvar on = editMode && settings.highlight;\n\t\tdocument.body.classList.toggle( 'wpve-show-fields', on );\n\t\tplatform.highlight( on );\n\t}\n\n\tfunction createSettingsButton() {\n\t\tvar btn = document.createElement( 'button' );\n\t\tbtn.id = 'wpve-settings-btn';\n\t\tbtn.type = 'button';\n\t\tbtn.title = 'Innstillinger';\n\t\tbtn.style.display = 'none';\n\t\tbtn.innerHTML =\n\t\t\t'<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\t'<path fill=\"currentColor\" d=\"M19.14 12.94c.04-.31.06-.62.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.61-.22l-2.39.96a7.3 7.3 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.42h-3.84a.5.5 0 0 0-.5.42l-.36 2.54c-.58.24-1.12.56-1.62.94l-2.39-.96a.5.5 0 0 0-.61.22L2.74 8.84a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.62-.06.94s.02.63.06.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .61.22l2.39-.96c.5.38 1.04.7 1.62.94l.36 2.54a.5.5 0 0 0 .5.42h3.84a.5.5 0 0 0 .5-.42l.36-2.54c.58-.24 1.12-.56 1.62-.94l2.39.96a.5.5 0 0 0 .61-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58ZM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z\"/>' +\n\t\t\t'</svg>';\n\t\tdocument.body.appendChild( btn );\n\t\tbtn.addEventListener( 'click', toggleSettings );\n\t\treturn btn;\n\t}\n\n\tfunction toggleSettings() {\n\t\tif ( settingsPanel ) {\n\t\t\tcloseSettings();\n\t\t} else {\n\t\t\topenSettings();\n\t\t}\n\t}\n\n\tfunction openSettings() {\n\t\tclosePanel();\n\t\tcloseSettings();\n\t\tsettingsPanel = document.createElement( 'div' );\n\t\tsettingsPanel.className = 'wpve-panel wpve-settings';\n\t\tsettingsPanel.innerHTML =\n\t\t\t'<div class=\"wpve-panel-head\">Innstillinger<span class=\"wpve-close\">×</span></div>' +\n\t\t\t'<label class=\"wpve-setting\"><input type=\"checkbox\" class=\"wpve-set-frame\"><span>Redigeringsramme</span></label>' +\n\t\t\t'<label class=\"wpve-setting\"><input type=\"checkbox\" class=\"wpve-set-highlight\"><span>Marker redigerbare felt</span></label>' +\n\t\t\t'<label class=\"wpve-setting wpve-setting-select\"><span>Størrelse</span>' +\n\t\t\t'<select class=\"wpve-set-size\">' +\n\t\t\t'<option value=\"small\">Liten</option>' +\n\t\t\t'<option value=\"medium\">Medium</option>' +\n\t\t\t'<option value=\"large\">Stor</option>' +\n\t\t\t'</select></label>' +\n\t\t\t'<label class=\"wpve-setting wpve-setting-select\"><span>Posisjon</span>' +\n\t\t\t'<select class=\"wpve-set-position\">' +\n\t\t\t'<option value=\"bottom-right\">Nede til høyre</option>' +\n\t\t\t'<option value=\"bottom-left\">Nede til venstre</option>' +\n\t\t\t'<option value=\"top-right\">Oppe til høyre</option>' +\n\t\t\t'<option value=\"top-left\">Oppe til venstre</option>' +\n\t\t\t'<option value=\"custom\">Egendefinert …</option>' +\n\t\t\t'</select></label>' +\n\t\t\t'<div class=\"wpve-custom-pos\">' +\n\t\t\t'<div class=\"wpve-pos-row\"><span>Vannrett</span>' +\n\t\t\t'<select class=\"wpve-set-hside\"><option value=\"right\">Fra høyre</option><option value=\"left\">Fra venstre</option></select>' +\n\t\t\t'<input type=\"number\" class=\"wpve-set-hoffset\" min=\"0\" max=\"4000\"><em>px</em></div>' +\n\t\t\t'<div class=\"wpve-pos-row\"><span>Loddrett</span>' +\n\t\t\t'<select class=\"wpve-set-vside\"><option value=\"bottom\">Fra bunnen</option><option value=\"top\">Fra toppen</option></select>' +\n\t\t\t'<input type=\"number\" class=\"wpve-set-voffset\" min=\"0\" max=\"4000\"><em>px</em></div>' +\n\t\t\t'</div>' +\n\t\t\tplatform.settingsHtml();\n\t\tdocument.body.appendChild( settingsPanel );\n\n\t\t// Panelet står ved siden av de små knappene, over bryteren.\n\t\tvar a = currentAnchor();\n\t\tanchorEl( settingsPanel, a.hSide, a.hVal + 50, a.vSide, a.vVal + 40 * bubbleScale() + 12 );\n\n\t\tvar frameBox = settingsPanel.querySelector( '.wpve-set-frame' );\n\t\tvar hlBox = settingsPanel.querySelector( '.wpve-set-highlight' );\n\t\tvar sizeSel = settingsPanel.querySelector( '.wpve-set-size' );\n\t\tvar posSel = settingsPanel.querySelector( '.wpve-set-position' );\n\t\tvar customBox = settingsPanel.querySelector( '.wpve-custom-pos' );\n\t\tvar hSideSel = settingsPanel.querySelector( '.wpve-set-hside' );\n\t\tvar hOffIn = settingsPanel.querySelector( '.wpve-set-hoffset' );\n\t\tvar vSideSel = settingsPanel.querySelector( '.wpve-set-vside' );\n\t\tvar vOffIn = settingsPanel.querySelector( '.wpve-set-voffset' );\n\n\t\tframeBox.checked = settings.frame;\n\t\thlBox.checked = settings.highlight;\n\t\tsizeSel.value = settings.size;\n\t\tposSel.value = settings.position;\n\t\thSideSel.value = settings.hSide;\n\t\thOffIn.value = settings.hOffset;\n\t\tvSideSel.value = settings.vSide;\n\t\tvOffIn.value = settings.vOffset;\n\t\tcustomBox.style.display = settings.position === 'custom' ? 'block' : 'none';\n\n\t\tsettingsPanel.querySelector( '.wpve-close' ).addEventListener( 'click', closeSettings );\n\t\tframeBox.addEventListener( 'change', function () {\n\t\t\tsettings.frame = frameBox.checked;\n\t\t\tsaveSettings();\n\t\t\tapplyFrame();\n\t\t} );\n\t\thlBox.addEventListener( 'change', function () {\n\t\t\tsettings.highlight = hlBox.checked;\n\t\t\tsaveSettings();\n\t\t\tapplyHighlight();\n\t\t} );\n\n\t\tfunction relayout() {\n\t\t\tsaveSettings();\n\t\t\tapplyLayout();\n\t\t\t// Hold panelet ved boblas (nye) hjørne mens man justerer.\n\t\t\tvar na = currentAnchor();\n\t\t\tsettingsPanel.style.left = 'auto';\n\t\t\tsettingsPanel.style.right = 'auto';\n\t\t\tsettingsPanel.style.top = 'auto';\n\t\t\tsettingsPanel.style.bottom = 'auto';\n\t\t\tanchorEl( settingsPanel, na.hSide, na.hVal + 50, na.vSide, na.vVal + 40 * bubbleScale() + 12 );\n\t\t}\n\n\t\tsizeSel.addEventListener( 'change', function () {\n\t\t\tsettings.size = oneOf( sizeSel.value, SIZES, settings.size );\n\t\t\trelayout();\n\t\t} );\n\t\tposSel.addEventListener( 'change', function () {\n\t\t\tsettings.position = oneOf( posSel.value, POSITIONS, settings.position );\n\t\t\tcustomBox.style.display = settings.position === 'custom' ? 'block' : 'none';\n\t\t\trelayout();\n\t\t} );\n\t\thSideSel.addEventListener( 'change', function () {\n\t\t\tsettings.hSide = hSideSel.value === 'left' ? 'left' : 'right';\n\t\t\trelayout();\n\t\t} );\n\t\tvSideSel.addEventListener( 'change', function () {\n\t\t\tsettings.vSide = vSideSel.value === 'top' ? 'top' : 'bottom';\n\t\t\trelayout();\n\t\t} );\n\t\thOffIn.addEventListener( 'input', function () {\n\t\t\tsettings.hOffset = numOr( hOffIn.value, 24 );\n\t\t\trelayout();\n\t\t} );\n\t\tvOffIn.addEventListener( 'input', function () {\n\t\t\tsettings.vOffset = numOr( vOffIn.value, 24 );\n\t\t\trelayout();\n\t\t} );\n\n\t\tplatform.settingsOpened();\n\t}\n\n\tfunction closeSettings() {\n\t\tif ( settingsPanel ) {\n\t\t\tsettingsPanel.remove();\n\t\t\tsettingsPanel = null;\n\t\t}\n\t}\n\n\t/* ---------- Kontoen AI-en bruker ---------- */\n\n\t// How many credits are left, as the end of a sentence, when the account has said.\n\tfunction creditsLeft() {\n\t\treturn account && 'number' === typeof account.balance\n\t\t\t? ' ' + ( 1 === account.balance ? '1 kreditt' : account.balance + ' kreditter' ) + ' igjen.'\n\t\t\t: '';\n\t}\n\n\t// What an AI picture costs, when the account has said.\n\tfunction imageCost() {\n\t\tvar c = account && account.costs;\n\t\tvar gen = c ? parseInt( c.image, 10 ) : 0;\n\t\tvar edit = c ? parseInt( c.imageEdit, 10 ) || gen : 0;\n\t\tif ( ! gen ) {\n\t\t\treturn '';\n\t\t}\n\t\tvar low = Math.min( gen, edit );\n\t\tvar high = Math.max( gen, edit );\n\t\treturn 'Et AI-bilde koster ' + ( low === high ? low : low + '–' + high ) + ' kreditter.';\n\t}\n\n\t// An error in a panel, with a link to buy more credits when they ran out.\n\tfunction showError( box, err ) {\n\t\tbox.innerHTML = '<div class=\"wpve-error\"></div>';\n\t\tvar el = box.firstChild;\n\t\tel.textContent = String( err );\n\t\tvar url = err && err.topupUrl;\n\t\tif ( url && /^https?:\\/\\//.test( url ) ) {\n\t\t\tvar a = document.createElement( 'a' );\n\t\t\ta.href = url;\n\t\t\ta.target = '_blank';\n\t\t\ta.rel = 'noopener';\n\t\t\ta.textContent = 'Kjøp flere kreditter';\n\t\t\tel.appendChild( document.createTextNode( ' ' ) );\n\t\t\tel.appendChild( a );\n\t\t}\n\t}\n\n\t/* ---------- Element-utvelgelse ---------- */\n\n\tfunction isOwnUi( el ) {\n\t\treturn el.closest( '#wpve-bubble' ) || el.closest( '#wpve-settings-btn' ) || el.closest( '#wpve-page-btn' ) ||\n\t\t\tel.closest( '.wpve-panel' ) || el.closest( '.wpve-toast' );\n\t}\n\n\t/**\n\t * Finn det redigerbare elementet og dets collection/id. Eksplisitt\n\t * data-vibe-field vinner alltid (stabil, forfatter-valgt nøkkel), med\n\t * collection/id på elementet selv eller en stamfar; ellers det plattformen\n\t * selv finner (platform.findAuto).\n\t */\n\tfunction editableFrom( el ) {\n\t\tif ( ! el || isOwnUi( el ) ) {\n\t\t\treturn null;\n\t\t}\n\t\tvar fieldEl = el.closest( '[data-vibe-field]' );\n\t\tif ( fieldEl && ! isOwnUi( fieldEl ) ) {\n\t\t\tvar box = fieldEl.closest( '[data-vibe-collection][data-vibe-id]' );\n\t\t\tvar collection = fieldEl.getAttribute( 'data-vibe-collection' ) || ( box && box.getAttribute( 'data-vibe-collection' ) );\n\t\t\tvar id = fieldEl.getAttribute( 'data-vibe-id' ) || ( box && box.getAttribute( 'data-vibe-id' ) );\n\t\t\tif ( ! collection || ! id ) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tvar ctx = {\n\t\t\t\tel: fieldEl,\n\t\t\t\tcollection: collection,\n\t\t\t\tid: id,\n\t\t\t\tfield: fieldEl.getAttribute( 'data-vibe-field' ),\n\t\t\t};\n\t\t\tplatform.fieldContext( ctx, fieldEl );\n\t\t\treturn ctx;\n\t\t}\n\t\treturn platform.findAuto( el );\n\t}\n\n\tfunction onHover( e ) {\n\t\tvar ctx = editableFrom( e.target );\n\t\tvar el = ctx ? ctx.el : null;\n\t\tif ( ! el || el === hovered ) {\n\t\t\treturn;\n\t\t}\n\t\tclearHover();\n\t\thovered = el;\n\t\thovered.classList.add( 'wpve-hover' );\n\t}\n\n\tfunction clearHover() {\n\t\tif ( hovered ) {\n\t\t\thovered.classList.remove( 'wpve-hover' );\n\t\t\thovered = null;\n\t\t}\n\t}\n\n\tfunction onPick( e ) {\n\t\tvar ctx = editableFrom( e.target );\n\t\tif ( ! ctx ) {\n\t\t\t// Et klikk utenfor lukker panelet, så lenge ingenting er skrevet i det.\n\t\t\tif ( panel && ! panel.hasAttribute( 'data-dirty' ) && ! isOwnUi( e.target ) ) {\n\t\t\t\tclosePanel();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\te.preventDefault();\n\t\te.stopPropagation();\n\t\tclickPos = { x: e.clientX, y: e.clientY };\n\n\t\tvar isImage = ctx.el.tagName === 'IMG' || !! ctx.el.querySelector( 'img' );\n\t\tif ( isImage ) {\n\t\t\teditImage( ctx );\n\t\t} else {\n\t\t\teditText( ctx );\n\t\t}\n\t}\n\n\t/* ---------- Tekst-redigering ---------- */\n\n\tfunction editText( ctx ) {\n\t\tplatform.textOpened( ctx );\n\t\tvar original = ctx.el.textContent.trim();\n\t\tctx.original = original;\n\t\topenPanel();\n\n\t\tpanel.classList.add( 'wpve-cmd' );\n\t\tpanel.innerHTML = textBarHtml();\n\t\tmarkSelected( ctx.el );\n\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\n\t\tvar promptEl = panel.querySelector( '.wpve-prompt' );\n\t\tvar toggle = panel.querySelector( '.ai-toggle' );\n\t\tvar suggestBtn = panel.querySelector( '.wpve-suggest' );\n\n\t\t// Egne tekstbuffere per modus, så man ikke mister det man har skrevet ved bytte.\n\t\t// Linjeskift og innrykk fra HTML-kilden vises ikke på siden, så de skal ikke inn i teksten man redigerer.\n\t\tvar buffers = { ai: '', manual: original.replace( /\\s*\\n\\s*/g, ' ' ) };\n\n\t\tfunction applyMode( mode ) {\n\t\t\ttoggle.dataset.mode = mode;\n\t\t\tpromptEl.value = buffers[ mode ];\n\t\t\tif ( mode === 'ai' ) {\n\t\t\t\tpromptEl.placeholder = 'Hva vil du endre?';\n\t\t\t\tsuggestBtn.textContent = 'Foreslå endring';\n\t\t\t} else {\n\t\t\t\tpromptEl.placeholder = 'Skriv inn teksten som skal vises';\n\t\t\t\tsuggestBtn.textContent = 'Lagre tekst';\n\t\t\t}\n\t\t\tpanel.querySelector( '.wpve-result' ).innerHTML = '';\n\t\t\tgrowPrompt( promptEl );\n\t\t\tpositionPanel();\n\t\t\tpromptEl.focus();\n\t\t}\n\n\t\ttoggle.addEventListener( 'click', function () {\n\t\t\tbuffers[ toggle.dataset.mode ] = promptEl.value;\n\t\t\tvar mode = toggle.dataset.mode === 'ai' ? 'manual' : 'ai';\n\t\t\t// Den som skriver selv, vil gjerne gjøre det neste gang også.\n\t\t\ttry { localStorage.setItem( 'vibe_text_mode', mode ); } catch ( e ) {}\n\t\t\tapplyMode( mode );\n\t\t} );\n\n\t\tsuggestBtn.addEventListener( 'click', function () {\n\t\t\tvar value = promptEl.value.trim();\n\t\t\tif ( ! value ) {\n\t\t\t\tpromptEl.focus();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif ( toggle.dataset.mode === 'manual' ) {\n\t\t\t\tsaveManualText( ctx, value );\n\t\t\t} else {\n\t\t\t\trequestSuggestion( ctx, original, value );\n\t\t\t}\n\t\t} );\n\n\t\t// AI er på som standard, med mindre man sist skrev selv.\n\t\tvar lastMode = 'ai';\n\t\ttry { lastMode = localStorage.getItem( 'vibe_text_mode' ) === 'manual' ? 'manual' : 'ai'; } catch ( e ) {}\n\t\tapplyMode( lastMode );\n\t}\n\n\t// Lagrer brukerens egen tekst direkte, uten AI-forslag (manuell modus). Koster ingen kreditter.\n\tfunction saveManualText( ctx, newText ) {\n\t\tvar resultBox = panel.querySelector( '.wpve-result' );\n\t\tvar btn = panel.querySelector( '.wpve-suggest' );\n\t\tbtn.disabled = true;\n\t\tbtn.textContent = 'Lagrer …';\n\t\tresultBox.innerHTML = '';\n\n\t\tpersistText( ctx, newText ).catch( function ( err ) {\n\t\t\tbtn.disabled = false;\n\t\t\tbtn.textContent = 'Lagre tekst';\n\t\t\tshowError( resultBox, err );\n\t\t} );\n\t}\n\n\t// Felles lagring – brukes av både AI-forslag og manuell tekst.\n\tfunction persistText( ctx, newText ) {\n\t\treturn api( 'save', platform.savePayload( ctx, newText ) ).then( function ( data ) {\n\t\t\tplatform.showText( ctx, newText, data );\n\t\t\tclosePanel();\n\t\t\ttoast( ( data && ( data.warning || data.message ) ) || 'Lagret ✓' );\n\t\t} );\n\t}\n\n\tfunction requestSuggestion( ctx, original, prompt ) {\n\t\tvar resultBox = panel.querySelector( '.wpve-result' );\n\t\tvar btn = panel.querySelector( '.wpve-suggest' );\n\t\tbtn.disabled = true;\n\t\tresultBox.innerHTML = '<div class=\"wpve-loading\">Skriver forslag …</div>';\n\n\t\tplatform.suggestText( ctx, original, prompt )\n\t\t\t.then( function ( data ) {\n\t\t\t\tbtn.disabled = false;\n\t\t\t\trenderSuggestion( resultBox, ctx, original, String( data.suggestion || '' ).trim() );\n\t\t\t\tplatform.aiUsed();\n\t\t\t} )\n\t\t\t.catch( function ( err ) {\n\t\t\t\tbtn.disabled = false;\n\t\t\t\tshowError( resultBox, err );\n\t\t\t} );\n\t}\n\n\tfunction renderSuggestion( box, ctx, original, suggestion ) {\n\t\tbox.innerHTML =\n\t\t\t'<div class=\"wpve-diff-label\">Forslag:</div>' +\n\t\t\t'<div class=\"wpve-suggestion\"></div>' +\n\t\t\t'<div class=\"wpve-actions\">' +\n\t\t\t'<button class=\"wpve-btn wpve-approve\">Godkjenn og lagre</button>' +\n\t\t\t'<button class=\"wpve-btn wpve-cancel-link\">Avbryt</button>' +\n\t\t\t'</div>';\n\t\tbox.querySelector( '.wpve-suggestion' ).textContent = suggestion;\n\t\tbox.querySelector( '.wpve-cancel-link' ).addEventListener( 'click', closePanel );\n\t\tpositionPanel();\n\n\t\tbox.querySelector( '.wpve-approve' ).addEventListener( 'click', function () {\n\t\t\tvar approveBtn = box.querySelector( '.wpve-approve' );\n\t\t\tapproveBtn.disabled = true;\n\t\t\tapproveBtn.textContent = 'Lagrer …';\n\n\t\t\tpersistText( ctx, suggestion ).catch( function ( err ) {\n\t\t\t\tapproveBtn.disabled = false;\n\t\t\t\tapproveBtn.textContent = 'Godkjenn og lagre';\n\t\t\t\tbox.querySelector( '.wpve-suggestion' ).insertAdjacentHTML(\n\t\t\t\t\t'afterend',\n\t\t\t\t\t'<div class=\"wpve-error\"></div>'\n\t\t\t\t);\n\t\t\t\tbox.querySelector( '.wpve-error' ).textContent = err;\n\t\t\t} );\n\t\t} );\n\t}\n\n\t/* ---------- Bilde-redigering ---------- */\n\n\tfunction imageOf( ctx ) {\n\t\treturn ctx.el.tagName === 'IMG' ? ctx.el : ctx.el.querySelector( 'img' );\n\t}\n\n\tfunction editImage( ctx ) {\n\t\tvar imgEl = imageOf( ctx );\n\t\tvar canGen = !! platform.canGenerate();\n\t\tvar isIcon = !! platform.isIcon( ctx );\n\t\tvar hint = platform.imageHint( canGen ) || '';\n\t\tvar withAlt = !! platform.hasAlt( ctx, imgEl );\n\n\t\topenPanel();\n\t\tpanel.classList.add( 'wpve-cmd' );\n\t\tpanel.innerHTML =\n\t\t\t'<div class=\"wpve-bar\">' +\n\t\t\t\t'<span class=\"wpve-bar-icon\">' + icon( 'image' ) + '</span>' +\n\t\t\t\t'<textarea class=\"wpve-prompt wpve-img-prompt\" rows=\"1\" placeholder=\"' + ( isIcon ? 'Beskriv ikonet du vil ha' : 'Beskriv bildet du vil ha' ) + '\"' + ( canGen ? '' : ' disabled' ) + '></textarea>' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-send wpve-gen\"' + ( canGen ? '' : ' disabled' ) + '>' + ( isIcon ? 'Lag ikon' : 'Generer bilde' ) + '</button>' +\n\t\t\t'</div>' +\n\t\t\t// What a picture costs, or why none can be made; a platform may fill it in later.\n\t\t\t'<div class=\"wpve-hint wpve-cost\"' + ( hint ? '' : ' style=\"display:none\"' ) + '>' + escapeHtml( hint ) + '</div>' +\n\t\t\t'<details class=\"wpve-more\"' + ( canGen ? '' : ' open' ) + '><summary>Flere valg</summary><div class=\"wpve-more-body\">' +\n\t\t\t\t'<div class=\"wpve-field-label\">Lim inn bilde-URL</div>' +\n\t\t\t\t'<div class=\"wpve-url-row\">' +\n\t\t\t\t\t'<input type=\"url\" class=\"wpve-url-input\" placeholder=\"https://…/bilde.jpg\" />' +\n\t\t\t\t\t'<button type=\"button\" class=\"wpve-url-btn\">Bruk</button>' +\n\t\t\t\t'</div>' +\n\t\t\t\t( withAlt ?\n\t\t\t\t\t'<div class=\"wpve-field-label\">Alt-tekst</div>' +\n\t\t\t\t\t'<div class=\"wpve-url-row\">' +\n\t\t\t\t\t\t'<input type=\"text\" class=\"wpve-url-input wpve-alt-input\" maxlength=\"300\" placeholder=\"Hva viser bildet? Leses av skjermlesere og søkemotorer\" />' +\n\t\t\t\t\t\t'<button type=\"button\" class=\"wpve-url-btn wpve-alt-btn\">Lagre</button>' +\n\t\t\t\t\t'</div>' : '' ) +\n\t\t\t\t'<div class=\"wpve-field-label\">Velg fra mediebiblioteket</div>' +\n\t\t\t\t'<div class=\"wpve-result\"><div class=\"wpve-loading\">Laster bilder …</div></div>' +\n\t\t\t'</div></details>' +\n\t\t\t'<button type=\"button\" class=\"wpve-close wpve-close-link\">Lukk</button>' +\n\t\t\t'<div class=\"wpve-status\"></div>';\n\t\tmarkSelected( imgEl || ctx.el );\n\t\tplatform.imageOpened( canGen );\n\t\tpanel.querySelector( '.wpve-more' ).addEventListener( 'toggle', positionPanel );\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\n\t\t// AI generation: builds on the current image when relevant (the service\n\t\t// decides), otherwise makes a brand-new one.\n\t\tif ( canGen ) {\n\t\t\tvar genBtn = panel.querySelector( '.wpve-gen' );\n\t\t\tvar genPrompt = panel.querySelector( '.wpve-img-prompt' );\n\t\t\tgenPrompt.focus();\n\t\t\tgenBtn.addEventListener( 'click', function () {\n\t\t\t\tvar p = ( genPrompt.value || '' ).trim();\n\t\t\t\tif ( ! p ) {\n\t\t\t\t\tgenPrompt.focus();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tvar resultBox = panel.querySelector( '.wpve-status' );\n\t\t\t\tgenBtn.disabled = true;\n\t\t\t\tresultBox.innerHTML = '<div class=\"wpve-loading\">' + escapeHtml( isIcon ? 'Lager ikon …' : platform.imageWait ) + '</div>';\n\t\t\t\tvar loader = imgEl ? showImageLoader( imgEl ) : null;\n\n\t\t\t\tplatform.generateImage( ctx, p, imgEl )\n\t\t\t\t\t.then( function ( data ) {\n\t\t\t\t\t\tif ( loader ) {\n\t\t\t\t\t\t\tloader.remove();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tapplyNew( ctx, data && data.url );\n\t\t\t\t\t\tclosePanel();\n\t\t\t\t\t\ttoast( ( data && data.warning ) || imageDone( data, isIcon ) );\n\t\t\t\t\t\tplatform.aiUsed();\n\t\t\t\t\t} )\n\t\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\t\tif ( loader ) {\n\t\t\t\t\t\t\tloader.remove();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tgenBtn.disabled = false;\n\t\t\t\t\t\tshowError( resultBox, err );\n\t\t\t\t\t} );\n\t\t\t} );\n\t\t}\n\n\t\tvar urlInput = panel.querySelector( '.wpve-url-input' );\n\t\tfunction useUrl() {\n\t\t\tvar url = ( urlInput.value || '' ).trim();\n\t\t\tif ( ! url ) {\n\t\t\t\turlInput.focus();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tswapImage( ctx, url, url );\n\t\t}\n\t\tpanel.querySelector( '.wpve-url-btn' ).addEventListener( 'click', useUrl );\n\t\turlInput.addEventListener( 'keydown', function ( e ) {\n\t\t\tif ( 'Enter' === e.key ) {\n\t\t\t\te.preventDefault();\n\t\t\t\tuseUrl();\n\t\t\t}\n\t\t} );\n\n\t\tif ( withAlt ) {\n\t\t\tvar altInput = panel.querySelector( '.wpve-alt-input' );\n\t\t\tvar altBtn = panel.querySelector( '.wpve-alt-btn' );\n\t\t\taltInput.value = imgEl ? ( imgEl.getAttribute( 'alt' ) || '' ) : '';\n\t\t\tvar saveAlt = function () {\n\t\t\t\tvar text = altInput.value.trim();\n\t\t\t\taltBtn.disabled = true;\n\t\t\t\tplatform.saveAlt( ctx, text, imgEl )\n\t\t\t\t\t.then( function ( data ) {\n\t\t\t\t\t\tif ( imgEl ) {\n\t\t\t\t\t\t\timgEl.setAttribute( 'alt', text );\n\t\t\t\t\t\t}\n\t\t\t\t\t\taltBtn.disabled = false;\n\t\t\t\t\t\ttoast( ( data && data.warning ) || 'Alt-tekst lagret ✓' );\n\t\t\t\t\t} )\n\t\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\t\taltBtn.disabled = false;\n\t\t\t\t\t\ttoast( err );\n\t\t\t\t\t} );\n\t\t\t};\n\t\t\taltBtn.addEventListener( 'click', saveAlt );\n\t\t\taltInput.addEventListener( 'keydown', function ( e ) {\n\t\t\t\tif ( 'Enter' === e.key ) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tsaveAlt();\n\t\t\t\t}\n\t\t\t} );\n\t\t}\n\n\t\tpositionPanel();\n\n\t\tplatform.listMedia()\n\t\t\t.then( function ( res ) {\n\t\t\t\treturn res.json();\n\t\t\t} )\n\t\t\t.then( function ( data ) {\n\t\t\t\trenderImagePicker( ctx, extractMediaDocs( data ) );\n\t\t\t} )\n\t\t\t.catch( function () {\n\t\t\t\tvar box = panel && panel.querySelector( '.wpve-result' );\n\t\t\t\tif ( box ) {\n\t\t\t\t\tbox.innerHTML =\n\t\t\t\t\t\t'<div class=\"wpve-hint\">Mediebiblioteket er ikke tilgjengelig – bruk URL-feltet over.</div>';\n\t\t\t\t\tpositionPanel();\n\t\t\t\t}\n\t\t\t} );\n\t}\n\n\t// What the toast says once a picture is made and saved.\n\tfunction imageDone( data, isIcon ) {\n\t\tif ( isIcon ) {\n\t\t\treturn 'Ikon laget og lagret ✓';\n\t\t}\n\t\tvar mode = data && data.mode;\n\t\tif ( 'edit' === mode ) {\n\t\t\treturn 'Bildet er endret og lagret ✓';\n\t\t}\n\t\treturn 'new' === mode ? 'Nytt bilde laget og lagret ✓' : 'Bilde generert og lagret ✓';\n\t}\n\n\t/**\n\t * Point the on-page image at a new URL without a reload. Themes often use\n\t * <picture>/<source srcset>, responsive `srcset`/`sizes`, and lazy-load\n\t * `data-*` attributes that all win over `img.src`; clear them so the new\n\t * image actually shows. Shared by AI generation and the URL/library swap.\n\t */\n\tfunction applyNew( ctx, url ) {\n\t\tif ( ! url ) {\n\t\t\treturn;\n\t\t}\n\t\tvar img = imageOf( ctx );\n\t\tif ( ! img ) {\n\t\t\treturn;\n\t\t}\n\t\t// Den opprinnelige src-en, som en innholdsnøkkel (apply.js) bygger på; ta vare på den før den byttes.\n\t\tif ( ! img.getAttribute( 'data-vibe-src' ) ) {\n\t\t\timg.setAttribute( 'data-vibe-src', img.getAttribute( 'src' ) || '' );\n\t\t}\n\t\tvar pic = img.closest ? img.closest( 'picture' ) : null;\n\t\tif ( pic ) {\n\t\t\tvar sources = pic.querySelectorAll( 'source' );\n\t\t\tfor ( var i = 0; i < sources.length; i++ ) {\n\t\t\t\tsources[ i ].setAttribute( 'srcset', url );\n\t\t\t}\n\t\t}\n\t\timg.removeAttribute( 'srcset' );\n\t\timg.removeAttribute( 'sizes' );\n\t\timg.removeAttribute( 'data-src' );\n\t\timg.removeAttribute( 'data-lazy-src' );\n\t\timg.removeAttribute( 'data-srcset' );\n\t\timg.removeAttribute( 'data-lazy-srcset' );\n\t\timg.srcset = '';\n\t\timg.src = url;\n\t}\n\n\t/* Accept whatever list shape the host's media endpoint happens to return. */\n\tfunction extractMediaDocs( data ) {\n\t\tif ( ! data ) {\n\t\t\treturn [];\n\t\t}\n\t\tif ( Array.isArray( data ) ) {\n\t\t\treturn data;\n\t\t}\n\t\treturn data.docs || data.media || data.results || data.items || data.data || [];\n\t}\n\n\t/* Pick the best thumbnail URL across common field names. */\n\tfunction mediaThumb( doc ) {\n\t\treturn (\n\t\t\t( doc.sizes && doc.sizes.thumbnail && doc.sizes.thumbnail.url ) ||\n\t\t\tdoc.thumbnailUrl ||\n\t\t\tdoc.url ||\n\t\t\tdoc.src ||\n\t\t\t''\n\t\t);\n\t}\n\n\tfunction renderImagePicker( ctx, docs ) {\n\t\tvar box = panel && panel.querySelector( '.wpve-result' );\n\t\tif ( ! box ) {\n\t\t\treturn;\n\t\t}\n\t\tif ( ! docs.length ) {\n\t\t\tbox.innerHTML =\n\t\t\t\t'<div class=\"wpve-hint\">Ingen bilder i biblioteket ennå – bruk URL-feltet over.</div>';\n\t\t\tpositionPanel();\n\t\t\treturn;\n\t\t}\n\t\tvar html = '<div class=\"wpve-media-grid\">';\n\t\tdocs.forEach( function ( doc ) {\n\t\t\tvar id = ( null != doc.id ) ? doc.id : doc._id;\n\t\t\tvar thumb = mediaThumb( doc );\n\t\t\tvar alt = doc.alt || doc.filename || '';\n\t\t\thtml += '<button type=\"button\" class=\"wpve-media-item\" data-id=\"' + escapeHtml( String( id ) ) + '\" title=\"' +\n\t\t\t\tescapeHtml( alt ) + '\"><img src=\"' + escapeHtml( thumb ) + '\" alt=\"' + escapeHtml( alt ) + '\"></button>';\n\t\t} );\n\t\thtml += '</div>';\n\t\tbox.innerHTML = html;\n\t\tpositionPanel();\n\n\t\tbox.querySelectorAll( '.wpve-media-item' ).forEach( function ( btn ) {\n\t\t\tbtn.addEventListener( 'click', function () {\n\t\t\t\tswapImage( ctx, btn.getAttribute( 'data-id' ), btn.querySelector( 'img' ).src );\n\t\t\t} );\n\t\t} );\n\t}\n\n\tfunction swapImage( ctx, uploadId, previewUrl ) {\n\t\tapi( 'swap-image', {\n\t\t\tcollection: ctx.collection,\n\t\t\tid: ctx.id,\n\t\t\tfield: ctx.field,\n\t\t\tuploadId: uploadId,\n\t\t} )\n\t\t\t.then( function ( data ) {\n\t\t\t\tif ( previewUrl ) {\n\t\t\t\t\tapplyNew( ctx, previewUrl );\n\t\t\t\t}\n\t\t\t\tclosePanel();\n\t\t\t\ttoast( ( data && data.warning ) || 'Bilde byttet ✓' );\n\t\t\t} )\n\t\t\t.catch( function ( err ) {\n\t\t\t\ttoast( err );\n\t\t\t} );\n\t}\n\n\t/* ---------- Sidens tittel og beskrivelse ---------- */\n\n\t// The page button, for a platform that learns only later that this page has a title to edit.\n\tfunction addPageButton() {\n\t\tif ( pageBtn ) {\n\t\t\treturn;\n\t\t}\n\t\tpageBtn = createPageButton();\n\t\tapplyLayout();\n\t\tshowPageButton();\n\t}\n\n\t// The button shows in edit mode, where the platform says the page has a title to edit.\n\tfunction showPageButton() {\n\t\tif ( pageBtn ) {\n\t\t\tpageBtn.style.display = editMode && platform.pageShown() ? 'flex' : 'none';\n\t\t}\n\t}\n\n\tfunction createPageButton() {\n\t\tvar btn = document.createElement( 'button' );\n\t\tbtn.id = 'wpve-page-btn';\n\t\tbtn.type = 'button';\n\t\tbtn.title = 'Sidetittel og beskrivelse';\n\t\tbtn.setAttribute( 'aria-label', 'Sidetittel og beskrivelse' );\n\t\tbtn.style.display = 'none';\n\t\tbtn.innerHTML =\n\t\t\t'<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\t'<path fill=\"currentColor\" d=\"M10.5 3a7.5 7.5 0 0 1 5.96 12.05l4.5 4.5-1.41 1.41-4.5-4.5A7.5 7.5 0 1 1 10.5 3Zm0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3 3.5h6v1.5h-6V8.5Zm0 3h4.5V13H7.5v-1.5Z\"/>' +\n\t\t\t'</svg>';\n\t\tdocument.body.appendChild( btn );\n\t\tbtn.addEventListener( 'click', function () {\n\t\t\tif ( panel && panel.classList.contains( 'wpve-page-panel' ) ) {\n\t\t\t\tclosePanel();\n\t\t\t} else {\n\t\t\t\teditPage();\n\t\t\t}\n\t\t} );\n\t\treturn btn;\n\t}\n\n\tfunction metaTags( which ) {\n\t\tvar out = [];\n\t\tvar all = document.getElementsByTagName( 'meta' );\n\t\tfor ( var i = 0; i < all.length; i++ ) {\n\t\t\tvar n = ( all[ i ].getAttribute( 'name' ) || '' ).toLowerCase();\n\t\t\tvar p = ( all[ i ].getAttribute( 'property' ) || '' ).toLowerCase();\n\t\t\tif ( which.indexOf( n ) !== -1 || which.indexOf( p ) !== -1 ) {\n\t\t\t\tout.push( all[ i ] );\n\t\t\t}\n\t\t}\n\t\treturn out;\n\t}\n\n\tfunction currentDescription() {\n\t\tvar tag = metaTags( [ 'description' ] )[ 0 ];\n\t\treturn tag ? ( tag.getAttribute( 'content' ) || '' ) : '';\n\t}\n\n\t// Vis endringen med en gang; ved neste visning skriver siden den selv.\n\tfunction showPageValue( kind, value ) {\n\t\tif ( 'title' === kind ) {\n\t\t\tdocument.title = value;\n\t\t\tmetaTags( [ 'og:title', 'twitter:title' ] ).forEach( function ( m ) {\n\t\t\t\tm.setAttribute( 'content', value );\n\t\t\t} );\n\t\t\treturn;\n\t\t}\n\t\tvar tags = metaTags( [ 'description', 'og:description', 'twitter:description' ] );\n\t\tif ( ! metaTags( [ 'description' ] ).length ) {\n\t\t\tvar m = document.createElement( 'meta' );\n\t\t\tm.setAttribute( 'name', 'description' );\n\t\t\tdocument.head.appendChild( m );\n\t\t\ttags.push( m );\n\t\t}\n\t\ttags.forEach( function ( tag ) {\n\t\t\ttag.setAttribute( 'content', value );\n\t\t} );\n\t}\n\n\t// Det siden handler om, som grunnlag for AI-forslag.\n\tfunction pageSummary() {\n\t\tvar parts = [];\n\t\tvar h1 = document.querySelector( 'h1' );\n\t\tif ( h1 && ! isOwnUi( h1 ) ) {\n\t\t\tparts.push( h1.textContent.trim() );\n\t\t}\n\t\tvar ps = document.querySelectorAll( 'p' );\n\t\tfor ( var i = 0; i < ps.length && parts.length < 5; i++ ) {\n\t\t\tvar t = ( ps[ i ].textContent || '' ).trim();\n\t\t\tif ( t.length > 30 && ! isOwnUi( ps[ i ] ) ) {\n\t\t\t\tparts.push( t );\n\t\t\t}\n\t\t}\n\t\treturn parts.join( '\\n' ).replace( /[ \\t]+/g, ' ' ).slice( 0, 900 );\n\t}\n\n\tfunction editPage() {\n\t\t// Read when the panel opens, since client-side navigation changes the\n\t\t// page without loading this script again.\n\t\tvar target = platform.pageTarget();\n\t\tif ( ! target ) {\n\t\t\ttoast( 'Denne siden har ingen tittel og beskrivelse å endre.' );\n\t\t\treturn;\n\t\t}\n\t\tcloseSettings();\n\t\t// Beside the button, so the panel doesn't cover it.\n\t\tvar r = pageBtn.getBoundingClientRect();\n\t\tclickPos = { x: r.left, y: r.top };\n\t\topenPanel();\n\t\tpanel.classList.add( 'wpve-page-panel' );\n\t\tpanel.innerHTML =\n\t\t\t'<div class=\"wpve-panel-head\">Side i søkemotorer<span class=\"wpve-close\">×</span></div>' +\n\t\t\t'<div class=\"wpve-result\"><div class=\"wpve-loading\">Henter sidetittel og beskrivelse …</div></div>';\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\t\tpositionPanel();\n\n\t\t// What the page has saved: the panel shows its own values, and what an empty field gives.\n\t\tvar mine = panel;\n\t\tnew Promise( function ( resolve ) {\n\t\t\tresolve( platform.readPage( target ) );\n\t\t} )\n\t\t\t.then( function ( seo ) {\n\t\t\t\tif ( panel === mine ) {\n\t\t\t\t\trenderPagePanel( target, seo || {} );\n\t\t\t\t}\n\t\t\t} )\n\t\t\t.catch( function ( err ) {\n\t\t\t\tif ( panel === mine ) {\n\t\t\t\t\tshowError( panel.querySelector( '.wpve-result' ), err );\n\t\t\t\t\tpositionPanel();\n\t\t\t\t}\n\t\t\t} );\n\t}\n\n\tfunction renderPagePanel( target, seo ) {\n\t\tvar mine = panel;\n\t\tvar was = { title: String( seo.title || '' ), description: String( seo.description || '' ) };\n\t\tvar titleEditable = seo.titleEditable !== false;\n\n\t\tpanel.innerHTML =\n\t\t\t'<div class=\"wpve-panel-head\">Side i søkemotorer<span class=\"wpve-close\">×</span></div>' +\n\t\t\t'<div class=\"wpve-field-label\">Sidetittel <span class=\"wpve-count\" data-kind=\"title\"></span></div>' +\n\t\t\t'<input type=\"text\" class=\"wpve-prompt wpve-page-title\" maxlength=\"200\" />' +\n\t\t\t'<div class=\"wpve-field-label\">Metabeskrivelse <span class=\"wpve-count\" data-kind=\"description\"></span></div>' +\n\t\t\t'<textarea class=\"wpve-prompt wpve-page-desc\" rows=\"4\" maxlength=\"400\"></textarea>' +\n\t\t\t'<div class=\"wpve-hint\"></div>' +\n\t\t\t'<div class=\"wpve-actions\">' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-btn wpve-page-ai\">Foreslå med AI</button>' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-btn wpve-page-save\">Lagre</button>' +\n\t\t\t'</div>' +\n\t\t\t'<div class=\"wpve-result\"></div>';\n\n\t\tvar titleEl = panel.querySelector( '.wpve-page-title' );\n\t\tvar descEl = panel.querySelector( '.wpve-page-desc' );\n\t\tvar resultBox = panel.querySelector( '.wpve-result' );\n\t\tvar aiBtn = panel.querySelector( '.wpve-page-ai' );\n\t\tvar saveBtn = panel.querySelector( '.wpve-page-save' );\n\t\tpanel.querySelector( '.wpve-hint' ).textContent = platform.pageHint( target, seo );\n\t\ttitleEl.value = was.title;\n\t\tdescEl.value = was.description;\n\t\t// Det et tomt felt gir, der plattformen har noe å falle tilbake på.\n\t\ttitleEl.placeholder = String( seo.titleDefault || '' );\n\t\tdescEl.placeholder = String( seo.descriptionDefault || '' );\n\t\ttitleEl.disabled = ! titleEditable;\n\t\tpanel.querySelector( '.wpve-close' ).addEventListener( 'click', closePanel );\n\n\t\tvar limits = { title: 60, description: 160 };\n\t\tfunction count() {\n\t\t\t[ [ 'title', titleEl ], [ 'description', descEl ] ].forEach( function ( pair ) {\n\t\t\t\tvar el = mine.querySelector( '.wpve-count[data-kind=\"' + pair[ 0 ] + '\"]' );\n\t\t\t\tvar n = pair[ 1 ].value.trim().length;\n\t\t\t\tel.textContent = n + ' / ' + limits[ pair[ 0 ] ];\n\t\t\t\tel.classList.toggle( 'wpve-count-long', n > limits[ pair[ 0 ] ] );\n\t\t\t} );\n\t\t}\n\t\ttitleEl.addEventListener( 'input', count );\n\t\tdescEl.addEventListener( 'input', count );\n\t\tcount();\n\n\t\tfunction note( cls, text ) {\n\t\t\tif ( 'wpve-error' === cls ) {\n\t\t\t\tshowError( resultBox, text );\n\t\t\t} else {\n\t\t\t\tresultBox.innerHTML = '<div class=\"' + cls + '\"></div>';\n\t\t\t\tresultBox.firstChild.textContent = text;\n\t\t\t}\n\t\t\tpositionPanel();\n\t\t}\n\n\t\t// Ett forslag per felt, hvert til prisen av et tekstforslag.\n\t\taiBtn.addEventListener( 'click', function () {\n\t\t\tvar off = platform.aiUnavailable();\n\t\t\tif ( off ) {\n\t\t\t\tnote( 'wpve-error', off );\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tvar about = pageSummary() || was.title || titleEl.placeholder || document.title;\n\t\t\tvar first = about.split( '\\n' )[ 0 ] || document.title;\n\t\t\taiBtn.disabled = true;\n\t\t\tnote( 'wpve-loading', 'Skriver forslag …' );\n\t\t\tnew Promise( function ( resolve ) {\n\t\t\t\tresolve( platform.aiReady() );\n\t\t\t} )\n\t\t\t\t.then( function () {\n\t\t\t\t\treturn Promise.all( [\n\t\t\t\t\t\ttitleEditable ? platform.suggestMeta( 'title',\n\t\t\t\t\t\t\ttitleEl.value.trim() || titleEl.placeholder || first,\n\t\t\t\t\t\t\t'Write the <title> for this web page instead: at most 60 characters, in the same language as the page, ' +\n\t\t\t\t\t\t\t\t'plain text, no quotes. The page says:\\n' + about,\n\t\t\t\t\t\t\ttarget\n\t\t\t\t\t\t) : null,\n\t\t\t\t\t\tplatform.suggestMeta( 'description',\n\t\t\t\t\t\t\tdescEl.value.trim() || descEl.placeholder || first,\n\t\t\t\t\t\t\t'Write the meta description for this web page instead: 120 to 155 characters, in the same language as the page, ' +\n\t\t\t\t\t\t\t\t'plain text, no quotes, one or two sentences that make a searcher want to click. The page says:\\n' + about,\n\t\t\t\t\t\t\ttarget\n\t\t\t\t\t\t),\n\t\t\t\t\t] );\n\t\t\t\t} )\n\t\t\t\t.then( function ( out ) {\n\t\t\t\t\taiBtn.disabled = false;\n\t\t\t\t\tif ( out[ 0 ] ) {\n\t\t\t\t\t\ttitleEl.value = String( out[ 0 ].suggestion || '' ).trim();\n\t\t\t\t\t}\n\t\t\t\t\tdescEl.value = String( out[ 1 ].suggestion || '' ).trim();\n\t\t\t\t\tcount();\n\t\t\t\t\t// Forslagene er ulagret tekst: et klikk utenfor skal ikke kaste dem.\n\t\t\t\t\tmine.setAttribute( 'data-dirty', '1' );\n\t\t\t\t\tnote( 'wpve-hint', 'Forslagene er satt inn. Se over og trykk Lagre.' );\n\t\t\t\t\tplatform.aiUsed();\n\t\t\t\t} )\n\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\taiBtn.disabled = false;\n\t\t\t\t\tnote( 'wpve-error', err );\n\t\t\t\t} );\n\t\t} );\n\n\t\tsaveBtn.addEventListener( 'click', function () {\n\t\t\tvar changes = {};\n\t\t\tvar changed = [];\n\t\t\t[ [ 'title', titleEl ], [ 'description', descEl ] ].forEach( function ( pair ) {\n\t\t\t\tvar value = pair[ 1 ].value.trim();\n\t\t\t\tif ( ! pair[ 1 ].disabled && value !== was[ pair[ 0 ] ].trim() ) {\n\t\t\t\t\tchanges[ pair[ 0 ] ] = value; // tomt = tilbake til originalen\n\t\t\t\t\tchanged.push( [ pair[ 0 ], value ] );\n\t\t\t\t}\n\t\t\t} );\n\t\t\tif ( ! changed.length ) {\n\t\t\t\tclosePanel();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsaveBtn.disabled = true;\n\t\t\tsaveBtn.textContent = 'Lagrer …';\n\t\t\tplatform.savePage( changes, target )\n\t\t\t\t.then( function ( data ) {\n\t\t\t\t\tvar emptied = false;\n\t\t\t\t\tchanged.forEach( function ( c ) {\n\t\t\t\t\t\tif ( c[ 1 ] ) {\n\t\t\t\t\t\t\tshowPageValue( c[ 0 ], c[ 1 ] );\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\temptied = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t} );\n\t\t\t\t\tclosePanel();\n\t\t\t\t\ttoast( ( data && data.warning ) || ( emptied ? platform.pageEmptied : 'Lagret ✓' ) );\n\t\t\t\t} )\n\t\t\t\t.catch( function ( err ) {\n\t\t\t\t\tsaveBtn.disabled = false;\n\t\t\t\t\tsaveBtn.textContent = 'Lagre';\n\t\t\t\t\tnote( 'wpve-error', err );\n\t\t\t\t} );\n\t\t} );\n\n\t\tpositionPanel();\n\t\t( titleEditable ? titleEl : descEl ).focus();\n\t}\n\n\t/* ---------- Ikoner og kommandolinje ---------- */\n\n\t// Function declarations are hoisted, so init() can use these wherever it runs.\n\tfunction icon( name, cls ) {\n\t\tvar shapes = {\n\t\t\tpencil: '<path d=\"M4 20h4L19 9l-4-4L4 16v4z\"/><path d=\"M13.5 6.5l4 4\"/>',\n\t\t\timage: '<rect x=\"3.5\" y=\"5\" width=\"17\" height=\"14\" rx=\"2.5\"/><circle cx=\"9\" cy=\"10\" r=\"1.5\"/><path d=\"M4 17l5-4.5 3.5 3 3-2.5 4.5 4\"/>',\n\t\t\tsignout: '<path d=\"M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4\"/><path d=\"M15 16l4-4-4-4\"/><path d=\"M19 12H9\"/>',\n\t\t\tspark: '<path fill=\"currentColor\" stroke=\"none\" d=\"M12 3l1.7 6.3L20 11l-6.3 1.7L12 19l-1.7-6.3L4 11l6.3-1.7L12 3z\"/>' +\n\t\t\t\t'<path fill=\"currentColor\" stroke=\"none\" d=\"M19 16l.6 1.9 1.9.6-1.9.6L19 21l-.6-1.9-1.9-.6 1.9-.6L19 16z\"/>',\n\t\t};\n\t\treturn '<svg class=\"' + ( cls || '' ) + '\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" ' +\n\t\t\t'stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\">' +\n\t\t\tshapes[ name ] + '</svg>';\n\t}\n\n\t// The command bar for a text. The switch between asking AI and typing comes\n\t// first in the markup so its state can style the bar after it (overlay.css).\n\tfunction textBarHtml() {\n\t\treturn '<button type=\"button\" class=\"ai-toggle\" data-mode=\"ai\">' +\n\t\t\t\t'<span class=\"wpve-when-ai\">Skriv teksten selv</span><span class=\"wpve-when-manual\">Bruk AI i stedet</span>' +\n\t\t\t'</button>' +\n\t\t\t'<div class=\"wpve-bar\">' +\n\t\t\t\t'<span class=\"wpve-bar-icon\">' + icon( 'spark', 'wpve-when-ai' ) + icon( 'pencil', 'wpve-when-manual' ) + '</span>' +\n\t\t\t\t'<textarea class=\"wpve-prompt\" rows=\"1\"></textarea>' +\n\t\t\t\t'<button type=\"button\" class=\"wpve-send wpve-suggest\"></button>' +\n\t\t\t'</div>' +\n\t\t\t'<button type=\"button\" class=\"wpve-close wpve-close-link\">Lukk</button>' +\n\t\t\t'<div class=\"wpve-result\"></div>';\n\t}\n\n\t// The bar's text field grows with what is typed, up to eight lines.\n\tfunction growPrompt( field ) {\n\t\tif ( ! field || 'TEXTAREA' !== field.tagName ) {\n\t\t\treturn;\n\t\t}\n\t\tfield.rows = 1;\n\t\tfield.rows = Math.max( 1, Math.min( 8, Math.ceil( ( field.scrollHeight - 18 ) / 20 ) ) );\n\t}\n\n\tfunction onPanelInput( e ) {\n\t\t// Something typed: a stray click outside must not throw it away (see onPick).\n\t\te.currentTarget.setAttribute( 'data-dirty', '1' );\n\t\tif ( e.target.closest && e.target.closest( '.wpve-bar' ) ) {\n\t\t\tgrowPrompt( e.target );\n\t\t\tpositionPanel();\n\t\t}\n\t}\n\n\t// Enter sends what is in the bar (Shift+Enter makes a new line); Escape closes.\n\tfunction onPanelKey( e ) {\n\t\tif ( 'Escape' === e.key ) {\n\t\t\te.preventDefault();\n\t\t\tclosePanel();\n\t\t\treturn;\n\t\t}\n\t\tvar bar = e.target.closest ? e.target.closest( '.wpve-bar' ) : null;\n\t\tif ( 'Enter' === e.key && ! e.shiftKey && ! e.isComposing && bar ) {\n\t\t\te.preventDefault();\n\t\t\tvar send = bar.querySelector( '.wpve-send' );\n\t\t\tif ( send && ! send.disabled ) {\n\t\t\t\tsend.click();\n\t\t\t}\n\t\t}\n\t}\n\n\t// How much the switch is scaled at each size (overlay.css), so what sits around it lines up.\n\tfunction bubbleScale() {\n\t\treturn { small: 0.82, large: 1.22 }[ settings.size ] || 1;\n\t}\n\n\t// Keep the outline on what is being edited while its panel is open.\n\tfunction markSelected( el ) {\n\t\tif ( selected ) {\n\t\t\tselected.classList.remove( 'wpve-selected' );\n\t\t}\n\t\tselected = el || null;\n\t\tif ( selected ) {\n\t\t\tselected.classList.add( 'wpve-selected' );\n\t\t}\n\t}\n\n\t/* ---------- Panel + toast ---------- */\n\n\tfunction openPanel() {\n\t\tclosePanel();\n\t\tpanel = document.createElement( 'div' );\n\t\tpanel.className = 'wpve-panel';\n\t\tpanel.addEventListener( 'keydown', onPanelKey );\n\t\tpanel.addEventListener( 'input', onPanelInput );\n\t\tdocument.body.appendChild( panel );\n\t\tpositionPanel();\n\t}\n\n\tfunction positionPanel() {\n\t\tif ( ! panel ) {\n\t\t\treturn;\n\t\t}\n\t\tvar margin = 12;\n\t\tvar offset = 18;\n\t\tvar vw = document.documentElement.clientWidth;\n\t\tvar vh = document.documentElement.clientHeight;\n\t\tvar rect = panel.getBoundingClientRect();\n\t\tvar w = rect.width;\n\t\tvar h = rect.height;\n\t\tvar x;\n\t\tvar y;\n\n\t\tif ( selected && selected.getBoundingClientRect ) {\n\t\t\t// Under det som redigeres, så teksten eller bildet er synlig; over hvis det ikke er plass.\n\t\t\tvar box = selected.getBoundingClientRect();\n\t\t\tx = box.left;\n\t\t\ty = box.bottom + 10;\n\t\t\tif ( y + h > vh - margin && box.top - 10 - h >= margin ) {\n\t\t\t\ty = box.top - 10 - h;\n\t\t\t}\n\t\t} else if ( clickPos ) {\n\t\t\tif ( clickPos.x + offset + w <= vw - margin ) {\n\t\t\t\tx = clickPos.x + offset;\n\t\t\t} else {\n\t\t\t\tx = clickPos.x - offset - w;\n\t\t\t}\n\t\t\ty = clickPos.y - 24;\n\t\t} else {\n\t\t\tx = vw - w - 24;\n\t\t\ty = vh - h - 96;\n\t\t}\n\n\t\tx = Math.max( margin, Math.min( x, vw - w - margin ) );\n\t\ty = Math.max( margin, Math.min( y, vh - h - margin ) );\n\n\t\tpanel.style.left = x + 'px';\n\t\tpanel.style.top = y + 'px';\n\t\tpanel.style.right = 'auto';\n\t\tpanel.style.bottom = 'auto';\n\t}\n\n\tfunction closePanel() {\n\t\tmarkSelected( null );\n\t\tif ( panel ) {\n\t\t\tpanel.remove();\n\t\t\tpanel = null;\n\t\t}\n\t}\n\n\t/**\n\t * Lay a spinner overlay over the image while AI works, so it's clear that\n\t * something is happening. Follows the image on scroll/resize, removed when\n\t * the call finishes.\n\t */\n\tfunction showImageLoader( target ) {\n\t\tvar loader = document.createElement( 'div' );\n\t\tloader.className = 'wpve-img-loader';\n\t\tloader.innerHTML = '<div class=\"wpve-spinner\"></div><span>Lager bilde …</span>';\n\t\tdocument.body.appendChild( loader );\n\n\t\tfunction place() {\n\t\t\tvar r = target.getBoundingClientRect();\n\t\t\tloader.style.left = r.left + 'px';\n\t\t\tloader.style.top = r.top + 'px';\n\t\t\tloader.style.width = r.width + 'px';\n\t\t\tloader.style.height = r.height + 'px';\n\t\t}\n\t\tplace();\n\n\t\twindow.addEventListener( 'scroll', place, true );\n\t\twindow.addEventListener( 'resize', place );\n\n\t\treturn {\n\t\t\tremove: function () {\n\t\t\t\twindow.removeEventListener( 'scroll', place, true );\n\t\t\t\twindow.removeEventListener( 'resize', place );\n\t\t\t\tloader.remove();\n\t\t\t},\n\t\t};\n\t}\n\n\tfunction toast( msg ) {\n\t\t// Én melding om gangen: en ny erstatter den som står.\n\t\tvar shown = document.querySelectorAll( '.wpve-toast' );\n\t\tfor ( var i = 0; i < shown.length; i++ ) {\n\t\t\tshown[ i ].remove();\n\t\t}\n\t\tvar t = document.createElement( 'div' );\n\t\tt.className = 'wpve-toast';\n\t\tt.textContent = msg;\n\t\tdocument.body.appendChild( t );\n\t\tsetTimeout( function () {\n\t\t\tt.classList.add( 'wpve-toast-out' );\n\t\t\tsetTimeout( function () {\n\t\t\t\tt.remove();\n\t\t\t}, 400 );\n\t\t}, 2600 );\n\t}\n\n\tfunction escapeHtml( str ) {\n\t\treturn String( str ).replace( /[&<>\"']/g, function ( c ) {\n\t\t\treturn { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[ c ];\n\t\t} );\n\t}\n\n\t/* ---------- API ---------- */\n\n\t// A POST to one of the API's endpoints, sent the platform's way (platform.request).\n\tfunction api( endpoint, payload, timeoutMs ) {\n\t\tvar controller = ( typeof AbortController !== 'undefined' ) ? new AbortController() : null;\n\t\tvar timer = null;\n\t\tif ( controller && timeoutMs ) {\n\t\t\ttimer = setTimeout( function () {\n\t\t\t\tcontroller.abort();\n\t\t\t}, timeoutMs );\n\t\t}\n\n\t\tvar req = platform.request( endpoint, payload );\n\t\treturn fetch( req.url, {\n\t\t\tmethod: 'POST',\n\t\t\tcredentials: req.credentials,\n\t\t\tsignal: controller ? controller.signal : undefined,\n\t\t\theaders: req.headers,\n\t\t\tbody: JSON.stringify( req.body ),\n\t\t} ).catch( function ( err ) {\n\t\t\tif ( timer ) {\n\t\t\t\tclearTimeout( timer );\n\t\t\t\ttimer = null;\n\t\t\t}\n\t\t\tif ( err && 'AbortError' === err.name ) {\n\t\t\t\tthrow 'Det tok for lang tid. Prøv igjen om litt.';\n\t\t\t}\n\t\t\tthrow ( err && err.message ) ? err.message : String( err );\n\t\t} ).then( function ( res ) {\n\t\t\tif ( timer ) {\n\t\t\t\tclearTimeout( timer );\n\t\t\t\ttimer = null;\n\t\t\t}\n\t\t\treturn answerOf( res );\n\t\t} );\n\t}\n\n\t/** The JSON answer, or its error as a message to show (with topupUrl when the credits ran out). */\n\tfunction answerOf( res ) {\n\t\treturn res.text().then( function ( text ) {\n\t\t\tvar data = null;\n\t\t\ttry {\n\t\t\t\tdata = JSON.parse( text );\n\t\t\t} catch ( e ) {\n\t\t\t\tdata = null;\n\t\t\t}\n\t\t\tif ( ! res.ok || data === null ) {\n\t\t\t\tvar err = new String( ( data && data.error ) ? data.error : ( 'Serverfeil (' + res.status + ').' ) ); // eslint-disable-line no-new-wrappers\n\t\t\t\tif ( data && data.topupUrl ) {\n\t\t\t\t\terr.topupUrl = data.topupUrl;\n\t\t\t\t}\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t\treturn data;\n\t\t} );\n\t}\n\n\t// Last, so everything above is in place however early this runs.\n\tif ( document.readyState === 'loading' ) {\n\t\tdocument.addEventListener( 'DOMContentLoaded', init );\n\t} else {\n\t\tinit();\n\t}\n} )();\n";
3
3
  export const overlayCss = "/*\n * Vibe Editor – the editor's own interface on a customer's page.\n *\n * A glass switch turns editing on and off, and the page glows while it is on.\n * Clicking a text or an image opens a command bar next to it: say what to\n * change, or type the text yourself. The panels sit on someone else's design,\n * so they are quiet: one accent colour, white surfaces, soft shadows.\n *\n * The same file ships in every integration (hosted embed and Cloudflare Pages,\n * WordPress, Next.js, Payload, Sitevision); keep the copies identical.\n */\n\n/* ---------- The edit switch ---------- */\n\n/* A glass toggle: cool and to the left at rest, warm and to the right, with a soft pulse, while editing. */\n#wpve-bubble {\n\tposition: fixed;\n\tbox-sizing: border-box;\n\tright: 24px;\n\tbottom: 24px;\n\twidth: 76px;\n\theight: 40px;\n\tborder-radius: 999px;\n\t/* The cool glass track (off). */\n\tbackground:\n\t\tradial-gradient( 140% 140% at 20% 10%, rgba( 255, 255, 255, 0.4 ), rgba( 255, 255, 255, 0 ) 55% ),\n\t\tlinear-gradient( 140deg, rgba( 109, 104, 255, 0.6 ), rgba( 80, 78, 220, 0.45 ) 55%, rgba( 60, 66, 190, 0.6 ) );\n\t-webkit-backdrop-filter: blur( 18px ) saturate( 180% );\n\tbackdrop-filter: blur( 18px ) saturate( 180% );\n\tborder: 1px solid rgba( 255, 255, 255, 0.42 );\n\tbox-shadow:\n\t\t0 10px 28px rgba( 40, 40, 120, 0.4 ),\n\t\tinset 0 1px 2px rgba( 255, 255, 255, 0.55 ),\n\t\tinset 0 -6px 14px rgba( 30, 20, 90, 0.25 );\n\tcursor: pointer;\n\tz-index: 999999;\n\tuser-select: none;\n\toverflow: hidden;\n\tisolation: isolate;\n\ttransition: box-shadow 0.35s ease;\n}\n\n/* Corner presets (also set inline by the script; here as the fallback). */\n#wpve-bubble.wpve-pos-bottom-right { right: 24px; bottom: 24px; left: auto; top: auto; transform-origin: bottom right; }\n#wpve-bubble.wpve-pos-bottom-left { left: 24px; bottom: 24px; right: auto; top: auto; transform-origin: bottom left; }\n#wpve-bubble.wpve-pos-top-right { right: 24px; top: 24px; left: auto; bottom: auto; transform-origin: top right; }\n#wpve-bubble.wpve-pos-top-left { left: 24px; top: 24px; right: auto; bottom: auto; transform-origin: top left; }\n\n/* Size: the whole switch is scaled. Medium is the default. */\n#wpve-bubble.wpve-size-small { transform: scale( 0.82 ); }\n#wpve-bubble.wpve-size-medium { transform: scale( 1 ); }\n#wpve-bubble.wpve-size-large { transform: scale( 1.22 ); }\n\n/* The warm track that fades in while editing. */\n#wpve-bubble::after {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: inherit;\n\tbackground: linear-gradient( 135deg,\n\t\trgba( 255, 150, 56, 0.95 ),\n\t\trgba( 255, 99, 72, 0.92 ),\n\t\trgba( 233, 64, 110, 0.92 ),\n\t\trgba( 255, 150, 56, 0.95 ) );\n\tbackground-size: 300% 300%;\n\topacity: 0;\n\tz-index: 0;\n\tpointer-events: none;\n\ttransition: opacity 0.6s ease;\n}\n\n/* The knob that slides from left to right, centred whatever the theme's box-sizing is. */\n#wpve-bubble .wpve-knob {\n\tposition: absolute;\n\tbox-sizing: border-box;\n\ttop: 50%;\n\tleft: 5px;\n\twidth: 30px;\n\theight: 30px;\n\tborder-radius: 50%;\n\tdisplay: grid;\n\tplace-items: center;\n\tbackground: radial-gradient( 130% 130% at 30% 25%, #fff, rgba( 255, 255, 255, 0.88 ) 60%, rgba( 235, 238, 255, 0.92 ) );\n\tbox-shadow:\n\t\t0 3px 8px rgba( 20, 18, 60, 0.35 ),\n\t\tinset 0 1px 1px rgba( 255, 255, 255, 0.9 );\n\tz-index: 2;\n\ttransform: translateY( -50% );\n\ttransition: left 0.38s cubic-bezier( 0.2, 0.8, 0.2, 1 );\n}\n\n#wpve-bubble.wpve-active .wpve-knob {\n\tleft: calc( 100% - 35px ); /* mirrors the 5px on the left, whatever the width */\n}\n\n#wpve-bubble .wpve-icon {\n\twidth: 17px;\n\theight: 17px;\n\tcolor: #6d5efc;\n\ttransition: color 0.4s ease;\n}\n\n#wpve-bubble.wpve-active .wpve-icon {\n\tcolor: #ff7a3c;\n}\n\n#wpve-bubble .wpve-knob img {\n\twidth: 18px;\n\theight: 18px;\n\tobject-fit: contain;\n}\n\n#wpve-bubble:hover {\n\tbox-shadow:\n\t\t0 14px 34px rgba( 17, 20, 40, 0.34 ),\n\t\tinset 0 1px 2px rgba( 255, 255, 255, 0.7 ),\n\t\tinset 0 -6px 14px rgba( 30, 20, 90, 0.25 );\n}\n\n/* Editing: the warm track fades in, drifts slowly and pulses softly. */\n#wpve-bubble.wpve-active {\n\tborder-color: rgba( 255, 255, 255, 0.6 );\n\tanimation: wpve-glow 2.6s ease-in-out infinite;\n}\n\n#wpve-bubble.wpve-active::after {\n\topacity: 1;\n\tanimation: wpve-flow 8s ease infinite;\n}\n\n@keyframes wpve-flow {\n\t0% { background-position: 0% 50%; }\n\t50% { background-position: 100% 50%; }\n\t100% { background-position: 0% 50%; }\n}\n\n/* The soft pulse: it breathes in the shadow and the glow, so nothing moves in the layout. */\n@keyframes wpve-glow {\n\t0%, 100% {\n\t\tbox-shadow:\n\t\t\t0 10px 28px rgba( 225, 90, 60, 0.45 ),\n\t\t\t0 0 0 0 rgba( 255, 120, 70, 0 ),\n\t\t\tinset 0 1px 2px rgba( 255, 255, 255, 0.55 );\n\t}\n\t50% {\n\t\tbox-shadow:\n\t\t\t0 14px 36px rgba( 225, 90, 60, 0.6 ),\n\t\t\t0 0 22px 5px rgba( 255, 120, 70, 0.4 ),\n\t\t\tinset 0 1px 2px rgba( 255, 255, 255, 0.65 );\n\t}\n}\n\n/* A soft glow around the whole page says that editing is on (a setting): a clear pulse when it\n turns on, then a calm loop. */\nbody.wpve-frame-active::after {\n\tcontent: \"\";\n\tposition: fixed;\n\tinset: 0;\n\tpointer-events: none;\n\tz-index: 999998;\n\tborder: 1px solid rgba( 255, 110, 70, 0.35 );\n\tbox-shadow:\n\t\tinset 0 0 22px rgba( 255, 120, 70, 0.38 ),\n\t\tinset 0 0 90px rgba( 233, 80, 90, 0.24 );\n\tanimation:\n\t\twpve-frame-in 0.8s cubic-bezier( 0.2, 0.8, 0.2, 1 ),\n\t\twpve-frame 3.5s ease-in-out 0.8s infinite;\n}\n\n/* Turning on: swell, then settle. */\n@keyframes wpve-frame-in {\n\t0% {\n\t\tbox-shadow:\n\t\t\tinset 0 0 8px rgba( 255, 120, 70, 0.12 ),\n\t\t\tinset 0 0 36px rgba( 233, 80, 90, 0.08 );\n\t}\n\t40% {\n\t\tbox-shadow:\n\t\t\tinset 0 0 44px rgba( 255, 120, 70, 0.80 ),\n\t\t\tinset 0 0 170px rgba( 233, 80, 90, 0.55 );\n\t}\n\t100% {\n\t\tbox-shadow:\n\t\t\tinset 0 0 22px rgba( 255, 120, 70, 0.38 ),\n\t\t\tinset 0 0 90px rgba( 233, 80, 90, 0.24 );\n\t}\n}\n\n@keyframes wpve-frame {\n\t0%, 100% {\n\t\tbox-shadow:\n\t\t\tinset 0 0 22px rgba( 255, 120, 70, 0.38 ),\n\t\t\tinset 0 0 90px rgba( 233, 80, 90, 0.24 );\n\t}\n\t50% {\n\t\tbox-shadow:\n\t\t\tinset 0 0 30px rgba( 255, 120, 70, 0.55 ),\n\t\t\tinset 0 0 120px rgba( 233, 80, 90, 0.36 );\n\t}\n}\n\n/* For visitors who ask for less motion. */\n@media ( prefers-reduced-motion: reduce ) {\n\t#wpve-bubble.wpve-active,\n\t#wpve-bubble.wpve-active::after,\n\tbody.wpve-frame-active::after {\n\t\tanimation: none;\n\t}\n\t#wpve-bubble .wpve-knob { transition: none; }\n}\n\n/* What you can click, and what you are editing now. */\nbody.wpve-edit-mode .wpve-hover,\nbody.wpve-edit-mode .wpve-selected {\n\toutline: 2px solid #6d5efc !important;\n\toutline-offset: 3px;\n\tborder-radius: 2px;\n\tcursor: pointer !important;\n}\n\nbody.wpve-edit-mode .wpve-hover {\n\tbackground: rgba( 109, 94, 252, 0.06 );\n}\n\nbody.wpve-show-fields [data-vibe-field],\nbody.wpve-show-fields .wpve-auto-field {\n\toutline: 1px dashed rgba( 109, 94, 252, 0.7 );\n\toutline-offset: 2px;\n}\n\n/* ---------- Panels ---------- */\n\n.wpve-panel {\n\tposition: fixed;\n\tbox-sizing: border-box;\n\t/* Default spot; the script moves it next to the click. */\n\tright: 24px;\n\tbottom: 96px;\n\twidth: 380px;\n\tmax-width: calc( 100vw - 24px );\n\tmax-height: calc( 100vh - 24px );\n\tbackground: #fff;\n\tcolor: #1d1d28;\n\tborder-radius: 16px;\n\tbox-shadow: 0 16px 44px rgba( 20, 20, 50, 0.18 ), 0 0 0 1px rgba( 20, 20, 50, 0.07 );\n\tz-index: 1000000;\n\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\tfont-size: 14px;\n\tfont-weight: 400;\n\tline-height: 1.45;\n\tletter-spacing: normal;\n\ttext-align: left;\n\toverflow-x: hidden;\n\toverflow-y: auto;\n\tpadding-bottom: 14px;\n}\n\n.wpve-panel *,\n.wpve-panel *::before,\n.wpve-panel *::after {\n\tbox-sizing: border-box;\n}\n\n/*\n * The panel is injected into someone else's page, and themes style buttons,\n * inputs and textareas hard — usually with selectors more specific than a\n * single class. Neutralise what themes reach for here, then the rules below\n * restyle it. !important is deliberate on both layers: we cannot know the\n * host's selectors, and specificity still decides between our own rules.\n */\n.wpve-panel button,\n.wpve-panel textarea {\n\tmargin: 0 !important;\n\tpadding: 0 !important;\n\tborder: none !important;\n\tborder-radius: 0 !important;\n\tbackground: none !important;\n\tcolor: inherit !important;\n\tfont: inherit !important;\n\tline-height: normal !important;\n\tletter-spacing: normal !important;\n\ttext-transform: none !important;\n\ttext-shadow: none !important;\n\ttext-align: left !important;\n\tbox-shadow: none !important;\n\twidth: auto !important;\n\tmin-width: 0 !important;\n\tmax-width: none !important;\n\theight: auto !important;\n\tmin-height: 0 !important;\n\tmax-height: none !important;\n\tfloat: none !important;\n\tposition: static !important;\n\ttransform: none !important;\n\t-webkit-appearance: none !important;\n\tappearance: none !important;\n}\n\n.wpve-panel button::before,\n.wpve-panel button::after {\n\tcontent: none !important;\n}\n\n/* Titled panels (search engines, settings, price): a plain heading row. */\n.wpve-panel-head {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\tpadding: 14px 16px 12px;\n\tmargin-bottom: 4px;\n\tborder-bottom: 1px solid #eeeef4;\n\tcolor: #1d1d28;\n\tfont-size: 15px;\n\tfont-weight: 600;\n}\n\n.wpve-panel-head .wpve-close {\n\tdisplay: grid;\n\tplace-items: center;\n\twidth: 28px;\n\theight: 28px;\n\tmargin: -4px -6px -4px 0;\n\tborder-radius: 50%;\n\tcolor: #6b6b7b;\n\tfont-size: 20px;\n\tfont-weight: 400;\n\tline-height: 1;\n\tcursor: pointer;\n}\n\n.wpve-panel-head .wpve-close:hover {\n\tbackground: #f1f1f6;\n\tcolor: #1d1d28;\n}\n\n/* ---------- The command bar (text and image) ---------- */\n\n/*\n * Rows: the bar, a note, then the links (switch or \"more choices\" on the left,\n * Close on the right), a status line, the result. The mode switch comes before\n * the bar in the source so its state can style the bar after it.\n */\n.wpve-panel.wpve-cmd {\n\tdisplay: grid;\n\tgrid-template-columns: 1fr auto;\n\talign-items: start;\n\tpadding: 6px 6px 8px;\n\tborder-radius: 20px;\n}\n\n.wpve-cmd > .wpve-bar { grid-row: 1; grid-column: 1 / -1; }\n.wpve-cmd > .wpve-hint { grid-row: 2; grid-column: 1 / -1; }\n.wpve-cmd > .ai-toggle { grid-row: 3; grid-column: 1; }\n.wpve-cmd > .wpve-more { grid-row: 3; grid-column: 1 / -1; }\n.wpve-cmd > .wpve-close-link { grid-row: 3; grid-column: 2; z-index: 1; }\n.wpve-cmd > .wpve-status { grid-row: 4; grid-column: 1 / -1; }\n.wpve-cmd > .wpve-result { grid-row: 5; grid-column: 1 / -1; }\n\n.wpve-panel .wpve-bar {\n\tdisplay: flex;\n\talign-items: flex-end;\n\tgap: 8px;\n\tpadding: 4px 4px 4px 12px;\n\tborder-radius: 16px;\n\tbackground: #f6f6fb;\n\ttransition: background 0.15s ease, box-shadow 0.15s ease;\n}\n\n.wpve-panel .wpve-bar:focus-within {\n\tbackground: #fff;\n\tbox-shadow: 0 0 0 2px #6d5efc;\n}\n\n.wpve-panel .wpve-bar-icon {\n\tflex: none;\n\tdisplay: grid;\n\tplace-items: center;\n\twidth: 20px;\n\theight: 38px;\n\tcolor: #6d5efc;\n}\n\n.wpve-panel .wpve-bar-icon svg {\n\tgrid-area: 1 / 1;\n\twidth: 20px;\n\theight: 20px;\n}\n\n.wpve-panel .wpve-bar .wpve-prompt {\n\tflex: 1 1 auto !important;\n\twidth: auto !important;\n\tmin-width: 0 !important;\n\tmin-height: 38px !important;\n\tmax-height: 180px !important;\n\tmargin: 0 !important;\n\tpadding: 9px 0 !important;\n\tborder: none !important;\n\tborder-radius: 0 !important;\n\tbackground: transparent !important;\n\tcolor: #1d1d28 !important;\n\tfont: inherit !important;\n\tfont-size: 15px !important;\n\tline-height: 20px !important;\n\tresize: none !important;\n\toverflow-y: auto !important;\n\tscrollbar-width: none !important;\n\toutline: none !important;\n\tbox-shadow: none !important;\n}\n\n.wpve-panel .wpve-bar .wpve-prompt::-webkit-scrollbar {\n\tdisplay: none;\n}\n\n.wpve-panel .wpve-bar .wpve-prompt:focus {\n\tborder: none !important;\n\tbox-shadow: none !important;\n}\n\n.wpve-panel .wpve-bar .wpve-prompt::placeholder {\n\tcolor: #8a8a99;\n\topacity: 1;\n}\n\n/* The round send button. Its label is for screen readers; the icon is drawn here. */\n.wpve-panel .wpve-send {\n\tflex: none !important;\n\twidth: 38px !important;\n\theight: 38px !important;\n\tborder-radius: 50% !important;\n\tbackground: #6d5efc url( \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 19V5M5.5 11.5 12 5l6.5 6.5'/%3E%3C/svg%3E\" ) center / 18px 18px no-repeat !important;\n\tcolor: transparent !important;\n\tfont-size: 0 !important;\n\toverflow: hidden !important;\n\tcursor: pointer !important;\n\ttransition: background-color 0.15s ease, opacity 0.15s ease !important;\n}\n\n.wpve-panel .wpve-send:hover {\n\tbackground-color: #5b4be6 !important;\n}\n\n.wpve-panel .wpve-send:focus-visible {\n\toutline: 2px solid #6d5efc !important;\n\toutline-offset: 2px !important;\n}\n\n.wpve-panel .wpve-send:disabled {\n\topacity: 0.45 !important;\n\tcursor: default !important;\n}\n\n/* Typing it yourself: a pencil in front, a check mark to save. */\n.wpve-panel .wpve-bar-icon .wpve-when-manual,\n.wpve-panel .ai-toggle .wpve-when-manual {\n\tdisplay: none;\n}\n\n.wpve-panel .ai-toggle[data-mode=\"manual\"] ~ .wpve-bar .wpve-bar-icon .wpve-when-ai,\n.wpve-panel .ai-toggle[data-mode=\"manual\"] .wpve-when-ai {\n\tdisplay: none;\n}\n\n.wpve-panel .ai-toggle[data-mode=\"manual\"] ~ .wpve-bar .wpve-bar-icon .wpve-when-manual,\n.wpve-panel .ai-toggle[data-mode=\"manual\"] .wpve-when-manual {\n\tdisplay: inline;\n}\n\n.wpve-panel .ai-toggle[data-mode=\"manual\"] ~ .wpve-bar .wpve-bar-icon {\n\tcolor: #4a4a5a;\n}\n\n.wpve-panel .ai-toggle[data-mode=\"manual\"] ~ .wpve-bar .wpve-send {\n\tbackground-image: url( \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5'/%3E%3C/svg%3E\" ) !important;\n}\n\n/* Under the bar: the switch between asking AI and typing, and Close. */\n.wpve-panel .ai-toggle,\n.wpve-panel .wpve-close-link {\n\tjustify-self: start !important;\n\tmargin: 8px 0 0 !important;\n\tpadding: 4px 10px !important;\n\tborder-radius: 8px !important;\n\tcolor: #6b6b7b !important;\n\tfont-size: 13px !important;\n\tcursor: pointer !important;\n}\n\n.wpve-panel .ai-toggle {\n\tcolor: #5546e0 !important;\n}\n\n.wpve-panel .wpve-close-link {\n\tjustify-self: end !important;\n}\n\n.wpve-panel .ai-toggle:hover,\n.wpve-panel .wpve-close-link:hover {\n\tbackground: #f1f0fb !important;\n}\n\n.wpve-panel .ai-toggle:focus-visible,\n.wpve-panel .wpve-close-link:focus-visible {\n\toutline: 2px solid #6d5efc !important;\n\toutline-offset: 1px !important;\n}\n\n/* More choices for an image: a link that opens the rest. */\n.wpve-more {\n\tmargin: 8px 0 0;\n}\n\n.wpve-more > summary {\n\tdisplay: inline-block;\n\tpadding: 4px 10px;\n\tborder-radius: 8px;\n\tcolor: #5546e0;\n\tfont-size: 13px;\n\tcursor: pointer;\n\tlist-style: none;\n}\n\n.wpve-more > summary::-webkit-details-marker {\n\tdisplay: none;\n}\n\n.wpve-more > summary:hover {\n\tbackground: #f1f0fb;\n}\n\n.wpve-more[open] > summary {\n\tcolor: #6b6b7b;\n}\n\n.wpve-more-body {\n\tmargin: 4px -6px 0;\n\tpadding: 2px 0 6px;\n\tborder-top: 1px solid #eeeef4;\n}\n\n.wpve-cmd .wpve-result:empty,\n.wpve-cmd .wpve-status:empty {\n\tdisplay: none;\n}\n\n.wpve-cmd > .wpve-result,\n.wpve-cmd > .wpve-status {\n\tmargin: 6px -6px 0;\n\tpadding-top: 2px;\n\tborder-top: 1px solid #eeeef4;\n}\n\n.wpve-cmd > .wpve-result .wpve-actions,\n.wpve-cmd > .wpve-status .wpve-actions {\n\tmargin-bottom: 6px !important;\n}\n\n/* ---------- Shared pieces ---------- */\n\n/* A plain field outside the bar (price, page title, API keys). */\n.wpve-panel .wpve-prompt {\n\tdisplay: block !important;\n\twidth: calc( 100% - 32px ) !important;\n\tmargin: 0 16px !important;\n\tpadding: 9px 12px !important;\n\tborder: 1px solid #dcdce6 !important;\n\tborder-radius: 10px !important;\n\tbackground: #fff !important;\n\tcolor: #1d1d28 !important;\n\tfont: inherit !important;\n\tresize: vertical !important;\n}\n\n.wpve-panel .wpve-prompt:focus {\n\toutline: none !important;\n\tborder-color: #6d5efc !important;\n\tbox-shadow: 0 0 0 3px rgba( 109, 94, 252, 0.16 ) !important;\n}\n\n/* Single-line fields keep their own height, whatever the theme says. */\n.wpve-panel input.wpve-prompt {\n\theight: 40px !important;\n\tmin-height: 0 !important;\n}\n\n.wpve-panel .wpve-actions {\n\tdisplay: flex !important;\n\tflex-wrap: wrap !important;\n\tgap: 8px !important;\n\tmargin: 12px 16px 0 !important;\n}\n\n.wpve-panel .wpve-btn {\n\tpadding: 9px 16px !important;\n\tborder-radius: 999px !important;\n\tbackground: #6d5efc !important;\n\tcolor: #fff !important;\n\tfont: inherit !important;\n\tfont-weight: 600 !important;\n\tcursor: pointer !important;\n\ttransition: background-color 0.15s ease !important;\n}\n\n.wpve-panel .wpve-btn:hover {\n\tbackground: #5b4be6 !important;\n}\n\n.wpve-panel .wpve-btn:focus-visible {\n\toutline: 2px solid #6d5efc !important;\n\toutline-offset: 2px !important;\n}\n\n.wpve-panel .wpve-btn:disabled {\n\topacity: 0.55 !important;\n\tcursor: default !important;\n}\n\n/* Second in line beside a save button: asking AI for a suggestion. */\n.wpve-panel .wpve-btn.wpve-page-ai {\n\tbackground: #f1f0fb !important;\n\tcolor: #4b3dd6 !important;\n}\n\n.wpve-panel .wpve-btn.wpve-page-ai:hover {\n\tbackground: #e6e3fb !important;\n}\n\n.wpve-panel .wpve-cancel-link {\n\tbackground: transparent !important;\n\tcolor: #55555f !important;\n\tfont-weight: 500 !important;\n}\n\n.wpve-panel .wpve-cancel-link:hover {\n\tbackground: #f1f1f6 !important;\n}\n\n.wpve-result {\n\tmargin-top: 4px;\n}\n\n.wpve-field-label,\n.wpve-diff-label {\n\tmargin: 14px 16px 6px;\n\tcolor: #6b6b7b;\n\tfont-size: 12.5px;\n\tfont-weight: 600;\n}\n\n.wpve-suggestion {\n\tmargin: 0 16px;\n\tpadding: 10px 12px;\n\tbackground: #f5f4ff;\n\tborder: 1px solid #e0dcfd;\n\tborder-radius: 12px;\n\tcolor: #1d1d28;\n\tfont-size: 15px;\n\twhite-space: pre-wrap;\n}\n\n.wpve-loading {\n\tmargin: 12px 16px 8px;\n\tcolor: #5546e0;\n}\n\n.wpve-error {\n\tmargin: 12px 16px 6px;\n\tpadding: 9px 12px;\n\tbackground: #fdeeee;\n\tborder: 1px solid #f3c6c6;\n\tborder-radius: 10px;\n\tcolor: #a61b1b;\n}\n\n.wpve-hint {\n\tmargin: 8px 16px 0;\n\tpadding: 9px 12px;\n\tbackground: #f6f6fb;\n\tborder-radius: 10px;\n\tcolor: #55555f;\n\tfont-size: 13px;\n}\n\n.wpve-cmd > .wpve-hint {\n\tmargin: 8px 0 0;\n}\n\n/* Toast */\n.wpve-toast {\n\tposition: fixed;\n\tleft: 50%;\n\tbottom: 32px;\n\ttransform: translateX( -50% );\n\tmax-width: calc( 100vw - 32px );\n\tbackground: #1d1d28;\n\tcolor: #fff;\n\tpadding: 10px 18px;\n\tborder-radius: 999px;\n\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\tfont-size: 14px;\n\tline-height: 1.4;\n\tz-index: 1000001;\n\tbox-shadow: 0 8px 24px rgba( 20, 20, 50, 0.28 );\n\topacity: 1;\n\ttransition: opacity 0.4s ease;\n}\n\n.wpve-toast-out {\n\topacity: 0;\n}\n\n/* Media library */\n.wpve-media-grid {\n\tdisplay: grid;\n\tgrid-template-columns: repeat( 3, 1fr );\n\tgap: 6px;\n\tmargin: 0 16px;\n\tmax-height: 280px;\n\toverflow-y: auto;\n}\n\n.wpve-panel .wpve-media-item {\n\tpadding: 0 !important;\n\tborder: 1px solid #e4e4ee !important;\n\tborder-radius: 10px !important;\n\tbackground: #f6f6fb !important;\n\tcursor: pointer !important;\n\toverflow: hidden !important;\n\taspect-ratio: 1 / 1 !important;\n}\n\n.wpve-panel .wpve-media-item:hover {\n\tborder-color: #6d5efc !important;\n}\n\n.wpve-media-item img {\n\twidth: 100%;\n\theight: 100%;\n\tobject-fit: cover;\n\tdisplay: block;\n}\n\n/* A one-line field with its button (image address, alt text). */\n.wpve-url-row {\n\tdisplay: flex;\n\tgap: 6px;\n\tmargin: 0 16px;\n}\n\n.wpve-panel .wpve-url-input {\n\tflex: 1;\n\tmin-width: 0;\n\theight: 36px;\n\tmargin: 0;\n\tpadding: 0 12px;\n\tborder: 1px solid #dcdce6;\n\tborder-radius: 10px;\n\tfont: inherit;\n\tfont-size: 13.5px;\n\tcolor: #1d1d28;\n\tbackground: #fff;\n\tbox-shadow: none;\n}\n\n.wpve-panel .wpve-url-input:focus {\n\toutline: none;\n\tborder-color: #6d5efc;\n\tbox-shadow: 0 0 0 3px rgba( 109, 94, 252, 0.16 );\n}\n\n.wpve-panel .wpve-url-btn {\n\tflex: 0 0 auto !important;\n\theight: 36px !important;\n\tpadding: 0 14px !important;\n\tborder-radius: 10px !important;\n\tbackground: #f1f0fb !important;\n\tcolor: #4b3dd6 !important;\n\tfont: inherit !important;\n\tfont-size: 13.5px !important;\n\tfont-weight: 600 !important;\n\tcursor: pointer !important;\n}\n\n.wpve-panel .wpve-url-btn:hover {\n\tbackground: #e6e3fb !important;\n}\n\n.wpve-panel .wpve-url-btn:disabled {\n\topacity: 0.55 !important;\n\tcursor: default !important;\n}\n\n/* ---------- The small buttons above the edit switch ---------- */\n\n#wpve-settings-btn,\n#wpve-page-btn {\n\tposition: fixed;\n\tbox-sizing: border-box;\n\tright: 26px;\n\tbottom: 76px;\n\twidth: 36px;\n\theight: 36px;\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 1px solid #e4e4ee;\n\tborder-radius: 50%;\n\tbackground: #fff;\n\tcolor: #3a3a4a;\n\tcursor: pointer;\n\tz-index: 999999;\n\tdisplay: none; /* shown by the script while editing */\n\talign-items: center;\n\tjustify-content: center;\n\tbox-shadow: 0 4px 14px rgba( 20, 20, 50, 0.14 );\n\ttransition: color 0.15s ease, border-color 0.15s ease;\n}\n\n#wpve-settings-btn svg,\n#wpve-page-btn svg {\n\twidth: 18px;\n\theight: 18px;\n}\n\n#wpve-settings-btn:hover,\n#wpve-page-btn:hover {\n\tcolor: #5546e0;\n\tborder-color: #cfcaf8;\n}\n\n#wpve-settings-btn:focus-visible,\n#wpve-page-btn:focus-visible {\n\toutline: 2px solid #6d5efc;\n\toutline-offset: 2px;\n}\n\n#wpve-page-btn {\n\tbottom: 120px;\n}\n\n/* ---------- Search engines panel ---------- */\n\n.wpve-count {\n\tfloat: right;\n\tcolor: #9a9aa8;\n\tfont-weight: 400;\n}\n\n.wpve-count-long {\n\tcolor: #c0392b;\n}\n\n.wpve-page-panel .wpve-result:empty {\n\tdisplay: none;\n}\n\n.wpve-page-panel .wpve-result {\n\tmargin-bottom: 14px;\n}\n\n/* ---------- Settings panel ---------- */\n\n.wpve-settings {\n\ttop: auto;\n\tleft: auto;\n\tright: 24px;\n\tbottom: 150px;\n\twidth: 280px;\n}\n\n.wpve-setting {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 9px;\n\tmargin: 12px 16px 0;\n\tcursor: pointer;\n}\n\n.wpve-setting input {\n\twidth: 16px;\n\theight: 16px;\n\tmargin: 0;\n\taccent-color: #6d5efc;\n}\n\n.wpve-setting-select {\n\tjustify-content: space-between;\n\tcursor: default;\n}\n\n.wpve-setting-select > span {\n\tcolor: #3a3a4a;\n}\n\n.wpve-setting-select select,\n.wpve-pos-row select {\n\tpadding: 6px 8px;\n\tborder: 1px solid #dcdce6;\n\tborder-radius: 8px;\n\tbackground: #fff;\n\tfont: inherit;\n\tcolor: #1d1d28;\n\tcursor: pointer;\n}\n\n.wpve-setting-select select {\n\tflex: 0 0 auto;\n\tmin-width: 140px;\n}\n\n.wpve-custom-pos {\n\tmargin: 10px 16px 0;\n\tpadding: 10px 12px;\n\tbackground: #f6f6fb;\n\tborder-radius: 10px;\n}\n\n.wpve-pos-row {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tmargin-top: 8px;\n}\n\n.wpve-pos-row:first-child {\n\tmargin-top: 0;\n}\n\n.wpve-pos-row > span {\n\tflex: 0 0 60px;\n\tcolor: #3a3a4a;\n\tfont-size: 13px;\n}\n\n.wpve-pos-row select {\n\tflex: 1 1 auto;\n}\n\n.wpve-pos-row input[type=\"number\"] {\n\tflex: 0 0 64px;\n\twidth: 64px;\n\tpadding: 6px 8px;\n\tborder: 1px solid #dcdce6;\n\tborder-radius: 8px;\n\tfont: inherit;\n\tcolor: #1d1d28;\n}\n\n.wpve-pos-row em {\n\tflex: 0 0 auto;\n\tfont-style: normal;\n\tcolor: #8a8a99;\n\tfont-size: 12px;\n}\n\n.wpve-setting-note {\n\tmargin: 12px 16px 0;\n\tfont-size: 12px;\n\tcolor: #8a8a99;\n}\n\n/* Signing out, last in the settings, where the editor has its own sign-in. */\n.wpve-signout {\n\tmargin: 14px 16px 0;\n\tpadding-top: 8px;\n\tborder-top: 1px solid #eeeef4;\n}\n\n.wpve-panel .wpve-signout-btn {\n\tdisplay: flex !important;\n\talign-items: center !important;\n\tgap: 9px !important;\n\t/* The hover fill reaches past the text, so the text lines up with the settings above. */\n\twidth: calc( 100% + 16px ) !important;\n\tmargin: 0 -8px !important;\n\tpadding: 8px !important;\n\tborder-radius: 10px !important;\n\tcolor: #55555f !important;\n\tfont-weight: 500 !important;\n\tcursor: pointer !important;\n\ttransition: background-color 0.15s ease, color 0.15s ease !important;\n}\n\n.wpve-panel .wpve-signout-btn svg {\n\tflex: none;\n\twidth: 16px;\n\theight: 16px;\n}\n\n.wpve-panel .wpve-signout-btn:hover {\n\tbackground: #f1f1f6 !important;\n\tcolor: #1d1d28 !important;\n}\n\n.wpve-panel .wpve-signout-btn:focus-visible {\n\toutline: 2px solid #6d5efc !important;\n\toutline-offset: 2px !important;\n}\n\n.wpve-panel .wpve-signout-btn:disabled {\n\topacity: 0.55 !important;\n\tcursor: default !important;\n}\n\n/* API key fields (write-only). */\n.wpve-key {\n\tmargin: 14px 16px 0;\n\tpadding-top: 12px;\n\tborder-top: 1px solid #eeeef4;\n}\n\n.wpve-key-label {\n\tmargin-bottom: 6px;\n\tcolor: #6b6b7b;\n\tfont-size: 12.5px;\n\tfont-weight: 600;\n}\n\n.wpve-key-status {\n\tfont-size: 13px;\n\tmargin-bottom: 8px;\n\tcolor: #55555f;\n}\n\n.wpve-key-status.wpve-key-set {\n\tcolor: #1b8a4b;\n}\n\n.wpve-key-status.wpve-key-unset {\n\tcolor: #b4690e;\n}\n\n.wpve-panel .wpve-key-form .wpve-prompt {\n\twidth: 100% !important;\n\tmargin: 0 !important;\n\tresize: none !important;\n}\n\n.wpve-panel .wpve-key-form .wpve-actions {\n\tmargin: 8px 0 0 !important;\n}\n\n.wpve-key-msg {\n\tfont-size: 12px;\n\tmargin-top: 8px;\n}\n\n.wpve-key-msg.wpve-key-ok {\n\tcolor: #1b8a4b;\n}\n\n.wpve-key-msg.wpve-error {\n\tmargin: 8px 0 0;\n\tpadding: 0;\n\tbackground: none;\n\tborder: none;\n}\n\n/* ---------- Image being generated ---------- */\n\n/* Laid over the image while AI works on it, so it is clear that something is happening. */\n.wpve-img-loader {\n\tposition: fixed;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 12px;\n\tbackground: rgba( 255, 255, 255, 0.55 );\n\t-webkit-backdrop-filter: blur( 2px );\n\tbackdrop-filter: blur( 2px );\n\tz-index: 999990;\n\tpointer-events: none;\n\tborder-radius: 4px;\n\toverflow: hidden;\n\t/* A pulsing edge in the same warm fade as the page glow. */\n\tborder: 2px solid rgba( 255, 120, 70, 0.5 );\n\tbox-sizing: border-box;\n\tanimation: wpve-img-pulse 1.8s ease-in-out infinite;\n}\n\n/* The warm pulse around the image. */\n@keyframes wpve-img-pulse {\n\t0%, 100% {\n\t\tborder-color: rgba( 255, 120, 70, 0.5 );\n\t\tbox-shadow:\n\t\t\tinset 0 0 26px rgba( 255, 120, 70, 0.45 ),\n\t\t\tinset 0 0 70px rgba( 233, 80, 90, 0.3 ),\n\t\t\t0 0 14px rgba( 255, 110, 70, 0.35 );\n\t}\n\t50% {\n\t\tborder-color: rgba( 233, 64, 110, 0.78 );\n\t\tbox-shadow:\n\t\t\tinset 0 0 42px rgba( 255, 120, 70, 0.78 ),\n\t\t\tinset 0 0 120px rgba( 233, 80, 90, 0.5 ),\n\t\t\t0 0 28px rgba( 233, 64, 110, 0.55 );\n\t}\n}\n\n/* A turning ring in the brand fade (violet, pink, orange). */\n.wpve-spinner {\n\twidth: 54px;\n\theight: 54px;\n\tborder-radius: 50%;\n\tbackground: conic-gradient( from 0deg, #6d5efc, #e9406e, #ff9638, #6d5efc );\n\t-webkit-mask: radial-gradient( farthest-side, transparent calc( 100% - 6px ), #000 calc( 100% - 6px ) );\n\tmask: radial-gradient( farthest-side, transparent calc( 100% - 6px ), #000 calc( 100% - 6px ) );\n\tanimation: wpve-spin 0.9s linear infinite;\n}\n\n.wpve-img-loader span {\n\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n\tfont-size: 13px;\n\tfont-weight: 600;\n\tcolor: #6d5efc;\n\ttext-shadow: 0 1px 2px rgba( 255, 255, 255, 0.8 );\n}\n\n@keyframes wpve-spin {\n\tto { transform: rotate( 360deg ); }\n}\n\n@media ( prefers-reduced-motion: reduce ) {\n\t.wpve-spinner { animation: wpve-spin 2.4s linear infinite; }\n\t.wpve-img-loader {\n\t\tanimation: none;\n\t\tborder-color: rgba( 233, 64, 110, 0.6 );\n\t\tbox-shadow: inset 0 0 30px rgba( 255, 120, 70, 0.5 );\n\t}\n}\n\n/* A narrow screen: the panel spans the width, near the bottom. */\n@media ( max-width: 480px ) {\n\t.wpve-panel {\n\t\twidth: calc( 100vw - 24px );\n\t}\n}\n";
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * they never hold a key.
11
11
  */
12
12
  import type { VibeEditorOptions } from './types.js';
13
- export type { VibeEditorOptions, EditableField } from './types.js';
13
+ export type { VibeEditorOptions, EditableField, BlockFieldDeclaration, BlockTextField } from './types.js';
14
14
  export declare function vibeEditor(options?: VibeEditorOptions): {
15
15
  id: string;
16
16
  version: string;