domma-cms 0.98.0 → 0.100.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.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.45.0
2
+ * Domma Tools CSS v0.47.0
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-09-27T18:03:07.363Z
6
- * Commit: 224c5a0
5
+ * Built: 2026-09-27T21:30:02.883Z
6
+ * Commit: eff3166
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.45.0
2
+ * Domma Tools v0.47.0
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-09-27T18:02:54.057Z
6
- * Commit: 224c5a0
5
+ * Built: 2026-09-27T21:29:48.350Z
6
+ * Commit: eff3166
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
package/admin/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  <link rel="stylesheet" href="/admin/css/dashboard.css">
22
22
 
23
23
  <!-- Forms CSS - needed for conditional logic preview (.fb-field-hidden, etc.) -->
24
- <link rel="stylesheet" href="/public/css/forms.css?v=20260926-end-panel">
24
+ <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
25
25
 
26
26
  <!-- Menu item hover / current-page effects - the admin sidebar is a menu
27
27
  like any other, so it honours the same `highlight` block. -->
@@ -80,7 +80,7 @@
80
80
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
81
81
 
82
82
  <!-- Form Logic Engine - needed for conditional logic preview -->
83
- <script src="/public/js/form-logic-engine.js?v=20260926-limits"></script>
83
+ <script src="/public/js/form-logic-engine.js?v=20260927-submit-btn"></script>
84
84
 
85
85
  <!-- Admin App (ES module entry) -->
86
86
  <script type="module" src="/admin/js/app.js"></script>
