diff --git a/.wordpress-org/screenshot-1.png b/.wordpress-org/screenshot-1.png index cc759d4..3c521df 100644 Binary files a/.wordpress-org/screenshot-1.png and b/.wordpress-org/screenshot-1.png differ diff --git a/.wordpress-org/screenshot-2.png b/.wordpress-org/screenshot-2.png index 0deede6..28db0d6 100644 Binary files a/.wordpress-org/screenshot-2.png and b/.wordpress-org/screenshot-2.png differ diff --git a/.wordpress-org/screenshot-3.png b/.wordpress-org/screenshot-3.png index 3c86e09..4bc6352 100644 Binary files a/.wordpress-org/screenshot-3.png and b/.wordpress-org/screenshot-3.png differ diff --git a/.wordpress-org/screenshot-4.png b/.wordpress-org/screenshot-4.png index 6dd5beb..9dd6e33 100644 Binary files a/.wordpress-org/screenshot-4.png and b/.wordpress-org/screenshot-4.png differ diff --git a/.wordpress-org/screenshot-5.png b/.wordpress-org/screenshot-5.png index ca36757..0deede6 100644 Binary files a/.wordpress-org/screenshot-5.png and b/.wordpress-org/screenshot-5.png differ diff --git a/.wordpress-org/screenshot-6.png b/.wordpress-org/screenshot-6.png index a6335c0..3c86e09 100644 Binary files a/.wordpress-org/screenshot-6.png and b/.wordpress-org/screenshot-6.png differ diff --git a/PLAN.md b/PLAN.md index c88875a..5aaf336 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,10 +33,13 @@ build step. Themes are token sets; the renderer only ever reads tokens. ## 1. Non-negotiables -- **OpenStation is optional.** Every shell call sits behind a `function_exists()` - gate resolved through `includes/shell-api.php` (same pattern as AllTerrain - Work). No shell → the builder renders on a plain admin page and the front-end - is untouched. No `Requires Plugins:` header. +- **OpenStation is required.** `Requires Plugins: desktop-mode` in the header; + WordPress 6.5+ enforces it at activation and blocks deactivating the shell + underneath us. The builder is a native shell window — the desktop *is* the + product. `includes/shell-api.php`'s `function_exists()` gates stay as defense + in depth (older WP, force-removed shell): admin shows a notice, the front end + keeps rendering published forms. *(Changed 2026-08-19; was "optional" through + 0.1.0.)* - **Everything is a post.** A form is a post, an entry is a post, an entry note is a *comment*, a theme is a post. No bespoke tables. That buys REST, `current_user_can()`, revisions, search, trash, the privacy exporter/eraser diff --git a/README.md b/README.md index 3ff9a43..53f2414 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,15 @@ is a post. Nothing lives in a bespoke table, so the REST API, `current_user_can()`, revisions, search, the trash, and WordPress's own privacy exporter and eraser already work on this data without a line of integration code. -**OpenStation is optional.** Every shell call sits behind a `function_exists()` -gate, so a site without the shell loses the desktop affordances and keeps a -complete forms plugin under **Forms** in wp-admin. There is deliberately no -`Requires Plugins:` header. +**OpenStation is required.** The plugin declares `Requires Plugins: +desktop-mode`, so WordPress 6.5+ installs and activates the shell alongside it +and refuses to pull the shell out from underneath it. The builder is a native +shell window — the desktop is the product, not a skin on it — so the plugin +does not ship a lesser version of itself for sites without one. The +`function_exists()` gates in `includes/shell-api.php` remain as defense in +depth for half-upgraded sites: without the shell an admin notice says what is +missing, and published forms keep rendering so no visitor ever pays for an +admin dependency. --- @@ -82,7 +87,6 @@ renderer, same stylesheet, same theme resolution, so what you see is what a visitor gets. Save, and the preview window refreshes itself. Drag a field, watch it change, drag another; the builder never closes. -Without OpenStation the same button opens the same URL in a tab. --- @@ -236,6 +240,33 @@ separate place. --- +## Nine abilities, so AI agents can use the forms + +The plugin registers its whole working surface with WordPress's Abilities API, +which is how a site describes what it can do to agents and MCP clients. Each +ability is a thin adapter over the same functions the windows and the REST API +call — an agent and a human clicking the same button get the same behaviour, +the same validation and the same capability checks. + +| Ability | What it does | +|---|---| +| `list-forms` | Every form with id, title, theme, shortcode, entry count and questions — the ids feed everything else | +| `get-form` | One form's questions: field ids, types, labels, required flags, choices | +| `list-field-types` | The building vocabulary: all 37 types and what each stores | +| `create-form` | Builds a form from a title and a loose field list; returns the shortcode | +| `set-form-theme` | Dresses a form in any installed theme | +| `submit-form` | Submits through the visitor pipeline — validation, anti-spam, storage, notifications; refusals return per-field errors | +| `list-entries` | Queries submissions — search, date range, status, pagination — raw and human-readable | +| `get-entry` | One submission, every answer labelled and formatted | +| `form-report` | The analytics as structured data: conversion, timeline, distributions, NPS, group-by breakdowns | + +Ask an assistant to *"make a booking form and show me last week's responses"*, +and these are what it calls. The full contract — permissions, schemas, and the +one honest liberty `submit-form` takes with the time trap — is in +[`docs/abilities.md`](docs/abilities.md). + +--- + ## Accessibility Not a section that says "we care about accessibility". The things that were @@ -409,6 +440,8 @@ npm run test:php # 465 PHP tests, inside wp-env ## Documentation - [`docs/hooks-reference.md`](docs/hooks-reference.md) — every action and filter +- [`docs/abilities.md`](docs/abilities.md) — the nine WordPress Abilities AI + agents use the forms through: build, theme, submit, query, report - [`docs/field-types.md`](docs/field-types.md) — adding a field type - [`docs/themes.md`](docs/themes.md) — the token surface, and adding a token - [`docs/javascript.md`](docs/javascript.md) — events, drag payloads, the bundles diff --git a/allterrain-forms.php b/allterrain-forms.php index 2cda2f8..0804a3e 100644 --- a/allterrain-forms.php +++ b/allterrain-forms.php @@ -4,8 +4,9 @@ * Plugin URI: https://github.com/AllTerrainDeveloper/forms * Description: Forms for WordPress with every premium feature free — conditional logic, calculations, multi-page, file uploads, signatures, repeaters, entry management, ten themes — built as an OpenStation desktop app with a drag-and-drop builder. * Version: 0.1.0 - * Requires at least: 6.0 + * Requires at least: 6.5 * Requires PHP: 7.4 + * Requires Plugins: desktop-mode * Author: Daniel Lopez * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -40,10 +41,14 @@ * privacy exporter and eraser already work on this data without a line of * integration code. * - * OpenStation is **optional**. Every shell call sits behind a `function_exists()` - * gate resolved through `includes/shell-api.php`, so a site without the shell - * loses the desktop affordances and keeps a complete forms plugin. That is why - * there is deliberately no `Requires Plugins:` header. + * OpenStation is **required** — the `Requires Plugins: desktop-mode` header + * says so, and WordPress 6.5+ enforces it at activation. The builder is a + * native shell window; the desktop is the product, not a skin on it. Every + * shell call still resolves through `includes/shell-api.php`'s + * `function_exists()` gates, but as defense in depth for half-upgraded sites + * rather than as a supported mode: without the shell, an admin notice says + * what is missing, visitors' published forms keep rendering so nobody's + * front end breaks, and nothing else pretends to work. * * @package AllTerrain_Forms */ @@ -189,6 +194,7 @@ require_once ATF_DIR . 'includes/importer-gravityforms.php'; require_once ATF_DIR . 'includes/import-notice.php'; require_once ATF_DIR . 'includes/rest.php'; +require_once ATF_DIR . 'includes/abilities.php'; require_once ATF_DIR . 'includes/preview.php'; require_once ATF_DIR . 'includes/shortcode.php'; require_once ATF_DIR . 'includes/block.php'; diff --git a/assets/css/builder.css b/assets/css/builder.css index 0096abf..ed3e7ec 100644 --- a/assets/css/builder.css +++ b/assets/css/builder.css @@ -1303,7 +1303,8 @@ them stacked on themselves rather than on a page. */ .atfs-preview__frame:has( .atf-is-dark ), .atfb-preview:has( .atf-is-dark ), -.atf-block-preview:has( .atf-is-dark ) { +.atf-block-preview:has( .atf-is-dark ), +.atfb--dark-form .atfb-preview { background: #101418; color: #f0f0f1; } diff --git a/assets/css/form.css b/assets/css/form.css index 68a06ac..ee131b1 100644 --- a/assets/css/form.css +++ b/assets/css/form.css @@ -191,26 +191,95 @@ apart without JavaScript — which is why a floating theme needs a placeholder, and why the space is reserved with a non-breaking placeholder when there isn't one. */ -.atf-labels-floating .atf-field { +/* Floating is only offered where a label *can* float: a single control the + label sits inside of and lifts out of. A radio group's legend, a scale's + heading or a signature pad's label has nothing to float into, and floating + them anyway painted the question straight across its own answers — so every + other type simply keeps its label on top, unstyled by this block. + + Two sub-cases inside the floatable set: + - Text-shaped inputs lift on focus or once they hold a value, told apart by + `:placeholder-shown` — the reserved single-space placeholder in + `atf_control_attributes()` is what makes that selector answerable. + - Selects and date/time inputs always display something, so + `:placeholder-shown` never matches and their label is simply always + lifted — which is the only honest float for a control that is never + visually empty. + + The lift raises the label into headroom reserved above the input rather + than parking it astride the input's top border: a border-straddling label + needs a background mask, and no one mask survives ten themes of gradients + and glass. */ +.atf-labels-floating + .atf-field:is( + .atf-field--text, + .atf-field--email, + .atf-field--tel, + .atf-field--url, + .atf-field--number, + .atf-field--password, + .atf-field--textarea, + .atf-field--select, + .atf-field--country, + .atf-field--date, + .atf-field--time, + .atf-field--datetime + ) { position: relative; - padding-block-start: 0; -} - -.atf-labels-floating .atf-label { + padding-block-start: calc( var( --atf-size-label ) * 1.3 ); +} + +.atf-labels-floating + .atf-field:is( + .atf-field--text, + .atf-field--email, + .atf-field--tel, + .atf-field--url, + .atf-field--number, + .atf-field--password, + .atf-field--textarea, + .atf-field--select, + .atf-field--country, + .atf-field--date, + .atf-field--time, + .atf-field--datetime + ) + > .atf-label { position: absolute; - inset-block-start: var( --atf-pad-field-y ); + inset-block-start: calc( var( --atf-size-label ) * 1.3 + var( --atf-pad-field-y ) ); inset-inline-start: calc( var( --atf-pad-field-x ) + var( --atf-border-width ) ); + max-inline-size: calc( 100% - 2 * var( --atf-pad-field-x ) ); + overflow: hidden; margin: 0; pointer-events: none; - transform-origin: left top; - transition: transform var( --atf-transition-duration ) var( --atf-transition-easing ), + white-space: nowrap; + text-overflow: ellipsis; + transform-origin: 0 0; + transition: inset-block-start var( --atf-transition-duration ) var( --atf-transition-easing ), + transform var( --atf-transition-duration ) var( --atf-transition-easing ), color var( --atf-transition-duration ) var( --atf-transition-easing ); color: var( --atf-text-muted ); } -.atf-labels-floating .atf-input:not( :placeholder-shown ) ~ .atf-label, -.atf-labels-floating .atf-input:focus ~ .atf-label { - transform: translateY( -0.85em ) scale( 0.78 ); +.atf-labels-floating + .atf-field:is( + .atf-field--text, + .atf-field--email, + .atf-field--tel, + .atf-field--url, + .atf-field--number, + .atf-field--password, + .atf-field--textarea, + .atf-field--select, + .atf-field--country, + .atf-field--date, + .atf-field--time, + .atf-field--datetime + ):is( :has( > .atf-input:focus ), :has( > .atf-input:not( :placeholder-shown ) ) ) + > .atf-label { + inset-block-start: 0; + inset-inline-start: 0; + transform: scale( 0.82 ); color: var( --atf-accent ); } diff --git a/assets/js/analytics.js b/assets/js/analytics.js index fee961f..fe6fcdb 100644 --- a/assets/js/analytics.js +++ b/assets/js/analytics.js @@ -86,13 +86,20 @@ var allTerrainFormsAnalytics = function(exports) { const string = search.toString(); return string ? `?${string}` : ""; } + function withObjectOverrides(form) { + const settings = form?.schema?.settings; + if (settings && (!settings.themeOverrides || Array.isArray(settings.themeOverrides))) { + settings.themeOverrides = { ...settings.themeOverrides }; + } + return form; + } const api = { config: () => get("/config"), listForms: () => get("/forms"), - getForm: (id) => get(`/forms/${id}`), - createForm: (body) => post("/forms", body), - updateForm: (id, body) => post(`/forms/${id}`, body), - duplicateForm: (id) => post(`/forms/${id}/duplicate`, {}), + getForm: (id) => get(`/forms/${id}`).then(withObjectOverrides), + createForm: (body) => post("/forms", body).then(withObjectOverrides), + updateForm: (id, body) => post(`/forms/${id}`, body).then(withObjectOverrides), + duplicateForm: (id) => post(`/forms/${id}/duplicate`, {}).then(withObjectOverrides), deleteForm: (id) => del(`/forms/${id}`), preview: (id, body) => post(`/forms/${id}/preview`, body), /** diff --git a/assets/js/analytics.min.js b/assets/js/analytics.min.js index 7d01874..3642a40 100644 --- a/assets/js/analytics.min.js +++ b/assets/js/analytics.min.js @@ -1 +1 @@ -var allTerrainFormsAnalytics=function(p){"use strict";function A(){return window.wp?.os??null}const c=window.allTerrainForms;class y extends Error{constructor(e,t,a=""){super(e),this.name="ApiError",this.status=t,this.code=a}}function T(s,e){return s.includes("?")?`${s}${e.replace("?","&")}`:`${s}${e}`}async function M(s,e={}){if(!c?.restUrl)throw new y("AllTerrain Forms is not configured on this page.",0);const t=T(c.restUrl,s),a={"Content-Type":"application/json",...e.headers??{}};c.nonce&&(a["X-WP-Nonce"]=c.nonce);const o=A(),r=await(o?.fetch?(l,u)=>o.fetch(l,u,{source:"allterrain-forms"}):(l,u)=>fetch(l,u))(t,{credentials:"same-origin",...e,headers:a});if(!r.ok){let l=`Request failed with status ${r.status}.`,u="";try{const h=await r.json();l=h.message??l,u=h.code??""}catch{}throw new y(l,r.status,u)}if(r.status!==204)return await r.json()}const d=s=>M(s);async function D(s){if(!c?.wpRestUrl)throw new y("AllTerrain Forms is not configured on this page.",0);const e=c.nonce?{"X-WP-Nonce":c.nonce}:{},t=A(),a=T(c.wpRestUrl,s),o=t?.fetch?await t.fetch(a,{credentials:"same-origin",headers:e},{source:"allterrain-forms"}):await fetch(a,{credentials:"same-origin",headers:e});if(!o.ok)throw new y(`Request failed with status ${o.status}.`,o.status);return await o.json()}function O(s){if(!s||!s.includes("&"))return s;const e=document.createElement("textarea");return e.innerHTML=s,e.value}const m=(s,e)=>M(s,{method:"POST",body:JSON.stringify(e)}),v=s=>M(s,{method:"DELETE"});function C(s){const e=new URLSearchParams;for(const[a,o]of Object.entries(s))o===void 0||o===""||o===!1||e.set(a,String(o));const t=e.toString();return t?`?${t}`:""}const f={config:()=>d("/config"),listForms:()=>d("/forms"),getForm:s=>d(`/forms/${s}`),createForm:s=>m("/forms",s),updateForm:(s,e)=>m(`/forms/${s}`,e),duplicateForm:s=>m(`/forms/${s}/duplicate`,{}),deleteForm:s=>v(`/forms/${s}`),preview:(s,e)=>m(`/forms/${s}/preview`,e),pages:()=>D("wp/v2/pages?per_page=100&status=publish&orderby=title&order=asc&_fields=id,title").then(s=>s.map(e=>({id:e.id,title:O(e.title?.rendered??"")||`#${e.id}`}))),mergeTags:s=>d(`/forms/${s}/merge-tags`).then(e=>e.groups),analytics:(s,e="")=>d(`/forms/${s}/analytics${e?`?dimension=${encodeURIComponent(e)}`:""}`),demoStatus:()=>d("/demo"),demoSeed:s=>m("/demo",s?{count:s}:{}),demoRemove:()=>v("/demo"),listEntries:s=>d(`/entries${C(s)}`),getEntry:s=>d(`/entries/${s}`),updateEntry:(s,e)=>m(`/entries/${s}`,e),deleteEntry:s=>v(`/entries/${s}`),exportEntries:s=>d(`/entries/export${C(s)}`),listThemes:()=>d("/themes"),saveTheme:s=>m("/themes",s),deleteTheme:s=>v(`/themes/${s}`)};function n(s,e={}){const t=document.createElement(s);e.class&&(t.className=e.class),e.text!==void 0&&(t.textContent=e.text),e.html!==void 0&&(t.innerHTML=e.html),e.title&&(t.title=e.title),e.type&&"type"in t&&(t.type=e.type),e.value!==void 0&&"value"in t&&(t.value=e.value),e.placeholder!==void 0&&"placeholder"in t&&(t.placeholder=e.placeholder),e.href&&"href"in t&&(t.href=e.href);for(const[a,o]of Object.entries(e.attrs??{}))o===void 0||o===!1||t.setAttribute(a,o===!0?"":String(o));Object.assign(t.style,e.style??{});for(const[a,o]of Object.entries(e.on??{}))t.addEventListener(a,o);for(const a of e.children??[])a==null||a===!1||t.append(a);return t}function w(s){s.replaceChildren()}function F(s,e,t){if(k("os-select")&&k("os-option")){const a=document.createElement("os-select");a.setAttribute("value",s),a.classList.add("atfb-field");for(const o of e){const i=document.createElement("os-option");i.setAttribute("value",o.value),i.textContent=o.label,a.append(i)}return a.addEventListener("os-pick",o=>{t(String(o.detail?.value??""))}),a}return n("select",{class:"atfb-input atfb-select",on:{change:a=>t(a.target.value)},children:e.map(a=>n("option",{value:a.value,text:a.label,attrs:{selected:a.value===s}}))})}function P(s,e,t="secondary",a){const o=[null,n("span",{text:s})];if(k("os-button")){const i=document.createElement("os-button");i.setAttribute("variant",t),i.setAttribute("type","button"),i.classList.add("atfb-button",`atfb-button--${t}`),i.addEventListener("click",e);for(const r of o)r&&i.append(r);return i}return n("button",{class:`atfb-button atfb-button--${t}`,type:"button",on:{click:e},children:o})}function k(s){return typeof customElements<"u"&&!!customElements.get(s)}const I=["os-button","os-checkbox-label","os-number-field","os-select","os-option","os-segmented","os-segment","os-color-field","os-empty-state"];let g=null;function R(){if(g)return g;const s=window.wp?.os;return g=s?.loadComponents?s.loadComponents(I).catch(()=>{}):Promise.resolve(),g}async function j(s,e=""){const t=window.wp?.os;return t?.confirm?t.confirm({title:e,message:s,danger:!0}):window.confirm(s)}function _(s,e="",t="info"){const a=window.wp?.os;if(a?.notify){a.notify({title:s,body:e,type:t});return}console.info(`[AllTerrain Forms] ${s}${e?`: ${e}`:""}`)}function B(s){const e=s.closest(".os-window__body");e&&(e.addEventListener("scroll",()=>{e.scrollTop&&(e.scrollTop=0),e.scrollLeft&&(e.scrollLeft=0)},{passive:!0}),e.scrollTop=0,e.scrollLeft=0)}const N=[{key:"detractors",label:"Detractors",hint:"0–6"},{key:"passives",label:"Passives",hint:"7–8"},{key:"promoters",label:"Promoters",hint:"9–10"}],L=50;function q(s){const e=Math.floor(s.min),t=Math.ceil(s.max);if(!Number.isFinite(e)||!Number.isFinite(t)||tMath.min(i-1,Math.max(0,Math.floor((h-e)/o))),l=[];for(let h=0;h({value:String(t.id),label:t.title||`#${t.id}`})),t=>{this.formId=Number(t),this.dimension="",this.load()}));const e=this.report?.dimensions??[];e.length>1&&this.bar.append(n("span",{class:"atfa__bar-label",text:"Group by"}),F(this.dimension,e.map(t=>({value:t.id,label:t.label})),t=>{this.dimension=t,this.load()}))}render(){const e=this.report;if(w(this.body),!!e){if(this.body.append(this.kpis(e)),e.submissions<1&&e.sampled<1)this.body.append(n("p",{class:"atfa__empty",text:"No submissions yet. Everything below fills in as they arrive."}));else{this.body.append(this.timeline(e));for(const t of e.fields)t.nps&&this.body.append(this.npsPanel(t));e.breakdown&&e.breakdown.groups.length&&this.body.append(this.breakdownPanel(e.breakdown)),this.body.append(this.questions(e))}this.demoAvailable&&this.body.append(this.demoPanel())}}kpis(e){const t=[{label:"Submissions",value:String(e.submissions)},{label:"Views",value:String(e.views)},{label:"Conversion",value:`${e.conversion}%`,hint:"of people who saw it"},{label:"Completion",value:`${e.completion}%`,hint:"of people who started"},{label:"Unread",value:String(e.unread)},{label:"Spam",value:String(e.spam)}];return n("div",{class:"atfa-kpis",children:t.map(a=>n("div",{class:"atfa-kpi",children:[n("span",{class:"atfa-kpi__value",text:a.value}),n("span",{class:"atfa-kpi__label",text:a.label}),a.hint?n("span",{class:"atfa-kpi__hint",text:a.hint}):null]}))})}timeline(e){const t=e.timeline,a=t.reduce((i,r)=>Math.max(i,r.count),0),o=t.reduce((i,r)=>i+r.count,0);return this.panel("When they answered",`${o} in the last ${t.length} days · busiest day ${a}`,[n("div",{class:"atfa-timeline",children:t.map(i=>n("div",{class:`atfa-timeline__day${i.count?"":" is-empty"}`,attrs:{style:`height:${a?Math.max(2,i.count/a*100):0}%`,title:`${i.date}: ${i.count}`}}))}),n("div",{class:"atfa-timeline__axis",children:[n("span",{text:t[0]?.date??""}),n("span",{text:t[t.length-1]?.date??""})]})])}npsPanel(e){const t=e.nps;if(!t)return n("div");const a=o=>t.responses?o/t.responses*100:0;return this.panel(e.label,`${t.responses} answers · Net Promoter Score`,[n("div",{class:"atfa-nps",children:[n("div",{class:`atfa-nps__score is-${t.score>=50?"great":t.score>=0?"ok":"poor"}`,children:[n("span",{class:"atfa-nps__number",text:`${t.score>0?"+":""}${t.score}`}),n("span",{class:"atfa-nps__caption",text:"NPS"})]}),n("div",{class:"atfa-nps__detail",children:[n("div",{class:"atfa-nps__bar",children:N.map(o=>n("div",{class:`atfa-nps__band is-${o.key}`,attrs:{style:`width:${a(t[o.key])}%`,title:`${o.label} (${o.hint}): ${t[o.key]}`}}))}),n("div",{class:"atfa-nps__legend",children:N.map(o=>n("span",{class:`atfa-nps__key is-${o.key}`,children:[n("strong",{text:String(t[o.key])}),n("span",{text:` ${o.label} `}),n("small",{text:o.hint})]}))})]})]})])}breakdownPanel(e){const t=e.groups[0]?.metrics??[];if(!t.length)return n("div");const a=new Map;for(const r of e.groups)for(const l of r.metrics)a.set(l.id,Math.max(a.get(l.id)??0,l.mean));const o=n("tr",{children:[n("th",{text:e.label,attrs:{scope:"col"}}),n("th",{text:"Answers",attrs:{scope:"col"}}),...t.map(r=>n("th",{text:r.label,attrs:{scope:"col"}}))]}),i=e.groups.map(r=>n("tr",{children:[n("th",{class:"atfa-table__label",text:r.label,attrs:{scope:"row"}}),n("td",{class:"atfa-table__count",text:String(r.count)}),...r.metrics.map(l=>n("td",{children:[n("div",{class:"atfa-meter",children:[n("div",{class:"atfa-meter__fill",attrs:{style:`width:${l.mean/(a.get(l.id)||1)*100}%`}})]}),n("span",{class:"atfa-meter__value",text:l.nps!==null?`${l.mean.toFixed(1)} · NPS ${l.nps>0?"+":""}${l.nps}`:l.mean.toFixed(2)})]}))]}));return this.panel(`Broken down by ${e.label.toLowerCase()}`,"",[n("table",{class:"atfa-table",children:[n("thead",{children:[o]}),n("tbody",{children:i})]})])}questions(e){return n("div",{class:"atfa-questions",children:e.fields.map(t=>this.question(t,e.sampled))})}question(e,t){const a=[];if(e.choices.length){const o=e.choices.reduce((i,r)=>Math.max(i,r.count),0);a.push(n("ul",{class:"atfa-bars",children:e.choices.map(i=>n("li",{class:"atfa-bars__row",children:[n("span",{class:"atfa-bars__label",text:i.label}),n("div",{class:"atfa-meter",children:[n("div",{class:"atfa-meter__fill",attrs:{style:`width:${o?i.count/o*100:0}%`}})]}),n("span",{class:"atfa-bars__value",text:`${i.count} ${e.answered?Math.round(i.count/e.answered*100):0}%`})]}))}))}return e.numbers&&a.push(this.histogram(e)),a.length||a.push(n("p",{class:"atfa-question__note",text:`${e.answered} of ${t} people answered this.`})),this.panel(e.label,this.questionSummary(e,t),a)}questionSummary(e,t){const a=[`${e.rate}% answered`];return e.numbers&&a.push(`mean ${e.numbers.mean}`,`median ${e.numbers.median}`),a.push(`${e.answered}/${t}`),a.join(" · ")}histogram(e){const t=e.numbers;if(!t)return n("div");const a=q(t),o=a.reduce((i,r)=>Math.max(i,r.count),0);return n("div",{class:"atfa-hist",children:a.map(i=>n("div",{class:`atfa-hist__col${i.holdsMean?" is-mean":""}`,attrs:{title:`${i.label}: ${i.count}`},children:[n("span",{class:"atfa-hist__count",text:i.count?String(i.count):""}),n("div",{class:"atfa-hist__bar",attrs:{style:`height:${o?Math.max(2,i.count/o*100):0}%`}}),n("span",{class:"atfa-hist__tick",text:i.label})]}))})}demoPanel(){const e=this.demo,t=n("p",{class:"atfa-demo__status",text:this.demoSummary(e)}),a=P("Fill with demo data",async()=>{await this.seedAll(t)}),o=P("Remove demo data",async()=>{if(await j("Delete the demo survey and every entry it generated?","Remove demo data")){t.textContent="Removing…";try{const i=await f.demoRemove();_("Demo data removed",`${i.entries} entries and ${i.forms} form.`)}catch{_("Could not remove the demo data","","error")}await this.refreshDemo(t)}});return n("div",{class:"atfa-demo",attrs:{"data-atfa-demo":""},children:[n("h3",{class:"atfa-demo__title",text:"Developer"}),n("p",{class:"atfa-demo__note",text:"Generates a team pulse survey and several hundred submissions through the real submission pipeline, so this report has something to report on. Everything it makes is tagged, and removing it deletes exactly that and nothing else."}),t,n("div",{class:"atfa-demo__actions",children:[a,o]})]})}demoSummary(e){return!e||!e.formId?"No demo data on this site.":`${e.title}: ${e.entries} of ${e.target} submissions.`}async seedAll(e){let t=0,a=0;for(;;){try{const o=await f.demoSeed();if(this.demo=o,a=0,e.textContent=`Generating… ${o.entries} of ${o.target}`,o.remaining<1){_("Demo data ready",`${o.entries} submissions.`);break}}catch{if(a+=1,a>=3){e.textContent="Stopped early. Press it again to carry on from here.",_("Could not finish the demo data","Some submissions were made. Try again to continue.","error");break}}if(t+=1,t>200)break}if(await this.refreshDemo(e),this.demo?.formId){try{this.forms=await f.listForms()}catch{}this.formId=this.demo.formId,this.dimension=""}await this.load()}async refreshDemo(e){try{this.demo=await f.demoStatus()}catch{this.demo=null}e.textContent=this.demoSummary(this.demo)}panel(e,t,a){return n("section",{class:"atfa-panel",children:[n("header",{class:"atfa-panel__head",children:[n("h3",{class:"atfa-panel__title",text:e}),t?n("p",{class:"atfa-panel__note",text:t}):null]}),...a]})}revealDemo(){this.body.querySelector("[data-atfa-demo]")?.scrollIntoView({behavior:"smooth",block:"start"})}}let $=null;function x(){const s=document.querySelector("[data-atfa-root]");!s||s.dataset.atfaMounted||(s.dataset.atfaMounted="1",B(s),$=new U(s),$.start())}let E=!1;return document.addEventListener("atf-open-demo-panel",()=>{E=!0,window.setTimeout(()=>{$?.revealDemo(),E=!1},600)}),document.addEventListener("os-window-content-loaded",()=>{x(),E&&window.setTimeout(()=>$?.revealDemo(),400)}),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",x):x(),p.MAX_HISTOGRAM_BUCKETS=L,p.histogramBuckets=q,p.mountAnalytics=x,Object.defineProperty(p,Symbol.toStringTag,{value:"Module"}),p}({}); +var allTerrainFormsAnalytics=function(p){"use strict";function T(){return window.wp?.os??null}const c=window.allTerrainForms;class v extends Error{constructor(e,t,n=""){super(e),this.name="ApiError",this.status=t,this.code=n}}function C(s,e){return s.includes("?")?`${s}${e.replace("?","&")}`:`${s}${e}`}async function k(s,e={}){if(!c?.restUrl)throw new v("AllTerrain Forms is not configured on this page.",0);const t=C(c.restUrl,s),n={"Content-Type":"application/json",...e.headers??{}};c.nonce&&(n["X-WP-Nonce"]=c.nonce);const i=T(),r=await(i?.fetch?(l,u)=>i.fetch(l,u,{source:"allterrain-forms"}):(l,u)=>fetch(l,u))(t,{credentials:"same-origin",...e,headers:n});if(!r.ok){let l=`Request failed with status ${r.status}.`,u="";try{const h=await r.json();l=h.message??l,u=h.code??""}catch{}throw new v(l,r.status,u)}if(r.status!==204)return await r.json()}const d=s=>k(s);async function D(s){if(!c?.wpRestUrl)throw new v("AllTerrain Forms is not configured on this page.",0);const e=c.nonce?{"X-WP-Nonce":c.nonce}:{},t=T(),n=C(c.wpRestUrl,s),i=t?.fetch?await t.fetch(n,{credentials:"same-origin",headers:e},{source:"allterrain-forms"}):await fetch(n,{credentials:"same-origin",headers:e});if(!i.ok)throw new v(`Request failed with status ${i.status}.`,i.status);return await i.json()}function j(s){if(!s||!s.includes("&"))return s;const e=document.createElement("textarea");return e.innerHTML=s,e.value}const m=(s,e)=>k(s,{method:"POST",body:JSON.stringify(e)}),y=s=>k(s,{method:"DELETE"});function F(s){const e=new URLSearchParams;for(const[n,i]of Object.entries(s))i===void 0||i===""||i===!1||e.set(n,String(i));const t=e.toString();return t?`?${t}`:""}function w(s){const e=s?.schema?.settings;return e&&(!e.themeOverrides||Array.isArray(e.themeOverrides))&&(e.themeOverrides={...e.themeOverrides}),s}const f={config:()=>d("/config"),listForms:()=>d("/forms"),getForm:s=>d(`/forms/${s}`).then(w),createForm:s=>m("/forms",s).then(w),updateForm:(s,e)=>m(`/forms/${s}`,e).then(w),duplicateForm:s=>m(`/forms/${s}/duplicate`,{}).then(w),deleteForm:s=>y(`/forms/${s}`),preview:(s,e)=>m(`/forms/${s}/preview`,e),pages:()=>D("wp/v2/pages?per_page=100&status=publish&orderby=title&order=asc&_fields=id,title").then(s=>s.map(e=>({id:e.id,title:j(e.title?.rendered??"")||`#${e.id}`}))),mergeTags:s=>d(`/forms/${s}/merge-tags`).then(e=>e.groups),analytics:(s,e="")=>d(`/forms/${s}/analytics${e?`?dimension=${encodeURIComponent(e)}`:""}`),demoStatus:()=>d("/demo"),demoSeed:s=>m("/demo",s?{count:s}:{}),demoRemove:()=>y("/demo"),listEntries:s=>d(`/entries${F(s)}`),getEntry:s=>d(`/entries/${s}`),updateEntry:(s,e)=>m(`/entries/${s}`,e),deleteEntry:s=>y(`/entries/${s}`),exportEntries:s=>d(`/entries/export${F(s)}`),listThemes:()=>d("/themes"),saveTheme:s=>m("/themes",s),deleteTheme:s=>y(`/themes/${s}`)};function a(s,e={}){const t=document.createElement(s);e.class&&(t.className=e.class),e.text!==void 0&&(t.textContent=e.text),e.html!==void 0&&(t.innerHTML=e.html),e.title&&(t.title=e.title),e.type&&"type"in t&&(t.type=e.type),e.value!==void 0&&"value"in t&&(t.value=e.value),e.placeholder!==void 0&&"placeholder"in t&&(t.placeholder=e.placeholder),e.href&&"href"in t&&(t.href=e.href);for(const[n,i]of Object.entries(e.attrs??{}))i===void 0||i===!1||t.setAttribute(n,i===!0?"":String(i));Object.assign(t.style,e.style??{});for(const[n,i]of Object.entries(e.on??{}))t.addEventListener(n,i);for(const n of e.children??[])n==null||n===!1||t.append(n);return t}function g(s){s.replaceChildren()}function P(s,e,t){if(E("os-select")&&E("os-option")){const n=document.createElement("os-select");n.setAttribute("value",s),n.classList.add("atfb-field");for(const i of e){const o=document.createElement("os-option");o.setAttribute("value",i.value),o.textContent=i.label,n.append(o)}return n.addEventListener("os-pick",i=>{t(String(i.detail?.value??""))}),n}return a("select",{class:"atfb-input atfb-select",on:{change:n=>t(n.target.value)},children:e.map(n=>a("option",{value:n.value,text:n.label,attrs:{selected:n.value===s}}))})}function O(s,e,t="secondary",n){const i=[null,a("span",{text:s})];if(E("os-button")){const o=document.createElement("os-button");o.setAttribute("variant",t),o.setAttribute("type","button"),o.classList.add("atfb-button",`atfb-button--${t}`),o.addEventListener("click",e);for(const r of i)r&&o.append(r);return o}return a("button",{class:`atfb-button atfb-button--${t}`,type:"button",on:{click:e},children:i})}function E(s){return typeof customElements<"u"&&!!customElements.get(s)}const I=["os-button","os-checkbox-label","os-number-field","os-select","os-option","os-segmented","os-segment","os-color-field","os-empty-state"];let _=null;function R(){if(_)return _;const s=window.wp?.os;return _=s?.loadComponents?s.loadComponents(I).catch(()=>{}):Promise.resolve(),_}async function B(s,e=""){const t=window.wp?.os;return t?.confirm?t.confirm({title:e,message:s,danger:!0}):window.confirm(s)}function $(s,e="",t="info"){const n=window.wp?.os;if(n?.notify){n.notify({title:s,body:e,type:t});return}console.info(`[AllTerrain Forms] ${s}${e?`: ${e}`:""}`)}function U(s){const e=s.closest(".os-window__body");e&&(e.addEventListener("scroll",()=>{e.scrollTop&&(e.scrollTop=0),e.scrollLeft&&(e.scrollLeft=0)},{passive:!0}),e.scrollTop=0,e.scrollLeft=0)}const N=[{key:"detractors",label:"Detractors",hint:"0–6"},{key:"passives",label:"Passives",hint:"7–8"},{key:"promoters",label:"Promoters",hint:"9–10"}],L=50;function q(s){const e=Math.floor(s.min),t=Math.ceil(s.max);if(!Number.isFinite(e)||!Number.isFinite(t)||tMath.min(o-1,Math.max(0,Math.floor((h-e)/i))),l=[];for(let h=0;h({value:String(t.id),label:t.title||`#${t.id}`})),t=>{this.formId=Number(t),this.dimension="",this.load()}));const e=this.report?.dimensions??[];e.length>1&&this.bar.append(a("span",{class:"atfa__bar-label",text:"Group by"}),P(this.dimension,e.map(t=>({value:t.id,label:t.label})),t=>{this.dimension=t,this.load()}))}render(){const e=this.report;if(g(this.body),!!e){if(this.body.append(this.kpis(e)),e.submissions<1&&e.sampled<1)this.body.append(a("p",{class:"atfa__empty",text:"No submissions yet. Everything below fills in as they arrive."}));else{this.body.append(this.timeline(e));for(const t of e.fields)t.nps&&this.body.append(this.npsPanel(t));e.breakdown&&e.breakdown.groups.length&&this.body.append(this.breakdownPanel(e.breakdown)),this.body.append(this.questions(e))}this.demoAvailable&&this.body.append(this.demoPanel())}}kpis(e){const t=[{label:"Submissions",value:String(e.submissions)},{label:"Views",value:String(e.views)},{label:"Conversion",value:`${e.conversion}%`,hint:"of people who saw it"},{label:"Completion",value:`${e.completion}%`,hint:"of people who started"},{label:"Unread",value:String(e.unread)},{label:"Spam",value:String(e.spam)}];return a("div",{class:"atfa-kpis",children:t.map(n=>a("div",{class:"atfa-kpi",children:[a("span",{class:"atfa-kpi__value",text:n.value}),a("span",{class:"atfa-kpi__label",text:n.label}),n.hint?a("span",{class:"atfa-kpi__hint",text:n.hint}):null]}))})}timeline(e){const t=e.timeline,n=t.reduce((o,r)=>Math.max(o,r.count),0),i=t.reduce((o,r)=>o+r.count,0);return this.panel("When they answered",`${i} in the last ${t.length} days · busiest day ${n}`,[a("div",{class:"atfa-timeline",children:t.map(o=>a("div",{class:`atfa-timeline__day${o.count?"":" is-empty"}`,attrs:{style:`height:${n?Math.max(2,o.count/n*100):0}%`,title:`${o.date}: ${o.count}`}}))}),a("div",{class:"atfa-timeline__axis",children:[a("span",{text:t[0]?.date??""}),a("span",{text:t[t.length-1]?.date??""})]})])}npsPanel(e){const t=e.nps;if(!t)return a("div");const n=i=>t.responses?i/t.responses*100:0;return this.panel(e.label,`${t.responses} answers · Net Promoter Score`,[a("div",{class:"atfa-nps",children:[a("div",{class:`atfa-nps__score is-${t.score>=50?"great":t.score>=0?"ok":"poor"}`,children:[a("span",{class:"atfa-nps__number",text:`${t.score>0?"+":""}${t.score}`}),a("span",{class:"atfa-nps__caption",text:"NPS"})]}),a("div",{class:"atfa-nps__detail",children:[a("div",{class:"atfa-nps__bar",children:N.map(i=>a("div",{class:`atfa-nps__band is-${i.key}`,attrs:{style:`width:${n(t[i.key])}%`,title:`${i.label} (${i.hint}): ${t[i.key]}`}}))}),a("div",{class:"atfa-nps__legend",children:N.map(i=>a("span",{class:`atfa-nps__key is-${i.key}`,children:[a("strong",{text:String(t[i.key])}),a("span",{text:` ${i.label} `}),a("small",{text:i.hint})]}))})]})]})])}breakdownPanel(e){const t=e.groups[0]?.metrics??[];if(!t.length)return a("div");const n=new Map;for(const r of e.groups)for(const l of r.metrics)n.set(l.id,Math.max(n.get(l.id)??0,l.mean));const i=a("tr",{children:[a("th",{text:e.label,attrs:{scope:"col"}}),a("th",{text:"Answers",attrs:{scope:"col"}}),...t.map(r=>a("th",{text:r.label,attrs:{scope:"col"}}))]}),o=e.groups.map(r=>a("tr",{children:[a("th",{class:"atfa-table__label",text:r.label,attrs:{scope:"row"}}),a("td",{class:"atfa-table__count",text:String(r.count)}),...r.metrics.map(l=>a("td",{children:[a("div",{class:"atfa-meter",children:[a("div",{class:"atfa-meter__fill",attrs:{style:`width:${l.mean/(n.get(l.id)||1)*100}%`}})]}),a("span",{class:"atfa-meter__value",text:l.nps!==null?`${l.mean.toFixed(1)} · NPS ${l.nps>0?"+":""}${l.nps}`:l.mean.toFixed(2)})]}))]}));return this.panel(`Broken down by ${e.label.toLowerCase()}`,"",[a("table",{class:"atfa-table",children:[a("thead",{children:[i]}),a("tbody",{children:o})]})])}questions(e){return a("div",{class:"atfa-questions",children:e.fields.map(t=>this.question(t,e.sampled))})}question(e,t){const n=[];if(e.choices.length){const i=e.choices.reduce((o,r)=>Math.max(o,r.count),0);n.push(a("ul",{class:"atfa-bars",children:e.choices.map(o=>a("li",{class:"atfa-bars__row",children:[a("span",{class:"atfa-bars__label",text:o.label}),a("div",{class:"atfa-meter",children:[a("div",{class:"atfa-meter__fill",attrs:{style:`width:${i?o.count/i*100:0}%`}})]}),a("span",{class:"atfa-bars__value",text:`${o.count} ${e.answered?Math.round(o.count/e.answered*100):0}%`})]}))}))}return e.numbers&&n.push(this.histogram(e)),n.length||n.push(a("p",{class:"atfa-question__note",text:`${e.answered} of ${t} people answered this.`})),this.panel(e.label,this.questionSummary(e,t),n)}questionSummary(e,t){const n=[`${e.rate}% answered`];return e.numbers&&n.push(`mean ${e.numbers.mean}`,`median ${e.numbers.median}`),n.push(`${e.answered}/${t}`),n.join(" · ")}histogram(e){const t=e.numbers;if(!t)return a("div");const n=q(t),i=n.reduce((o,r)=>Math.max(o,r.count),0);return a("div",{class:"atfa-hist",children:n.map(o=>a("div",{class:`atfa-hist__col${o.holdsMean?" is-mean":""}`,attrs:{title:`${o.label}: ${o.count}`},children:[a("span",{class:"atfa-hist__count",text:o.count?String(o.count):""}),a("div",{class:"atfa-hist__bar",attrs:{style:`height:${i?Math.max(2,o.count/i*100):0}%`}}),a("span",{class:"atfa-hist__tick",text:o.label})]}))})}demoPanel(){const e=this.demo,t=a("p",{class:"atfa-demo__status",text:this.demoSummary(e)}),n=O("Fill with demo data",async()=>{await this.seedAll(t)}),i=O("Remove demo data",async()=>{if(await B("Delete the demo survey and every entry it generated?","Remove demo data")){t.textContent="Removing…";try{const o=await f.demoRemove();$("Demo data removed",`${o.entries} entries and ${o.forms} form.`)}catch{$("Could not remove the demo data","","error")}await this.refreshDemo(t)}});return a("div",{class:"atfa-demo",attrs:{"data-atfa-demo":""},children:[a("h3",{class:"atfa-demo__title",text:"Developer"}),a("p",{class:"atfa-demo__note",text:"Generates a team pulse survey and several hundred submissions through the real submission pipeline, so this report has something to report on. Everything it makes is tagged, and removing it deletes exactly that and nothing else."}),t,a("div",{class:"atfa-demo__actions",children:[n,i]})]})}demoSummary(e){return!e||!e.formId?"No demo data on this site.":`${e.title}: ${e.entries} of ${e.target} submissions.`}async seedAll(e){let t=0,n=0;for(;;){try{const i=await f.demoSeed();if(this.demo=i,n=0,e.textContent=`Generating… ${i.entries} of ${i.target}`,i.remaining<1){$("Demo data ready",`${i.entries} submissions.`);break}}catch{if(n+=1,n>=3){e.textContent="Stopped early. Press it again to carry on from here.",$("Could not finish the demo data","Some submissions were made. Try again to continue.","error");break}}if(t+=1,t>200)break}if(await this.refreshDemo(e),this.demo?.formId){try{this.forms=await f.listForms()}catch{}this.formId=this.demo.formId,this.dimension=""}await this.load()}async refreshDemo(e){try{this.demo=await f.demoStatus()}catch{this.demo=null}e.textContent=this.demoSummary(this.demo)}panel(e,t,n){return a("section",{class:"atfa-panel",children:[a("header",{class:"atfa-panel__head",children:[a("h3",{class:"atfa-panel__title",text:e}),t?a("p",{class:"atfa-panel__note",text:t}):null]}),...n]})}revealDemo(){this.body.querySelector("[data-atfa-demo]")?.scrollIntoView({behavior:"smooth",block:"start"})}}let x=null;function S(){const s=document.querySelector("[data-atfa-root]");!s||s.dataset.atfaMounted||(s.dataset.atfaMounted="1",U(s),x=new G(s),x.start())}let A=!1;return document.addEventListener("atf-open-demo-panel",()=>{A=!0,window.setTimeout(()=>{x?.revealDemo(),A=!1},600)}),document.addEventListener("os-window-content-loaded",()=>{S(),A&&window.setTimeout(()=>x?.revealDemo(),400)}),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",S):S(),p.MAX_HISTOGRAM_BUCKETS=L,p.histogramBuckets=q,p.mountAnalytics=S,Object.defineProperty(p,Symbol.toStringTag,{value:"Module"}),p}({}); diff --git a/assets/js/builder.js b/assets/js/builder.js index b0c4c78..f18ebb7 100644 --- a/assets/js/builder.js +++ b/assets/js/builder.js @@ -326,13 +326,20 @@ var allTerrainFormsBuilder = function(exports) { const string = search.toString(); return string ? `?${string}` : ""; } + function withObjectOverrides(form) { + const settings = form?.schema?.settings; + if (settings && (!settings.themeOverrides || Array.isArray(settings.themeOverrides))) { + settings.themeOverrides = { ...settings.themeOverrides }; + } + return form; + } const api = { config: () => get("/config"), listForms: () => get("/forms"), - getForm: (id) => get(`/forms/${id}`), - createForm: (body) => post("/forms", body), - updateForm: (id, body) => post(`/forms/${id}`, body), - duplicateForm: (id) => post(`/forms/${id}/duplicate`, {}), + getForm: (id) => get(`/forms/${id}`).then(withObjectOverrides), + createForm: (body) => post("/forms", body).then(withObjectOverrides), + updateForm: (id, body) => post(`/forms/${id}`, body).then(withObjectOverrides), + duplicateForm: (id) => post(`/forms/${id}/duplicate`, {}).then(withObjectOverrides), deleteForm: (id) => del(`/forms/${id}`), preview: (id, body) => post(`/forms/${id}/preview`, body), /** @@ -1768,7 +1775,12 @@ var allTerrainFormsBuilder = function(exports) { const repaint = debounce(async () => { try { const html = await options.previewFor(active, overrides); + const pane = preview.closest(".atf-studio__preview"); + const scrolled = pane?.scrollTop ?? 0; preview.innerHTML = html; + if (pane) { + pane.scrollTop = scrolled; + } document.dispatchEvent(new CustomEvent("atf-refresh")); } catch (error) { clear(preview); @@ -1777,8 +1789,24 @@ var allTerrainFormsBuilder = function(exports) { ); } }, 180); + const CLASS_TOKENS = { + "label-position": "atf-labels-", + "field-style": "atf-fields-" + }; + const swapClass = (target, prefix, value) => { + const safe = value.replace(/[^a-z0-9_-]/gi, ""); + for (const existing of [...target.classList]) { + if (existing.startsWith(prefix)) { + target.classList.remove(existing); + } + } + if (safe) { + target.classList.add(prefix + safe); + } + }; const paintNow = (written) => { const wrap = preview.querySelector(".atf-form-wrap"); + const formEl = wrap?.querySelector(".atf-form"); if (!wrap) { return; } @@ -1788,6 +1816,17 @@ var allTerrainFormsBuilder = function(exports) { } else { wrap.style.setProperty(`--atf-${token}`, value); } + if (formEl && CLASS_TOKENS[token]) { + swapClass(formEl, CLASS_TOKENS[token], value || (themes.find((t) => t.slug === active)?.resolved[token] ?? "")); + } + } + }; + const paintTheme = (theme) => { + paintNow(theme.resolved); + const formEl = preview.querySelector(".atf-form"); + if (formEl) { + swapClass(formEl, "atf-theme-", theme.slug); + formEl.classList.toggle("atf-is-dark", !!theme.dark); } }; const resolve = (token) => { @@ -1834,11 +1873,10 @@ var allTerrainFormsBuilder = function(exports) { active = theme.slug; replaceOverrides({}); options.onTheme(active); - paintNow(theme.resolved); + paintTheme(theme); renderThemes(); - renderQuick(); - renderControls(); - repaint(); + syncQuick(); + syncControlsSoon(); syncDeleteButton(); } } @@ -1854,6 +1892,42 @@ var allTerrainFormsBuilder = function(exports) { } return resolved; }; + const keepScroll = (render) => { + const scroller = quick.closest(".atf-studio__controls"); + const scrolled = scroller?.scrollTop ?? 0; + render(); + if (scroller) { + scroller.scrollTop = scrolled; + } + }; + const syncControlsSoon = debounce(() => keepScroll(renderControls), 150); + const syncQuick = () => { + const tokens = currentTokens(); + const rows = quick.querySelectorAll(".atfs-dial"); + quickDials().forEach((dial, index) => { + const row2 = rows[index]; + if (!row2) { + return; + } + const at = dial.read(tokens); + if (dial.kind === "colour") { + const picker = row2.querySelector('input[type="color"]'); + const text = row2.querySelector("input.atfb-input"); + if (picker && document.activeElement !== picker) { + picker.value = normaliseHex(at); + } + if (text && document.activeElement !== text) { + text.value = at; + } + return; + } + row2.querySelectorAll(".atfs-segment").forEach((segment, step) => { + const on = dial.steps?.[step]?.value === at; + segment.classList.toggle("is-on", on); + segment.setAttribute("aria-pressed", String(on)); + }); + }); + }; const applyDial = (dial, step) => { const written = dial.apply(step, currentTokens()); for (const [token, value] of Object.entries(written)) { @@ -1861,9 +1935,8 @@ var allTerrainFormsBuilder = function(exports) { options.onOverride(token, value); } paintNow(written); - renderQuick(); - renderControls(); - repaint(); + syncQuick(); + syncControlsSoon(); }; const renderQuick = () => { clear(quick); @@ -1963,7 +2036,6 @@ var allTerrainFormsBuilder = function(exports) { } options.onOverride(token.token, next); paintNow({ [token.token]: next }); - repaint(); }; let control2; if (token.control === "color") { @@ -2033,7 +2105,7 @@ var allTerrainFormsBuilder = function(exports) { on: { click: () => { change(""); - renderControls(); + keepScroll(renderControls); } } }) @@ -2063,8 +2135,8 @@ var allTerrainFormsBuilder = function(exports) { replaceOverrides({}); options.onTheme(active); renderThemes(); - renderControls(); - repaint(); + syncQuick(); + keepScroll(renderControls); notify("Theme saved", saved.label); } catch (error) { notify("Could not save the theme", error instanceof Error ? error.message : "", "error"); @@ -2086,8 +2158,12 @@ var allTerrainFormsBuilder = function(exports) { replaceOverrides({}); options.onTheme(active); renderThemes(); - renderControls(); - repaint(); + syncQuick(); + keepScroll(renderControls); + const clean = themes.find((candidate) => candidate.slug === active); + if (clean) { + paintTheme(clean); + } } catch (error) { notify("Could not delete the theme", error instanceof Error ? error.message : "", "error"); } @@ -2121,8 +2197,8 @@ var allTerrainFormsBuilder = function(exports) { options.onOverride(name, String(value)); } paintNow({ ...overrides }); - renderControls(); - repaint(); + syncQuick(); + keepScroll(renderControls); } catch (error) { notify("Could not read that theme", error instanceof Error ? error.message : "", "error"); } @@ -3104,6 +3180,9 @@ var allTerrainFormsBuilder = function(exports) { * happen before the *next* edit. */ rebindCanvas() { + if (this.tab !== "build") { + return; + } const focused = document.activeElement; if (focused instanceof HTMLElement && this.canvas.contains(focused)) { focused.addEventListener("blur", () => this.rebindCanvas(), { once: true }); @@ -4873,6 +4952,7 @@ var allTerrainFormsBuilder = function(exports) { this.canvasThemeSignature = signature; try { const html = await this.previewHtml(theme, this.schema.settings.themeOverrides ?? {}); + this.root.classList.toggle("atfb--dark-form", /atf-is-dark/.test(html)); const block = /