@@ -1,3 +1,3 @@
1
- export const FIELD_TYPES=[{value:"string",label:"Text (single line)"},{value:"email",label:"Email"},{value:"tel",label:"Phone"},{value:"number",label:"Number"},{value:"textarea",label:"Textarea (multi-line)"},{value:"select",label:"Dropdown (select)"},{value:"radio",label:"Radio buttons"},{value:"checkbox",label:"Single checkbox"},{value:"checkbox-group",label:"Checkbox group"},{value:"date",label:"Date"},{value:"time",label:"Time"},{value:"url",label:"URL"},{value:"hidden",label:"Hidden field"}],OPTION_TYPES=new Set(["select","radio","checkbox-group"]),OPERATIONS=["create","read","update","delete"],EXPORT_FORMATS=["csv","json"],FIELD_NAME_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;export function slugify(e){return String(e??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export const nameFromLabel=e=>slugify(e).replace(/-/g,"_"),typeLabel=e=>FIELD_TYPES.find(n=>n.value===e)?.label||e||"";export function optionsText(e){return(Array.isArray(e)?e:[]).map(n=>typeof n=="string"?`${n}: ${n}`:`${n?.value??""}: ${n?.label??n?.value??""}`).join(`
1
+ export const FIELD_TYPES=[{value:"string",label:"Text (single line)"},{value:"email",label:"Email"},{value:"tel",label:"Phone"},{value:"number",label:"Number"},{value:"textarea",label:"Textarea (multi-line)"},{value:"select",label:"Dropdown (select)"},{value:"radio",label:"Radio buttons"},{value:"checkbox",label:"Single checkbox"},{value:"checkbox-group",label:"Checkbox group"},{value:"date",label:"Date"},{value:"time",label:"Time"},{value:"url",label:"URL"},{value:"hidden",label:"Hidden field"}],OPTION_TYPES=new Set(["select","radio","checkbox-group"]),ADDON_TYPES=new Set(["string","email","tel","number","textarea","select","date","time","url"]);const f=/^[a-z0-9-]+$/i;export const EXTRA_TYPES={clear:new Set(["string","email","tel","url","textarea"]),stepper:new Set(["number"]),counter:new Set(["string","textarea"])};const m=24;function p(e){return e?typeof e=="string"?{icon:"",text:e}:{icon:e.icon||"",text:e.icon?"":String(e.text??"")}:{icon:"",text:""}}export function addonFromBoxes(e,r){const n=String(e??"").trim(),t=String(r??"").trim();return n&&f.test(n)?{icon:n}:t?{text:t.slice(0,m)}:null}export const OPERATIONS=["create","read","update","delete"],EXPORT_FORMATS=["csv","json"],FIELD_NAME_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;export function slugify(e){return String(e??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export const nameFromLabel=e=>slugify(e).replace(/-/g,"_"),typeLabel=e=>FIELD_TYPES.find(r=>r.value===e)?.label||e||"";export function optionsText(e){return(Array.isArray(e)?e:[]).map(r=>typeof r=="string"?`${r}: ${r}`:`${r?.value??""}: ${r?.label??r?.value??""}`).join(`
2
2
  `)}export function parseOptions(e){return String(e??"").split(`
3
- `).filter(n=>n.trim()).map(n=>{const[r,...t]=n.split(":");return{value:r.trim(),label:t.join(":").trim()||r.trim()}})}const d=()=>({create:{enabled:!1,access:"admin"},read:{enabled:!0,access:"public"},update:{enabled:!1,access:"admin"},delete:{enabled:!1,access:"admin"}}),m=e=>({enabled:!0,access:e||"",formats:["csv","json"]});export function roleLadder(e){return(Array.isArray(e)?e:e?.entries||[]).map(r=>({name:r?.data?.name,label:r?.data?.label||r?.data?.name,level:Number(r?.data?.level)})).filter(r=>typeof r.name=="string"&&r.name&&Number.isFinite(r.level)).sort((r,t)=>r.level-t.level||r.name.localeCompare(t.name))}export function accessOptions(e,n,{token:r=!0}={}){const t=[{value:"public",label:"Public - anyone"}];r&&t.push({value:"token",label:"API token"});for(const l of[...e||[]].reverse())t.push({value:l.name,label:l.level===0?`${l.label} only`:`${l.label} and above`});return typeof n=="string"&&n&&!t.some(l=>l.value===n)&&t.push(e?.length?{value:n,label:`${n} (role not found)`,missing:!0}:{value:n,label:n}),t}let f=0;export const fieldKey=()=>`f${Date.now().toString(36)}${(f++).toString(36)}`;export function formFromCollection(e=null,{project:n="",defaultRole:r=""}={}){const t=e||{},l={...d(),...t.api||{}},a={...m(r),...t.export||{}},s={title:String(t.title??""),slug:String(t.slug??""),description:String(t.description??""),layout:t.layout==="grid"?"grid":"stacked",columns:String(Number.parseInt(t.columns,10)||2),project:String(t.meta?.project??n??""),bundled:t.bundled===!0,storageAdapter:t.storage?.adapter==="mongodb"?"mongodb":"file",storageConnection:String(t.storage?.connection||"default"),fields:(Array.isArray(t.fields)?t.fields:[]).map(o=>({...o,_key:fieldKey()})),readFields:Array.isArray(l.read?.fields)?l.read.fields.join(", "):"",exportEnabled:a.enabled!==!1,exportAccess:typeof a.access=="string"&&a.access?a.access:String(r||""),exportCsv:(Array.isArray(a.formats)?a.formats:EXPORT_FORMATS).includes("csv"),exportJson:(Array.isArray(a.formats)?a.formats:EXPORT_FORMATS).includes("json")};for(const o of OPERATIONS){s[`${o}Enabled`]=l[o]?.enabled===!0;const i=l[o]?.access;s[`${o}Access`]=typeof i=="string"&&i?i:"admin"}return s}export function cleanField(e){const{_key:n,...r}=e||{};return r}export function payloadFromForm(e,{isNew:n=!1,meta:r={}}={}){const t=e||{},l={};for(const o of OPERATIONS)l[o]={enabled:t[`${o}Enabled`]===!0,access:t[`${o}Access`]||"admin"};const a=String(t.readFields??"").split(",").map(o=>o.trim()).filter(Boolean);a.length&&(l.read.fields=a);const s=Math.min(6,Math.max(1,Number.parseInt(t.columns,10)||2));return{title:String(t.title??"").trim(),...n&&{slug:slugify(t.slug||t.title)},description:String(t.description??"").trim(),layout:t.layout==="grid"?"grid":"stacked",columns:s,fields:(t.fields||[]).map(cleanField),api:l,export:{enabled:t.exportEnabled!==!1,...t.exportAccess&&{access:t.exportAccess},formats:EXPORT_FORMATS.filter(o=>o==="csv"?t.exportCsv:t.exportJson)},storage:t.storageAdapter==="mongodb"?{adapter:"mongodb",connection:t.storageConnection||"default"}:{adapter:"file"},meta:{...r||{},project:String(t.project??"")||null},bundled:t.bundled===!0}}export function snapshot(e){const{fields:n=[],...r}=e||{};return JSON.stringify({...r,fields:n.map(cleanField)})}export function checkForm(e,{isNew:n=!1,taken:r=[],original:t={}}={}){const l=e||{},a={};if(String(l.title??"").trim()||(a.title="A collection needs a title."),n){const c=slugify(l.slug||l.title);c?r.includes(c)&&(a.slug=`"${c}" is already a collection.`):a.slug="The slug needs a letter or a number."}const s=Number.parseInt(l.columns,10);l.layout==="grid"&&!(s>=1&&s<=6)&&(a.columns="Between 1 and 6 columns.");const o={},i=new Map;return(l.fields||[]).forEach(c=>{const p=fieldProblem(c,t[c._key]);p&&(o[c._key]=p);const u=String(c.name??"").trim();u&&(i.has(u)?o[c._key]=o[c._key]||`"${u}" is used by another field.`:i.set(u,c._key))}),Object.keys(o).length&&(a.fields=o),l.exportEnabled&&!l.exportCsv&&!l.exportJson&&(a.export="Pick at least one export format, or turn export off."),a}export function fieldProblem(e,n){const r=String(e?.name??"").trim();return r?r!==n&&!FIELD_NAME_RE.test(r)?"Names start with a letter or _, then letters, digits, _ or -.":OPTION_TYPES.has(e.type)&&!(Array.isArray(e.options)&&e.options.length)?`A ${typeLabel(e.type).toLowerCase()} needs at least one option.`:"":"A field needs a name - it is what each entry is keyed by."}export const errorCount=e=>Object.keys(e||{}).filter(n=>n!=="fields").length+Object.keys(e?.fields||{}).length;export function fieldRow(e,n={},{grid:r=!1}={}){const t=[e.name||"(no name)",typeLabel(e.type)];return OPTION_TYPES.has(e.type)&&t.push(`${(e.options||[]).length} options`),r&&t.push(e.fullWidth?"full width":`span ${e.span>1?e.span:1}`),{key:e._key,label:e.label||"(Untitled field)",meta:t.join(" \xB7 "),required:e.required===!0,hidden:e.type==="hidden",problem:n[e._key]||"",rowCls:n[e._key]?"is-bad":""}}export function moveField(e,n,r){const t=[...e];if(n<0||n>=t.length||r<0||r>=t.length||n===r)return t;const[l]=t.splice(n,1);return t.splice(r,0,l),t}export function duplicateField(e,n){const r=e[n];if(!r)return[...e];const t=new Set(e.map(i=>i.name)),l=`${r.name||"field"}_copy`;let a=l;for(let i=2;t.has(a);i++)a=`${l}_${i}`;const s={...JSON.parse(JSON.stringify(cleanField(r))),name:a,label:`${r.label||"Field"} (copy)`,_key:fieldKey()};delete s.id;const o=[...e];return o.splice(n+1,0,s),o}export const newField=()=>({id:`field-${Date.now()}`,name:"",label:"",type:"string",required:!1,placeholder:"",helper:"",options:[],validation:[],logic:null,_key:fieldKey()});export function panelForm(e){return{label:String(e.label??""),name:String(e.name??""),type:e.type||"string",required:e.required===!0,placeholder:String(e.placeholder??""),helper:String(e.helper??""),options:optionsText(e.options),span:String(e.span>1?e.span:1),fullWidth:e.fullWidth===!0}}export function fieldFromPanel(e,n){const r={...e,label:String(n.label??"").trim(),name:String(n.name??"").trim(),type:n.type||"string",required:n.required===!0,placeholder:String(n.placeholder??"").trim(),helper:String(n.helper??"").trim()};if(OPTION_TYPES.has(r.type)&&(r.options=parseOptions(n.options)),n.fullWidth)r.fullWidth=!0,delete r.span;else{delete r.fullWidth;const t=Number.parseInt(n.span,10);t>1?r.span=Math.min(6,t):delete r.span}return r}export function storageChange(e,n){const r=e||"file",t=n||"file";return r===t?"":`${r} \u2192 ${t}`}export function draftOf(e){const{fields:n=[],...r}=e||{};return{...r,fields:n.map(cleanField)}}export function formFromDraft(e,n){const r=e||{};return{...n,...r,fields:(Array.isArray(r.fields)?r.fields:n.fields).map(t=>({...cleanField(t),_key:fieldKey()}))}}
3
+ `).filter(r=>r.trim()).map(r=>{const[n,...t]=r.split(":");return{value:n.trim(),label:t.join(":").trim()||n.trim()}})}const g=()=>({create:{enabled:!1,access:"admin"},read:{enabled:!0,access:"public"},update:{enabled:!1,access:"admin"},delete:{enabled:!1,access:"admin"}}),b=e=>({enabled:!0,access:e||"",formats:["csv","json"]});export function roleLadder(e){return(Array.isArray(e)?e:e?.entries||[]).map(n=>({name:n?.data?.name,label:n?.data?.label||n?.data?.name,level:Number(n?.data?.level)})).filter(n=>typeof n.name=="string"&&n.name&&Number.isFinite(n.level)).sort((n,t)=>n.level-t.level||n.name.localeCompare(t.name))}export function accessOptions(e,r,{token:n=!0}={}){const t=[{value:"public",label:"Public - anyone"}];n&&t.push({value:"token",label:"API token"});for(const l of[...e||[]].reverse())t.push({value:l.name,label:l.level===0?`${l.label} only`:`${l.label} and above`});return typeof r=="string"&&r&&!t.some(l=>l.value===r)&&t.push(e?.length?{value:r,label:`${r} (role not found)`,missing:!0}:{value:r,label:r}),t}let x=0;export const fieldKey=()=>`f${Date.now().toString(36)}${(x++).toString(36)}`;export function formFromCollection(e=null,{project:r="",defaultRole:n=""}={}){const t=e||{},l={...g(),...t.api||{}},o={...b(n),...t.export||{}},a={title:String(t.title??""),slug:String(t.slug??""),description:String(t.description??""),layout:t.layout==="grid"?"grid":"stacked",columns:String(Number.parseInt(t.columns,10)||2),project:String(t.meta?.project??r??""),bundled:t.bundled===!0,storageAdapter:t.storage?.adapter==="mongodb"?"mongodb":"file",storageConnection:String(t.storage?.connection||"default"),fields:(Array.isArray(t.fields)?t.fields:[]).map(i=>({...i,_key:fieldKey()})),readFields:Array.isArray(l.read?.fields)?l.read.fields.join(", "):"",exportEnabled:o.enabled!==!1,exportAccess:typeof o.access=="string"&&o.access?o.access:String(n||""),exportCsv:(Array.isArray(o.formats)?o.formats:EXPORT_FORMATS).includes("csv"),exportJson:(Array.isArray(o.formats)?o.formats:EXPORT_FORMATS).includes("json")};for(const i of OPERATIONS){a[`${i}Enabled`]=l[i]?.enabled===!0;const s=l[i]?.access;a[`${i}Access`]=typeof s=="string"&&s?s:"admin"}return a}export function cleanField(e){const{_key:r,...n}=e||{};return n}export function payloadFromForm(e,{isNew:r=!1,meta:n={}}={}){const t=e||{},l={};for(const i of OPERATIONS)l[i]={enabled:t[`${i}Enabled`]===!0,access:t[`${i}Access`]||"admin"};const o=String(t.readFields??"").split(",").map(i=>i.trim()).filter(Boolean);o.length&&(l.read.fields=o);const a=Math.min(6,Math.max(1,Number.parseInt(t.columns,10)||2));return{title:String(t.title??"").trim(),...r&&{slug:slugify(t.slug||t.title)},description:String(t.description??"").trim(),layout:t.layout==="grid"?"grid":"stacked",columns:a,fields:(t.fields||[]).map(cleanField),api:l,export:{enabled:t.exportEnabled!==!1,...t.exportAccess&&{access:t.exportAccess},formats:EXPORT_FORMATS.filter(i=>i==="csv"?t.exportCsv:t.exportJson)},storage:t.storageAdapter==="mongodb"?{adapter:"mongodb",connection:t.storageConnection||"default"}:{adapter:"file"},meta:{...n||{},project:String(t.project??"")||null},bundled:t.bundled===!0}}export function snapshot(e){const{fields:r=[],...n}=e||{};return JSON.stringify({...n,fields:r.map(cleanField)})}export function checkForm(e,{isNew:r=!1,taken:n=[],original:t={}}={}){const l=e||{},o={};if(String(l.title??"").trim()||(o.title="A collection needs a title."),r){const c=slugify(l.slug||l.title);c?n.includes(c)&&(o.slug=`"${c}" is already a collection.`):o.slug="The slug needs a letter or a number."}const a=Number.parseInt(l.columns,10);l.layout==="grid"&&!(a>=1&&a<=6)&&(o.columns="Between 1 and 6 columns.");const i={},s=new Map;return(l.fields||[]).forEach(c=>{const d=fieldProblem(c,t[c._key]);d&&(i[c._key]=d);const u=String(c.name??"").trim();u&&(s.has(u)?i[c._key]=i[c._key]||`"${u}" is used by another field.`:s.set(u,c._key))}),Object.keys(i).length&&(o.fields=i),l.exportEnabled&&!l.exportCsv&&!l.exportJson&&(o.export="Pick at least one export format, or turn export off."),o}export function fieldProblem(e,r){const n=String(e?.name??"").trim();return n?n!==r&&!FIELD_NAME_RE.test(n)?"Names start with a letter or _, then letters, digits, _ or -.":OPTION_TYPES.has(e.type)&&!(Array.isArray(e.options)&&e.options.length)?`A ${typeLabel(e.type).toLowerCase()} needs at least one option.`:"":"A field needs a name - it is what each entry is keyed by."}export const errorCount=e=>Object.keys(e||{}).filter(r=>r!=="fields").length+Object.keys(e?.fields||{}).length;export function fieldRow(e,r={},{grid:n=!1}={}){const t=[e.name||"(no name)",typeLabel(e.type)];return OPTION_TYPES.has(e.type)&&t.push(`${(e.options||[]).length} options`),n&&t.push(e.fullWidth?"full width":`span ${e.span>1?e.span:1}`),{key:e._key,label:e.label||"(Untitled field)",meta:t.join(" \xB7 "),required:e.required===!0,hidden:e.type==="hidden",problem:r[e._key]||"",rowCls:r[e._key]?"is-bad":""}}export function moveField(e,r,n){const t=[...e];if(r<0||r>=t.length||n<0||n>=t.length||r===n)return t;const[l]=t.splice(r,1);return t.splice(n,0,l),t}export function duplicateField(e,r){const n=e[r];if(!n)return[...e];const t=new Set(e.map(s=>s.name)),l=`${n.name||"field"}_copy`;let o=l;for(let s=2;t.has(o);s++)o=`${l}_${s}`;const a={...JSON.parse(JSON.stringify(cleanField(n))),name:o,label:`${n.label||"Field"} (copy)`,_key:fieldKey()};delete a.id;const i=[...e];return i.splice(r+1,0,a),i}export const newField=()=>({id:`field-${Date.now()}`,name:"",label:"",type:"string",required:!1,placeholder:"",helper:"",options:[],validation:[],logic:null,_key:fieldKey()});export function panelForm(e){return{label:String(e.label??""),name:String(e.name??""),type:e.type||"string",required:e.required===!0,placeholder:String(e.placeholder??""),helper:String(e.helper??""),options:optionsText(e.options),span:String(e.span>1?e.span:1),fullWidth:e.fullWidth===!0,prefixIcon:p(e.prefix).icon,prefixText:p(e.prefix).text,suffixIcon:p(e.suffix).icon,suffixText:p(e.suffix).text,clear:e.clear===!0,stepper:e.stepper===!0,counter:e.counter===!0}}export function fieldFromPanel(e,r){const n={...e,label:String(r.label??"").trim(),name:String(r.name??"").trim(),type:r.type||"string",required:r.required===!0,placeholder:String(r.placeholder??"").trim(),helper:String(r.helper??"").trim()};OPTION_TYPES.has(n.type)&&(n.options=parseOptions(r.options));for(const[t,l]of Object.entries(EXTRA_TYPES)){if(!l.has(n.type)){delete n[t];continue}t in r&&(r[t]===!0?n[t]=!0:delete n[t])}for(const t of["prefix","suffix"]){const l=`${t}Icon`in r||`${t}Text`in r;if(!ADDON_TYPES.has(n.type)){delete n[t];continue}if(!l)continue;const o=addonFromBoxes(r[`${t}Icon`],r[`${t}Text`]);o?n[t]=o:delete n[t]}if(r.fullWidth)n.fullWidth=!0,delete n.span;else{delete n.fullWidth;const t=Number.parseInt(r.span,10);t>1?n.span=Math.min(6,t):delete n.span}return n}export function storageChange(e,r){const n=e||"file",t=r||"file";return n===t?"":`${n} \u2192 ${t}`}export function draftOf(e){const{fields:r=[],...n}=e||{};return{...n,fields:r.map(cleanField)}}export function formFromDraft(e,r){const n=e||{};return{...r,...n,fields:(Array.isArray(n.fields)?n.fields:r.fields).map(t=>({...cleanField(t),_key:fieldKey()}))}}
@@ -1,4 +1,4 @@
1
- import{onceClosed as p}from"./tool-kit.js";const m=([o,a,n])=>n===void 0?{code:o,where:"",text:a}:{code:o,where:a,text:n},e=(o,a,n="")=>({title:o,code:a,note:n||""}),t=(o,{intro:a="",head:n=null,rows:s=[],block:l="",examples:i=[],outro:r=""}={})=>({title:o,intro:a,head:(n||[]).map((d,c)=>({i:c,text:d})),rows:s.map(m),block:l,examples:i,outro:r}),u=[t("Markdown Basics",{head:["Syntax","Result"],rows:[["**bold**","bold (rendered bold)"],["_italic_","italic (rendered italic)"],["## Heading","Heading (h2)"],["[text](url)","Link"],["![alt](url)","Image"],["- item","Bullet list"],["`code`","Inline code"],["---","Divider"]]}),t("Keyboard Shortcuts",{rows:[["Ctrl + B","Bold selected text"],["Ctrl + I","Italic selected text"],["Ctrl + K","Insert link dialog"],["Tab","Indent 2 spaces at cursor"],["Shift + Tab","Dedent 2 spaces on current line"],["Ctrl + X","Cut current line (no selection) or cut selection"],["Ctrl + C","Copy current line (no selection) or copy selection"],["Ctrl + V","Paste from clipboard"],["Ctrl + Z","Undo"],["Ctrl + Y","Redo"]]}),t("Tips",{rows:[['class="text-center"',"Centre text and inline elements on any shortcode"],['class="mx-auto"',"Centre a block element horizontally"],["[center]...[/center]","Shorthand wrapper for centred content"]],outro:"Most shortcodes accept a class attribute for custom styling."})],g=[t("Grid & Columns",{rows:[['cols="N"',"[grid] only","Number of columns (1-12)"],['gap="N"',"[grid], [row]","Gap between columns/rows (1-6)"],['span="N"',"[col] only","How many columns this cell spans"],['fullwidth="true"',"[grid] only","Break out of page container to span full viewport"],['class="x"',"all","Add extra CSS classes"]],examples:[e("Equal columns",`[grid cols="3" gap="4"]
1
+ import{onceClosed as p}from"./tool-kit.js";const m=([o,a,n])=>n===void 0?{code:o,where:"",text:a}:{code:o,where:a,text:n},e=(o,a,n="")=>({title:o,code:a,note:n||""}),t=(o,{intro:a="",head:n=null,rows:s=[],block:l="",examples:i=[],outro:r=""}={})=>({title:o,intro:a,head:(n||[]).map((d,c)=>({i:c,text:d})),rows:s.map(m),block:l,examples:i,outro:r}),u=[t("Markdown Basics",{head:["Syntax","Result"],rows:[["**bold**","bold (rendered bold)"],["_italic_","italic (rendered italic)"],["## Heading","Heading (h2)"],["[text](url)","Link"],["![alt](url)","Image"],["- item","Bullet list"],["`code`","Inline code"],["---","Divider"]]}),t("Keyboard Shortcuts",{rows:[["Ctrl + B","Bold selected text"],["Ctrl + I","Italic selected text"],["Ctrl + K","Insert link dialog"],["Tab","Indent 2 spaces at cursor"],["Shift + Tab","Dedent 2 spaces on current line"],["Ctrl + X","Cut current line (no selection) or cut selection"],["Ctrl + C","Copy current line (no selection) or copy selection"],["Ctrl + V","Paste from clipboard"],["Ctrl + Z","Undo"],["Ctrl + Y","Redo"]]}),t("Tips",{rows:[['class="text-center"',"Centre text and inline elements on any shortcode"],['class="mx-auto"',"Centre a block element horizontally"],["[center]...[/center]","Shorthand wrapper for centred content"]],outro:"Most shortcodes accept a class attribute for custom styling."})],h=[t("Grid & Columns",{rows:[['cols="N"',"[grid] only","Number of columns (1-12)"],['gap="N"',"[grid], [row]","Gap between columns/rows (1-6)"],['span="N"',"[col] only","How many columns this cell spans"],['fullwidth="true"',"[grid] only","Break out of page container to span full viewport"],['class="x"',"all","Add extra CSS classes"]],examples:[e("Equal columns",`[grid cols="3" gap="4"]
2
2
  [col]One[/col]
3
3
  [col]Two[/col]
4
4
  [col]Three[/col]
@@ -22,13 +22,15 @@ import{onceClosed as p}from"./tool-kit.js";const m=([o,a,n])=>n===void 0?{code:o
22
22
  [col]
23
23
  [card title="Three"]Content[/card]
24
24
  [/col]
25
- [/grid]`)]}),t("Box",{intro:"A card with no header - a plain container. No shadow and a 2px corner radius unless you say otherwise.",rows:[['shadow="none|md|lg"',"Shadow (default: none)"],['rounded="xs|none|sm|lg|full"',"Corners (default: xs = 2px)"],['padding="compact|spacious"',"Inner padding"],["borderless","Flag: no border"],['footer="..."',"Footer strip text"],['class="..."',"Extra CSS classes (the box also carries dm-box)"]],examples:[e("Basic box",`[box]
25
+ [/grid]`)]}),t("Box",{intro:"A card with no header - a plain container. No shadow and a 2px corner radius unless you say otherwise.",rows:[['shadow="none|md|lg"',"Shadow (default: none)"],['rounded="xs|none|sm|lg|full"',"Corners (default: xs = 2px)"],['padding="compact|spacious"',"Inner padding"],["borderless","Flag: no border"],['color="primary|secondary|success|info|warning|danger"',"Tint from the theme colour (light or dark with the theme)"],['color="#0f766e"',"Your own colour, kept on every theme, with black or white text picked for it"],['footer="..."',"Footer strip text"],['class="..."',"Extra CSS classes (the box also carries dm-box)"]],examples:[e("Basic box",`[box]
26
26
  Any content - text, buttons, a grid or a card.
27
+ [/box]`),e("Tinted from the theme",`[box color="warning"]
28
+ **Heads up:** the office is closed on Friday.
27
29
  [/box]`),e("With a shadow and rounder corners",`[box shadow="md" rounded="lg"]
28
30
  Lifted off the page.
29
31
  [/box]`,"Title, icon and image are ignored - a box never has a header. A [card] can go inside a [box], and a [box] inside a [card].")]}),t("Spacer",{intro:"Self-closing shortcode that inserts a fixed-height blank gap.",rows:[['size="N"',"Height in px (default: 8)"],['class="..."',"Extra CSS class on the spacer div"]],examples:[e("32px gap",'[spacer size="32" /]')]}),t("Center",{intro:"Wraps content in a centred div. Works with any content including cards, grids, and text.",examples:[e("Example","[center]Centred content here[/center]",'Accepts an optional class="..." attribute for extra styling.')]}),t("Hero",{intro:"Full-width hero sections - no plugin required. Uses Domma's built-in Hero CSS component.",rows:[['title="..."',"Heading text"],['tagline="..."',"Subtitle text"],['size="sm|lg|full"',"Height variant (default: normal)"],['variant="dark|primary|gradient-blue|gradient-purple|gradient-sunset|gradient-ocean"',"Colour / gradient preset"],['image="url"',"Background image URL (adds cover mode)"],['overlay="light|dark|darker|gradient|gradient-reverse"',"Image overlay style"],['align="center|left"',"Content alignment (default: center)"],['fullwidth="true"',"Break out of the page container to span full viewport width"],['bg="..."',"Background colour CSS value"],["twinkle","Flag: adds particle overlay (requires Effects plugin)"],['twinkle-count="N"',"Number of particles"],['twinkle-colour="..."',"Particle colour CSS value"],["blobs","Flag: adds ambient blob background"],['blobs-type="..."',"Blob animation type (default: float-blobs)"],['class="..."',"Extra CSS classes"],['id="..."',"Element id"]],examples:[e("Basic hero",'[hero title="Welcome" tagline="Build something great"][/hero]'),e("Gradient with body content",`[hero title="Get Started" tagline="Everything you need." size="lg" variant="gradient-blue" align="center"]
30
32
  Some introductory **Markdown** content here.
31
- [/hero]`),e("Background image with overlay",'[hero title="Our Story" image="/media/hero.jpg" overlay="dark" size="full"][/hero]',"Combine image + overlay for text legibility over photos.")]})],h=[t("Cards",{examples:[e("Basic card",`[card title="Optional Title"]
33
+ [/hero]`),e("Background image with overlay",'[hero title="Our Story" image="/media/hero.jpg" overlay="dark" size="full"][/hero]',"Combine image + overlay for text legibility over photos.")]})],g=[t("Cards",{examples:[e("Basic card",`[card title="Optional Title"]
32
34
  Markdown **works** here.
33
35
  [/card]`,"Omit title for a card with no header."),e("Icon - inline (default)",`[card title="Feature" icon="star"]
34
36
  Icon sits left of the title in a flex row.
@@ -88,7 +90,7 @@ Markdown content here.
88
90
  [undo all="true" /]
89
91
  [/scribe]`,'Use [render]...[/render] to type text, [wait]Ms[/wait] to pause, and [undo /] to delete. loop="true" replays the sequence. Only [render], [wait], and [undo] shortcodes are parsed - plain text between actions is ignored.'),e("Christmas",'[christmas intensity="medium" snowmen="off" trees="off" wreaths="off" northStars="off" sleigh="off" train="off" elf="off" robin="off" firework="off" /]',"intensity: light | medium | heavy. Count-based (off disables entirely): snowmen, trees, wreaths, northStars. Probability-based specials (off prevents spawning): sleigh, train, elf, robin, firework. Snowflakes always fall."),e("Halloween",'[halloween intensity="medium" gravestones="off" jackOLanterns="off" spiders="off" scarecrows="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): gravestones, jackOLanterns, spiders, scarecrows, twinklingStars. Particles: bats, ghosts, pumpkins (always on). Fixed decorations: haunted-house, cauldron (always present)."),e("St Patrick's Day",'[st-patricks intensity="medium" pots="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): pots (pots of gold), twinklingStars. Particles: shamrocks, clover petals, gold coins, sparkles (always on). Fixed decorations: rainbow, harp, moon. Leprechaun appears randomly - more likely at higher intensity."),e("Valentine's Day",'[valentines intensity="medium" garlands="off" butterflies="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): garlands (heart garlands), butterflies. Particles: hearts, rose petals, sparkles, lips (always on). Fixed decorations: envelopes (always present). Cupid appears randomly - more likely at higher intensity."),e("Guy Fawkes Night",'[guy-fawkes intensity="medium" bonfires="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): bonfires. Particles: embers, fireworks, sparks, rockets, bursts, trails (always on). Fixed decorations: guy-effigy, catherine-wheel, roman-candle, sparkler-bundle, moon. Starts at 25% particle density and builds up gradually."),e("St Andrew's Day",'[st-andrews intensity="medium" thistles="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): thistles, twinklingStars. Particles: heather petals, saltire sparkles (always on). Fixed decorations: bagpiper, saltire-flag, tartan-pattern, highland-scene (always present). Bagpiper probability increases with intensity."),e("St David's Day",'[st-davids intensity="medium" daffodilFields="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): daffodilFields, twinklingStars. Particles: daffodil petals, spring sparkles (always on). Fixed decorations: welsh-dragon, harp, leek-bundle, flag (always present). Flying dragon appears randomly - more likely at higher intensity."),e("St George's Day",'[st-georges intensity="medium" roses="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): roses (English roses), twinklingStars. Particles: rose petals, Tudor roses, oak leaves, sparkles (always on). Fixed decorations: st-georges-cross, dragon, shield, castle (always present). Knight appears randomly - more likely at higher intensity.")],outro:"Enable the Domma Effects plugin to activate these shortcodes on your site."}),t("DConfig - Declarative Behaviour",{intro:"Define click handlers and class toggles without writing JavaScript. Use the DConfig section above or embed inline with [dconfig]...[/dconfig] in the content body. Inline shortcodes win on selector conflict.",examples:[e("Toggle a class on click",'{ "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }','Selector keys use standard CSS selectors. "target" is optional - defaults to the element itself.'),e("Inline shortcode syntax",`[dconfig]
90
92
  { "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }
91
- [/dconfig]`)]}),t("CSS Editor",{intro:"The toolbar's </> button (left of the Split View icon) swaps the left pane between Markdown and Custom CSS. Edits are saved together with the page - one Save, one toast.",rows:[["</> button","Toggle between Markdown editor and Custom CSS editor"],["Split / Write / Preview","Standard view mode buttons to the right of </>"],["Save","Saves both page content and CSS in a single operation"]]}),t("Colour Picker",{intro:"Insert \u2192 Colour Picker opens a centred colour picker. Pick a colour, then use Copy to copy the hex code to the clipboard or Insert to drop it at the cursor. Click outside the panel to dismiss."})];export const REFERENCE=[{id:"basics",label:"Basics",sections:u},{id:"layout",label:"Layout",sections:g},{id:"components",label:"Components",sections:h},{id:"data",label:"Data",sections:b},{id:"advanced",label:"Advanced",sections:f}];const y=`
93
+ [/dconfig]`)]}),t("CSS Editor",{intro:"The toolbar's </> button (left of the Split View icon) swaps the left pane between Markdown and Custom CSS. Edits are saved together with the page - one Save, one toast.",rows:[["</> button","Toggle between Markdown editor and Custom CSS editor"],["Split / Write / Preview","Standard view mode buttons to the right of </>"],["Save","Saves both page content and CSS in a single operation"]]}),t("Colour Picker",{intro:"Insert \u2192 Colour Picker opens a centred colour picker. Pick a colour, then use Copy to copy the hex code to the clipboard or Insert to drop it at the cursor. Click outside the panel to dismiss."})];export const REFERENCE=[{id:"basics",label:"Basics",sections:u},{id:"layout",label:"Layout",sections:h},{id:"components",label:"Components",sections:g},{id:"data",label:"Data",sections:b},{id:"advanced",label:"Advanced",sections:f}];const y=`
92
94
  <div class="per">
93
95
  <div class="per-tabs" role="tablist" data-each="tabs key=id">
94
96
  <button type="button" class="per-tab" role="tab" data-on-click="$root.show(id)"
@@ -2,7 +2,7 @@ export const SHORTCODES=[{name:"card",label:"Card",icon:"card",category:"Layout"
2
2
  Content
3
3
  [/card]`,custom:"card",attrs:[{name:"title",type:"text",label:"Title"},{name:"subtitle",type:"text",label:"Subtitle"},{name:"icon",type:"icon",label:"Icon"},{name:"icon-layout",type:"select",label:"Icon layout",options:[["inline","Inline"],["stacked","Stacked"]]},{name:"footer",type:"text",label:"Footer"},{name:"variant",type:"select",label:"Variant",options:[["","Default"],["primary","Primary"]]},{name:"collapsible",type:"checkbox",label:"Collapsible"},{name:"hover",type:"checkbox",label:"Hover highlight"},{name:"class",type:"text",label:"CSS class"},{name:"id",type:"text",label:"ID"}]},{name:"box",label:"Box",icon:"square",category:"Layout",selfClosing:!1,container:!0,wrappable:!0,template:`[box]
4
4
  Content
5
- [/box]`,attrs:[{name:"shadow",type:"select",label:"Shadow",options:[["","None (default)"],["md","Medium"],["lg","Large"]]},{name:"rounded",type:"select",label:"Corners",options:[["","2px (default)"],["none","Square"],["sm","Small"],["lg","Large"],["full","Pill"]]},{name:"padding",type:"select",label:"Padding",options:[["","Normal"],["compact","Compact"],["spacious","Spacious"]]},{name:"borderless",type:"checkbox",label:"No border"},{name:"footer",type:"text",label:"Footer"},{name:"class",type:"text",label:"CSS class"},{name:"id",type:"text",label:"ID"}]},{name:"grid",label:"Grid",icon:"columns",category:"Layout",selfClosing:!1,container:!0,wrappable:!1,template:`[grid cols="2" gap="3"]
5
+ [/box]`,attrs:[{name:"shadow",type:"select",label:"Shadow",options:[["","None (default)"],["md","Medium"],["lg","Large"]]},{name:"rounded",type:"select",label:"Corners",options:[["","2px (default)"],["none","Square"],["sm","Small"],["lg","Large"],["full","Pill"]]},{name:"padding",type:"select",label:"Padding",options:[["","Normal"],["compact","Compact"],["spacious","Spacious"]]},{name:"borderless",type:"checkbox",label:"No border"},{name:"color",type:"select",label:"Colour (from the theme)",options:[["","None"],["primary","Primary"],["secondary","Secondary"],["success","Success"],["info","Info"],["warning","Warning"],["danger","Danger"]]},{name:"footer",type:"text",label:"Footer"},{name:"class",type:"text",label:"CSS class"},{name:"id",type:"text",label:"ID"}]},{name:"grid",label:"Grid",icon:"columns",category:"Layout",selfClosing:!1,container:!0,wrappable:!1,template:`[grid cols="2" gap="3"]
6
6
  [col]
7
7
  Left
8
8
  [/col]
@@ -194,6 +194,9 @@
194
194
  .cex-list { display: flex; flex-direction: column; }
195
195
  .cex-row { display: flex; align-items: center; gap: .6rem; padding: .5rem .35rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; }
196
196
  .cex-row:last-child { border-bottom: 0; }
197
+ .cex-addon-row { display: flex; align-items: center; gap: .4rem; margin-top: .35rem; }
198
+ .cex-addon-row > .form-input { flex: 1; min-width: 0; }
199
+ .cex-addon-side { flex: none; width: 3.2rem; font-size: var(--dm-text-sm, .85rem); color: var(--dm-text-muted); }
197
200
  .cex-row:hover, .cex-row:focus-visible { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); outline: none; }
198
201
  .cex-row.is-bad { box-shadow: inset 3px 0 0 var(--dm-danger); }
199
202
  /* Six dots drawn in CSS: the row template is a data-each template, so an icon
@@ -112,6 +112,10 @@
112
112
  "successRedirect": "/thanks?id=&#123;&#123;entryId&#125;&#125;",
113
113
  "layout": "grid",
114
114
  "columns": 2,
115
+ "submitAlign": "",
116
+ "submitButton": { "style": "success", "size": "lg", "iconStart": "", "iconEnd": "send" },
117
+ "secondaryButton": { "kind": "link", "label": "Cancel", "url": "/", "style": "ghost",
118
+ "iconStart": "", "iconEnd": "", "first": false, "stack": false },
115
119
  "honeypot": true,
116
120
  "rateLimitPerMinute": 3,
117
121
  "actionSlug": ""
@@ -122,6 +126,23 @@
122
126
  "collection": { "enabled": true, "slug": "contact" }
123
127
  }
124
128
  }</code></pre>
129
+ <p><code>submitAlign</code> places the submit button: <code>""</code> (the default - straight after the last
130
+ field), <code>left</code>, <code>center</code>, <code>right</code> or <code>full</code> (stretched across the
131
+ form). Any of the four gives the button a row of its own. A multi-step form's Previous / Next / Finish footer is
132
+ not affected. The older <code>submitSpan: "full"</code> is still read, as <code>left</code>.</p>
133
+ <p><code>submitButton</code> (optional) styles the button: <code>style</code> is one of <code>primary</code> (the
134
+ default), <code>secondary</code>, <code>success</code>, <code>danger</code>, <code>warning</code>,
135
+ <code>info</code>, <code>outline</code>, <code>outline-success</code>, <code>outline-danger</code>,
136
+ <code>outline-warning</code>, <code>outline-info</code> or <code>ghost</code>; <code>size</code> is
137
+ <code>""</code>, <code>sm</code> or <code>lg</code>; <code>iconStart</code> / <code>iconEnd</code> are icon names.
138
+ On a multi-step form they apply to Next and Finish (icons to Finish only).</p>
139
+ <p><code>secondaryButton</code> (optional) adds a button beside Submit: <code>kind</code> <code>reset</code> (Clear
140
+ form) or <code>link</code> (Cancel, to <code>url</code>), with its own <code>label</code>, <code>style</code> and
141
+ icons; <code>first: true</code> puts it before Submit and <code>stack: true</code> stacks the two. It shares the
142
+ submit's size and <code>submitAlign</code> places the pair. Not shown on multi-step forms. A <code>link</code>
143
+ <code>url</code> must be a path, <code>#anchor</code>, <code>?query</code> or an <code>http(s):</code>,
144
+ <code>mailto:</code> or <code>tel:</code> address - create and update answer <code>400</code> otherwise. Leave
145
+ either key out for the default button.</p>
125
146
  <p>The create and update responses carry a <code>warnings</code> array when the form and its target collection
126
147
  disagree - the collection is missing, requires a field the form does not collect, or lacks a field the form
127
148
  sends. Saving still succeeds.</p>
@@ -226,6 +247,11 @@
226
247
  stored. <code>notify</code> exists only on the server (notification source
227
248
  <code>core:form-triggers</code>).</li>
228
249
  </ul>
250
+ <p>Messages that the visitor sees (banner, toast, block reason, end message and done message) fill
251
+ <code>&#123;&#123;field_name&#125;&#125;</code> tokens with the posted answers - option labels for choices, nothing for
252
+ passwords, files, images and any field a trigger hid, disabled or masked - so the <code>400</code> body and the
253
+ done message match what the browser showed. Values are plain text, one line, at most 200 characters.
254
+ Redirect URLs and notify titles are not filled.</p>
229
255
 
230
256
  <h2>Submissions</h2>
231
257
  <p>All submissions routes read and write the form's target collection, and only this form's entries in it (in a
@@ -64,6 +64,9 @@ Subject Prefix: [Contact Form]</code></pre>
64
64
  because MongoDB is not connected - the submission is still stored.</p>
65
65
  <p>A field's <strong>triggers</strong> (Fields tab) can also run an action, notify the admins or redirect
66
66
  after submit, only when the answers meet the trigger's condition.</p>
67
+ <p>A trigger's messages can use the visitor's answers: a banner reading
68
+ <code>Hello &#123;&#123;name&#125;&#125;</code> greets them by name as they type. Use <strong>Insert field</strong> beside the
69
+ message box to pick a field. Passwords, files and images are never repeated back.</p>
67
70
 
68
71
  <h3>4. Success message vs. redirect</h3>
69
72
  <p>After a successful submission the visitor sees one of two things:</p>
@@ -21,7 +21,11 @@
21
21
  number, textarea, dropdown, radio buttons, single checkbox, checkbox group, date, time,
22
22
  URL and hidden fields. Reference (a link to an entry in another collection), file and
23
23
  multi-select fields exist too, but are added by editing the collection's
24
- <code>schema.json</code>.</li>
24
+ <code>schema.json</code>. Click a field to edit it; under <strong>Joined to the
25
+ input</strong> a text, number, dropdown, date, time, URL or textarea field can have an icon
26
+ or a short text (such as <code>£</code> or <code>kg</code>) joined to the start or end of
27
+ its input in the entry form. The same panel can add a clear button, - / + buttons on a
28
+ number, or a character counter.</li>
25
29
  <li><strong>API &amp; Export</strong> - what the public API may do (read, create, update,
26
30
  delete) and who may do it, which fields a read returns, and whether visitors may export
27
31
  what a <code>[collection]</code> display shows.</li>
@@ -103,7 +103,14 @@ Any content - text, a button, a [grid] or a [card].
103
103
  [/box]
104
104
  [box shadow="md" rounded="lg" padding="spacious"]
105
105
  Lifted off the page, with rounder corners.
106
+ [/box]
107
+ [box color="success"]
108
+ Tinted with the theme's success colour.
106
109
  [/box]</code></pre>
110
+ <p>A box follows the site's theme: its background, border, text and links come from the theme's
111
+ colours, so it reads properly on every theme, light or dark, and when a visitor switches
112
+ theme. <code>variant="dark"</code> stays a dark panel with light text on it whatever the
113
+ theme.</p>
107
114
  <table class="table table-sm">
108
115
  <thead>
109
116
  <tr>
@@ -124,6 +131,15 @@ Lifted off the page, with rounder corners.
124
131
  <code>lg</code> &middot; <code>full</code></td>
125
132
  <td>Corner radius</td>
126
133
  </tr>
134
+ <tr>
135
+ <td><code>color</code> (or <code>colour</code>)</td>
136
+ <td><code>primary</code> &middot; <code>secondary</code> &middot; <code>success</code> &middot;
137
+ <code>info</code> &middot; <code>warning</code> &middot; <code>danger</code> &middot; a hex
138
+ colour such as <code>#0f766e</code></td>
139
+ <td>A theme colour tints the box from the site's theme, light or dark. A hex colour is kept
140
+ as you gave it, with black or white text - whichever reads better. Replaces
141
+ <code>variant</code>.</td>
142
+ </tr>
127
143
  <tr>
128
144
  <td><code>padding</code>, <code>borderless</code>, <code>variant</code>, <code>footer</code>,
129
145
  <code>class</code>, <code>id</code></td>
@@ -120,10 +120,6 @@
120
120
  <div class="card mb-4">
121
121
  <div class="card-header"><h2>Settings</h2></div>
122
122
  <div class="card-body">
123
- <div class="mb-3">
124
- <label class="form-label">Submit Button Text</label>
125
- <input id="setting-submit-text" type="text" class="form-input" placeholder="Submit" value="Submit">
126
- </div>
127
123
  <div class="mb-3">
128
124
  <label class="form-label">Success Message</label>
129
125
  <textarea id="setting-success-message" class="form-input" rows="3"
@@ -153,18 +149,136 @@
153
149
  </select>
154
150
  <p class="form-hint text-muted" style="margin-top:.3rem;font-size:.8rem;">A grid form lays out in two
155
151
  or three columns. Use a field's Column Span to make one field wider than the rest.</p>
156
- <div class="mt-3">
157
- <label class="form-label">Submit Button Span</label>
158
- <select id="setting-submit-span" class="form-select">
159
- <option value="">Auto (1 column)</option>
160
- <option value="full">Full width</option>
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ <!-- The submit button: text, position, look, and an optional second
157
+ button (settings.submitText / submitAlign / submitButton /
158
+ secondaryButton - public/js/form-submit.mjs draws all of it). -->
159
+ <div class="card mb-4" id="submit-button-card">
160
+ <div class="card-header"><h2>Submit Button</h2></div>
161
+ <div class="card-body">
162
+ <div class="row">
163
+ <div class="col-6 mb-3">
164
+ <label class="form-label">Text</label>
165
+ <input id="setting-submit-text" type="text" class="form-input" placeholder="Submit" value="Submit">
166
+ </div>
167
+ <div class="col-6 mb-3">
168
+ <label class="form-label">Position <span data-help-title="Submit Button Position" data-help="Where the submit button sits. Default keeps it straight after the last field - in a grid, in the next free column. Left, Centre and Right give it a row of its own at that side of the form; Full width stretches it across the form. With a second button, this places the pair. A multi-step form keeps Previous and Next / Finish in its footer whatever this says."></span></label>
169
+ <select id="setting-submit-align" class="form-select">
170
+ <option value="">Default (after the last field)</option>
171
+ <option value="left">Left</option>
172
+ <option value="center">Centre</option>
173
+ <option value="right">Right</option>
174
+ <option value="full">Full width</option>
175
+ </select>
176
+ <p class="form-hint text-muted" style="margin-top:.3rem;font-size:.8rem;">Left, Centre, Right and Full
177
+ width put the button on a row of its own below the fields.</p>
178
+ </div>
179
+ </div>
180
+ <div class="row">
181
+ <div class="col-6 mb-3">
182
+ <label class="form-label">Style <span data-help-title="Button style" data-help="The button's colour, from the site's theme. Outline draws a border with the page showing through; Ghost is text only until hovered. A multi-step form's Next and Finish take the same style."></span></label>
183
+ <select id="setting-submit-style" class="form-select">
184
+ <option value="primary">Primary</option>
185
+ <option value="secondary">Secondary</option>
186
+ <option value="success">Success</option>
187
+ <option value="danger">Danger</option>
188
+ <option value="warning">Warning</option>
189
+ <option value="info">Info</option>
190
+ <option value="outline">Outline</option>
191
+ <option value="outline-success">Outline success</option>
192
+ <option value="outline-danger">Outline danger</option>
193
+ <option value="outline-warning">Outline warning</option>
194
+ <option value="outline-info">Outline info</option>
195
+ <option value="ghost">Ghost</option>
196
+ </select>
197
+ </div>
198
+ <div class="col-6 mb-3">
199
+ <label class="form-label">Size</label>
200
+ <select id="setting-submit-size" class="form-select">
201
+ <option value="sm">Small</option>
202
+ <option value="" selected>Normal</option>
203
+ <option value="lg">Large</option>
204
+ </select>
205
+ </div>
206
+ </div>
207
+ <div class="row">
208
+ <div class="col-6 mb-3">
209
+ <label class="form-label">Icon before the text <span data-help-title="Button icons" data-help="An icon either side of the button's text - a paper plane after Send, say. Type an icon's name or browse for one; leave both empty for text only. On a multi-step form the icons go on the Finish button, not on Next. If a trigger ends the form early, its Finish keeps them."></span></label>
210
+ <div id="setting-submit-icon-start-slot"></div>
211
+ </div>
212
+ <div class="col-6 mb-3">
213
+ <label class="form-label">Icon after the text</label>
214
+ <div id="setting-submit-icon-end-slot"></div>
215
+ </div>
216
+ </div>
217
+
218
+ <div class="mb-3" style="border-top:1px solid var(--dm-border);padding-top:1rem;">
219
+ <label class="form-label">Second button <span data-help-title="Second button" data-help="A button beside Submit. Clear form empties every answer and puts the form back as it started (the visitor is asked first if they have typed anything). Cancel link takes the visitor to a page - back to the home page, say. Not shown on a multi-step form, whose footer already has Previous."></span></label>
220
+ <select id="setting-second-kind" class="form-select">
221
+ <option value="">None</option>
222
+ <option value="reset">Clear form</option>
223
+ <option value="link">Cancel link</option>
224
+ </select>
225
+ </div>
226
+ <div id="second-button-group" style="display:none;">
227
+ <div class="row">
228
+ <div class="col-6 mb-3">
229
+ <label class="form-label">Text</label>
230
+ <input id="setting-second-label" type="text" class="form-input" placeholder="Clear form">
231
+ </div>
232
+ <div class="col-6 mb-3" id="second-url-group">
233
+ <label class="form-label">Goes to <span data-help-title="Cancel link" data-help="Where Cancel takes the visitor: a page on this site (/contact) or a full address (https://..., mailto:, tel:). Anything else is refused when you save."></span></label>
234
+ <input id="setting-second-url" type="text" class="form-input" placeholder="/">
235
+ </div>
236
+ </div>
237
+ <div class="row">
238
+ <div class="col-6 mb-3">
239
+ <label class="form-label">Style</label>
240
+ <select id="setting-second-style" class="form-select">
241
+ <option value="primary">Primary</option>
242
+ <option value="secondary" selected>Secondary</option>
243
+ <option value="success">Success</option>
244
+ <option value="danger">Danger</option>
245
+ <option value="warning">Warning</option>
246
+ <option value="info">Info</option>
247
+ <option value="outline">Outline</option>
248
+ <option value="outline-success">Outline success</option>
249
+ <option value="outline-danger">Outline danger</option>
250
+ <option value="outline-warning">Outline warning</option>
251
+ <option value="outline-info">Outline info</option>
252
+ <option value="ghost">Ghost</option>
161
253
  </select>
162
- <p class="form-hint text-muted" style="margin-top:.3rem;font-size:.8rem;">Controls how wide the submit
163
- button is in grid layout.</p>
164
254
  </div>
165
- </div>
255
+ <div class="col-6 mb-3">
256
+ <label class="form-label">Arrangement <span data-help-title="Arrangement" data-help="Which button comes first, and whether the two sit side by side or one above the other. Submit Button Position lines the pair up; with Full width, side by side buttons share the row equally."></span></label>
257
+ <div style="display:flex;gap:.5rem;">
258
+ <select id="setting-second-order" class="form-select">
259
+ <option value="after">Submit first</option>
260
+ <option value="first">This button first</option>
261
+ </select>
262
+ <select id="setting-second-arrange" class="form-select">
263
+ <option value="row">Side by side</option>
264
+ <option value="stack">Stacked</option>
265
+ </select>
166
266
  </div>
267
+ </div>
268
+ </div>
269
+ <div class="row">
270
+ <div class="col-6 mb-3">
271
+ <label class="form-label">Icon before the text</label>
272
+ <div id="setting-second-icon-start-slot"></div>
273
+ </div>
274
+ <div class="col-6 mb-3">
275
+ <label class="form-label">Icon after the text</label>
276
+ <div id="setting-second-icon-end-slot"></div>
277
+ </div>
167
278
  </div>
279
+ </div>
280
+ </div>
281
+ </div>
168
282
  </div>
169
283
 
170
284
  <!-- Tab 4: Actions -->
@@ -328,6 +442,10 @@
328
442
  .fb-trig-p-fields { background-image: none; height: auto; min-height: 6rem; padding: .3rem; }
329
443
  .fb-trig-f { display: flex; flex-direction: column; gap: .3rem; min-width: 0; margin: 0; }
330
444
  .fb-trig-f--full { grid-column: 1 / -1; }
445
+ /* A trigger message box and its "Insert field" picker ({{field_name}} tokens). */
446
+ .fb-token-row { display: flex; gap: .4rem; min-width: 0; }
447
+ .fb-token-row > .form-input:first-child { flex: 1; min-width: 0; }
448
+ .fb-token-pick { flex: none; width: auto; max-width: 12rem; }
331
449
  .fb-trig-f-label { font-size: var(--dm-text-xs); font-weight: 600; color: var(--dm-text-muted); }
332
450
  .fb-trig-f-hint { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
333
451
  .fb-trig-opts { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; padding-top: .65rem; border-top: 1px dashed var(--dm-border); }