domma-cms 1.13.2 → 1.15.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.
@@ -25,6 +25,7 @@
25
25
  <button type="button" class="tab-item">Cookie consent <i class="stx-dot" data-bind-hidden="!tabs.value.cookies" title="Changed"></i></button>
26
26
  <button type="button" class="tab-item">Analytics <i class="stx-dot" data-bind-hidden="!tabs.value.analytics" title="Changed"></i></button>
27
27
  <button type="button" class="tab-item">Breadcrumbs <i class="stx-dot" data-bind-hidden="!tabs.value.breadcrumbs" title="Changed"></i></button>
28
+ <button type="button" class="tab-item">Reduce motion <i class="stx-dot" data-bind-hidden="!tabs.value.motion" title="Changed"></i></button>
28
29
  <button type="button" class="tab-item">Cache</button>
29
30
  </div>
30
31
 
@@ -203,28 +204,8 @@
203
204
  <span data-help="Blocks (Content › Blocks) shown side by side at the top of the footer on every page, by name, in order - for example wp-footer-1, wp-footer-2. Import from WordPress puts a site's footer widget areas here. Leave it empty for none; a name that is not a block is left out."></span></label>
204
205
  <input id="stx-footer-blocks" type="text" class="form-input" maxlength="400" placeholder="wp-footer-1, wp-footer-2" data-model="footerBlocks.value">
205
206
  </div>
206
- <div class="stx-wide stx-setting">
207
- <label class="form-label" for="stx-motion">Show a “Reduce motion” switch
208
- <span data-help="An accessibility switch in the footer that lets visitors turn animations off. Turning this off only hides the switch - visitors who already chose it, and anyone whose browser asks for reduced motion, still get a calmer site."></span></label>
209
- <input id="stx-motion" type="checkbox" class="form-switch" data-model="footerMotionToggle.value">
210
- </div>
211
- <div class="stx-setting">
212
- <label class="form-label" for="stx-motion-default">Reduced motion for new visitors
213
- <span data-help="What a visitor who has not used the switch gets. Follow their device honours the system setting (the accessible default). Off shows every animation until they ask for less; On starts calm. A visitor's own choice always wins."></span></label>
214
- <select id="stx-motion-default" class="form-input" data-model="footerMotionDefault.value">
215
- <option value="system">Follow their device</option>
216
- <option value="off">Off - full motion</option>
217
- <option value="on">On - calm</option>
218
- </select>
219
- </div>
220
- <div class="stx-setting">
221
- <label class="form-label" for="stx-motion-control">The control
222
- <span data-help="How the Reduce motion control looks in the footer."></span></label>
223
- <select id="stx-motion-control" class="form-input" data-model="footerMotionControl.value">
224
- <option value="switch">A switch</option>
225
- <option value="checkbox">A checkbox</option>
226
- </select>
227
- </div>
207
+ <p class="stx-wide stx-hint">The “Reduce motion” control now has a tab of its own:
208
+ <button type="button" class="stx-inline-link" data-on-click="goTab('motion')">Reduce motion</button>.</p>
228
209
  <div>
229
210
  <label class="form-label" for="stx-footer-class">Footer CSS classes
230
211
  <span data-help-title="Footer CSS classes" data-help="Your own class names, separated by spaces, added to the site footer. Style them in Custom CSS. The footer's menus take classes of their own in Menus."></span></label>
@@ -584,6 +565,91 @@
584
565
  </div>
585
566
  </div>
586
567
 
568
+ <!-- Reduce motion -->
569
+ <div class="tab-panel">
570
+ <div class="stx-card-columns">
571
+ <div class="stx-card">
572
+ <div class="stx-card-title"><span data-icon="pause" data-icon-size="16" aria-hidden="true"></span>The control
573
+ <span data-help-title="Reduce motion" data-help="An accessibility control that lets visitors turn animations off. Turning it off only hides the control - visitors who already chose it, and anyone whose browser asks for reduced motion, still get a calmer site."></span></div>
574
+ <div class="stx-grid">
575
+ <div class="stx-wide stx-setting"><label class="form-label" for="stx-mt-on">Show it</label>
576
+ <input id="stx-mt-on" type="checkbox" class="form-switch" data-model="mtEnabled.value"></div>
577
+ <div class="stx-wide"><label class="form-label" for="stx-mt-label">Label
578
+ <span data-help="The words on the control, and what a screen reader announces. Up to 40 characters."></span></label>
579
+ <input id="stx-mt-label" type="text" class="form-input" maxlength="40" placeholder="Reduce motion" data-model="mtLabel.value"></div>
580
+ <div class="stx-wide stx-setting">
581
+ <label class="form-label" for="stx-motion-default">Reduced motion for new visitors
582
+ <span data-help="What a visitor who has not used the control gets. Follow their device honours the system setting (the accessible default). Off shows every animation until they ask for less; On starts calm. A visitor's own choice always wins."></span></label>
583
+ <select id="stx-motion-default" class="form-input" data-model="mtDefault.value">
584
+ <option value="system">Follow their device</option>
585
+ <option value="off">Off - full motion</option>
586
+ <option value="on">On - calm</option>
587
+ </select>
588
+ </div>
589
+ <div class="stx-wide stx-setting" data-bind-hidden="mtPlacement.value !== 'footer'">
590
+ <label class="form-label" for="stx-motion-control">Footer style
591
+ <span data-help="How the Reduce motion control looks in the footer."></span></label>
592
+ <select id="stx-motion-control" class="form-input" data-model="mtControl.value">
593
+ <option value="switch">A switch</option>
594
+ <option value="checkbox">A checkbox</option>
595
+ </select>
596
+ </div>
597
+ <div class="stx-wide">
598
+ <label class="form-label" for="stx-mt-class">CSS classes
599
+ <span data-help="Your own class names, separated by spaces, added to the control wherever it sits. Style them in Custom CSS."></span></label>
600
+ <input id="stx-mt-class" type="text" class="form-input" placeholder="motion-quiet" autocomplete="off" spellcheck="false" data-model="mtClass.value">
601
+ </div>
602
+ </div>
603
+ </div>
604
+ <div class="stx-card">
605
+ <div class="stx-card-title"><span data-icon="layout" data-icon-size="16" aria-hidden="true"></span>Placement
606
+ <span data-help-title="Placement" data-help="Footer puts it at the foot of every page, as it always was. Floating button is a disc fixed to a corner of the window, kept clear of Search, Back to top and the theme switcher. Navbar adds it at the end of the navbar - on a page with no navbar, or on a phone when the navbar shows only its menu button, it floats instead."></span></div>
607
+ <div class="stx-grid">
608
+ <div class="stx-wide">
609
+ <span class="form-label">Where it goes</span>
610
+ <div class="stx-segments" role="group" aria-label="Where it goes">
611
+ <button type="button" data-on-click="motionPlacement('footer')" data-bind-class="mtPlacement.value === 'footer' && 'is-on'">Footer</button>
612
+ <button type="button" data-on-click="motionPlacement('floating')" data-bind-class="mtPlacement.value === 'floating' && 'is-on'">Floating button</button>
613
+ <button type="button" data-on-click="motionPlacement('navbar')" data-bind-class="mtPlacement.value === 'navbar' && 'is-on'">Navbar</button>
614
+ </div>
615
+ </div>
616
+ <div class="stx-setting stx-wide" data-bind-hidden="mtPlacement.value === 'footer'">
617
+ <label class="form-label" for="stx-mt-style">Button
618
+ <span data-help="An icon on its own (the label is still read out by screen readers), or the icon with the label beside it."></span></label>
619
+ <select id="stx-mt-style" class="form-input" data-model="mtTriggerStyle.value">
620
+ <option value="icon">Icon</option>
621
+ <option value="icon-text">Icon and label</option>
622
+ </select>
623
+ </div>
624
+ <div class="stx-wide" data-bind-hidden="mtPlacement.value !== 'floating'">
625
+ <span class="form-label">Corner</span>
626
+ <div class="stx-corners" role="group" aria-label="Corner">
627
+ <button type="button" data-on-click="motionCorner('TL')" data-bind-class="mtPosition.value === 'TL' && 'is-on'">↖ Top left</button>
628
+ <button type="button" data-on-click="motionCorner('TR')" data-bind-class="mtPosition.value === 'TR' && 'is-on'">Top right ↗</button>
629
+ <button type="button" data-on-click="motionCorner('BL')" data-bind-class="mtPosition.value === 'BL' && 'is-on'">↙ Bottom left</button>
630
+ <button type="button" data-on-click="motionCorner('BR')" data-bind-class="mtPosition.value === 'BR' && 'is-on'">Bottom right ↘</button>
631
+ </div>
632
+ </div>
633
+ <div class="stx-pair stx-wide" data-bind-hidden="mtPlacement.value !== 'floating'">
634
+ <div><label class="form-label" for="stx-mt-x">From the side (px)</label>
635
+ <input id="stx-mt-x" type="number" class="form-input" min="0" max="500" placeholder="32" data-model="mtOffsetX.value"></div>
636
+ <div><label class="form-label" for="stx-mt-y">From the top/bottom (px)</label>
637
+ <input id="stx-mt-y" type="number" class="form-input" min="0" max="500" placeholder="32" data-model="mtOffsetY.value"></div>
638
+ </div>
639
+ <div class="stx-pair stx-wide" data-bind-hidden="mtPlacement.value !== 'floating' || mtTriggerStyle.value !== 'icon'">
640
+ <div><label class="form-label" for="stx-mt-size">Size <span class="stx-readout" data-bind-text="mtSizeText.value"></span>
641
+ <span data-help="How big the disc is (32-96 px). The glyph inside scales with it. 44 px and up is comfortable to tap on a phone."></span></label>
642
+ <input id="stx-mt-size" type="range" min="32" max="96" step="2" data-model="mtIconSize.value"></div>
643
+ <div><label class="form-label" for="stx-mt-radius">Corners <span class="stx-readout" data-bind-text="mtRadiusText.value"></span>
644
+ <span data-help="0 is a square; the top of the range is a circle, and it is always half the size - so it moves when the size does."></span></label>
645
+ <input id="stx-mt-radius" type="range" min="0" step="1" data-model="mtIconRadius.value"></div>
646
+ </div>
647
+ </div>
648
+ <p class="stx-hint" data-bind-hidden="mtPlacement.value !== 'floating'">If Search, Back to top or the theme switcher sits in the same corner, the disc stacks clear of it, further from the edge.</p>
649
+ </div>
650
+ </div>
651
+ </div>
652
+
587
653
  <!-- Cache -->
588
654
  <div class="tab-panel">
589
655
  <div class="stx-card">
@@ -662,6 +728,14 @@
662
728
  .stx-corners button:nth-child(-n+2) { border-bottom: 1px solid var(--dm-border); }
663
729
  .stx-corners button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
664
730
  .stx-corners button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
731
+ .stx-segments { display: inline-flex; flex-wrap: wrap; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
732
+ .stx-segments button { border: 0; background: none; color: var(--dm-text); padding: .45rem .8rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
733
+ .stx-segments button + button { border-left: 1px solid var(--dm-border); }
734
+ .stx-segments button:hover { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); }
735
+ .stx-segments button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
736
+ .stx-readout { color: var(--dm-text-muted); font-weight: 400; font-variant-numeric: tabular-nums; }
737
+ .stx-pair input[type="range"] { width: 100%; }
738
+ .stx-inline-link { background: none; border: 0; padding: 0; font: inherit; color: var(--dm-primary); text-decoration: underline; cursor: pointer; }
665
739
  .stx-chips { display: flex; gap: .35rem; flex-wrap: wrap; }
666
740
  .stx-chip { display: inline-flex; align-items: center; font-size: var(--dm-text-xs); font-weight: 600; padding: .1rem .5rem; border-radius: 999px; white-space: nowrap;
667
741
  background: color-mix(in srgb, var(--dm-text) 10%, transparent); color: var(--dm-text-muted); }
@@ -1,11 +1,11 @@
1
- import{api as B,apiRequest as C}from"../api.js";import{placeTools as Ie}from"../lib/sidebar-grouping.js";import{addBannerAction as me}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{openMenuAt as Y,sidebarCount as fe}from"../lib/tool-kit.js";import{changedTabs as Pe,describeCacheKey as J,draftOf as $e,expiresIn as je,footerShowcaseErrors as Be,formFromSettings as De,gaIdError as he,homeValue as He,payloadFromForm as qe,siteUrlError as ve,settingsProblems as Fe,snapshot as be,TAB_FIELDS as Ue}from"../lib/site-settings-arrange.js";import{pickMedia as _e}from"../lib/media-picker.js";import{faviconGenerator as Ke}from"../lib/favicon-generator.js";import{makeIconInput as Ne}from"../lib/shortcode-modal.js";import{fmtDateTime as ze}from"../lib/dates.js";const Q=/^\/settings(\?.*)?$/,ge="#/settings",w="settings.draft",Ge=`
1
+ import{api as D,apiRequest as k}from"../api.js";import{placeTools as $e}from"../lib/sidebar-grouping.js";import{addBannerAction as fe}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{openMenuAt as V,sidebarCount as he}from"../lib/tool-kit.js";import{changedTabs as De,describeCacheKey as W,draftOf as Be,expiresIn as He,footerShowcaseErrors as je,formFromDraft as qe,formFromSettings as Fe,gaIdError as ve,homeValue as Ue,payloadFromForm as _e,siteUrlError as be,settingsProblems as Ke,snapshot as ge,TAB_FIELDS as ze}from"../lib/site-settings-arrange.js";import{pickMedia as Ne}from"../lib/media-picker.js";import{faviconGenerator as Ge}from"../lib/favicon-generator.js";import{makeIconInput as Ve}from"../lib/shortcode-modal.js";import{fmtDateTime as We}from"../lib/dates.js";const Y=/^\/settings(\?.*)?$/,ye="#/settings",y="settings.draft",Ye=`
2
2
  <div class="stx-problem">
3
3
  <span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
4
4
  <span>{{text}}</span>
5
5
  <button type="button" class="btn btn-sm btn-ghost" data-go="{{tab}}">Go there</button>
6
- </div>`,Ve=`
6
+ </div>`,Je=`
7
7
  <div class="stx-row" data-id="{{id}}" tabindex="0">
8
8
  <span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
9
9
  <span class="stx-chip">{{expires}}</span>
10
10
  <button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
11
- </div>`,We=["general","branding","favicon","seo","footer","email","pageNavigation","cookies","analytics","breadcrumbs","cache"];async function Ye(l,u){const[r,k]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),n=new Map([["/","Dashboard (built in)"]]),i=(v,d)=>{for(const o of v||[]){if(!o||o.hidden||o.restricted)continue;const m=typeof o.url=="string"?o.url:"";m.startsWith("#/")&&m.length>2&&!n.has(m.slice(1))&&n.set(m.slice(1),`${d?`${d} \u203A `:""}${o.text||m}`),i(o.items,o.url?d:o.text||d)}};i(Ie(r?.items||[],k||[]),""),n.has(u)||n.set(u,`${u} (not in the sidebar)`),l.replaceChildren();for(const[v,d]of n){const o=document.createElement("option");o.value=v,o.textContent=d,l.appendChild(o)}}let f=null,ye=!1;function Je(){ye||!window.R?.use||(ye=!0,R.use((l,u,r)=>!(Q.test(u?.path||"")&&!Q.test(l?.path||""))||!f?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(n=>{n?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(f&&!Q.test(l?.path||"")){const u=f;f=null,u.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Je(),f){const e=f;f=null,e.dispose()}const u=[],r=e=>(u.push(e),e);let k=!1;const n=l.get(0).querySelector(".stx")||l.get(0),i=()=>!k&&n.isConnected,v=E.loader(l.get(0),{type:"dots"});let d;try{d=await B.settings.get()}catch(e){v.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(v.destroy(),!n.isConnected)return;const o=M.observable(d||{}),m=De(d||{}),s={};for(const[e,t]of Object.entries(m))s[e]=M.observable(t);const D=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),X=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),b=M.observable(m),q=M.computed(()=>be(b.value)),x=M.computed(()=>be(D())!==q.value),T=M.observable(!1),L=M.observable(""),Z=M.observable(!1);B.plugins.policy().then(e=>{i()&&(Z.value=e?.signedOnly===!1)}).catch(()=>{});const F=M.observable(!1),ee=M.computed(()=>{const e=new Set(Pe(D(),b.value));return Object.fromEntries(Object.keys(Ue).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=Fe(D()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const te=M.observable(""),O=M.observable(!1),A=M.observable(""),P=M.observable(""),p=M.observable(null),_=M.observable([]),K=M.observable(""),N=M.observable(!0),z=M.observable(""),ae=M.observable([]);r(M.effect(()=>{const e=K.value.trim().toLowerCase(),t=Date.now();ae.value=[..._.value].reverse().filter(a=>!e||String(a.key).toLowerCase().includes(e)||(a.tags||[]).some(c=>String(c).toLowerCase().includes(e))).map(a=>{const c=J(a.key);return{id:a.key,path:c.path,meta:[c.page?`as ${c.role}`:"not a page",(a.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:je(a.expiresAt,t)}})}));const se=Ke(s.favicon,s.brandLogo,i),we={...se.data,...s,saving:T,dirty:x,tabs:ee,problems:U,testTo:te,testing:O,testResult:A,testTone:P,cacheQuery:K,cacheLoading:N,cacheError:z,cacheRows:ae,errorText:L,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>ve(s.siteUrl.value)),gaIdError:M.computed(()=>he(s.gaId.value)),gaForcesBanner:M.computed(()=>!!String(s.gaId.value||"").trim()&&s.ccEnabled.value!==!0),noSmtp:M.computed(()=>!String(s.smtpHost.value||"").trim()),mayAllowInsecureTLS:Z,dirtyEmail:M.computed(()=>ee.value.email),seoLength:M.computed(()=>{const e=String(s.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cacheOn:M.computed(()=>p.value?.enabled===!0),cacheState:M.computed(()=>{const e=p.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=p.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=p.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>p.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>K.value.trim()?"Nothing matches.":p.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function G(){if(T.peek())return;const e=X(),t=b.peek(),a=ve(e.siteUrl);if(a){E.toast(`Site URL: ${a}`,{type:"error"});return}const c=he(e.gaId);if(c){E.toast(`Google Analytics: ${c}`,{type:"error"});return}const y=Be(e)[0];if(y){E.toast(y,{type:"error"});return}T.value=!0,L.value="";const W=String(e.title).trim()!==String(t.title).trim();try{await B.settings.save(qe(e,o.peek(),{adminHomeReady:F.peek()}));const j=await B.settings.get().catch(()=>null);if(!i())return;j&&(o.value=j);const Re={...e,smtpPass:""};s.smtpPass.value="",b.value=Re;try{S.remove?S.remove(w):S.set(w,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(W||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(pe=>e[pe]!==t[pe]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),fe(ge),E.toast("Settings saved.",{type:"success"})}catch(j){L.value=j.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{T.value=!1}}async function ke(){await E.confirm("Throw away your unsaved settings?")&&(ne(b.peek()),L.value="")}function ne(e){for(const[t,a]of Object.entries(e))s[t]&&(s[t].value=a)}async function Ee(){if(!O.peek()){O.value=!0,P.value="",A.value="Sending\u2026";try{const e=await C("/settings/test-email",{method:"POST",body:JSON.stringify({to:te.peek().trim()||void 0})});if(!i())return;A.value=e?.message||"Test email sent.",P.value="is-ok"}catch(e){if(!i())return;A.value=e.message||"Could not send the test email.",P.value="is-bad"}finally{O.value=!1}}}async function $(){N.value=!0;try{const[e,t]=await Promise.all([C("/cache/status").catch(()=>null),C("/cache/keys")]);if(!i())return;p.value=e,_.value=Array.isArray(t?.entries)?t.entries:[],z.value=""}catch(e){if(!i())return;z.value=`Could not read the cache: ${e.message||e}`}finally{N.value=!1}}async function Se(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await C("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await $()}}async function Me(){const e=p.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{p.value=await C("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),fe(ge)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await $()}}const oe=e=>_.peek().find(t=>t.key===e);function Ce(e){const t=oe(e);if(!t)return[];const a=J(t.key);return[{header:a.path},{label:"Open the page",icon:"external-link",disabled:!a.page,action:()=>window.open(a.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...a.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(a.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}n.querySelector(".stx-problems").innerHTML=Ge;const g=n.querySelector(".stx-list");g.innerHTML=Ve;const xe=E.tabs(n.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Te=M.applyBindings(we,n,{methods:{...se.methods,save:G,discard:ke,sendTest:Ee,refreshCache:$,clearCache:Se,toggleCache:Me,pickBrandImage:async e=>{const t=await _e({title:"Choose a branding image",current:s[e].peek()});t&&i()&&(s[e].value=t)},clearBrandImage:e=>{s[e].value=""},corner:e=>{s.bcPosition.value=e}}});r(()=>Te.dispose());for(const e of n.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,a=Ne("Choose an icon",s[t].peek());a.input.id=`stx-${t}`,a.input.classList.add("form-input");const c=()=>{s[t].value=a.input.value};a.input.addEventListener("input",c),r(()=>a.input.removeEventListener("input",c)),r(M.effect(()=>{a.input.value=s[t].value})),e.appendChild(a.el)}r(Ae(n,{position:"bottom"}));const Le=e=>{const t=e.target.closest("[data-go]");if(!t)return;const a=We.indexOf(t.dataset.go);a>=0&&xe.activate(a)};n.querySelector(".stx-problems").addEventListener("click",Le);const V=n.querySelector("#stx-admin-home");Ye(V,He(s.adminHome.peek())).then(()=>{i()&&(V.value=s.adminHome.peek(),F.value=!0)}).catch(()=>{});const Oe=E.contextMenu(g,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Ce(e.dataset.id)});r(()=>Oe?.destroy?.());const re=e=>{const t=oe(e.dataset.id),a=t&&J(t.key);a?.page?window.open(a.path,"_blank","noopener"):Y(e)},ie=e=>{const t=e.target.closest(".stx-row");if(!t)return;const a=e.target.closest(".stx-kebab");if(a){Y(a);return}re(t)},ce=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Y(t)):e.key==="Enter"&&e.target===t&&re(t))};g.addEventListener("click",ie),g.addEventListener("keydown",ce),r(()=>{g.removeEventListener("click",ie),g.removeEventListener("keydown",ce)}),me(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),me(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>G()});const le=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),G())};document.addEventListener("keydown",le,!0),r(()=>document.removeEventListener("keydown",le,!0));const ue=()=>{if(x.peek())try{S.set(w,{form:$e(X()),base:q.peek(),at:new Date().toISOString()})}catch{}},de=e=>{x.peek()&&(ue(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",de),r(()=>window.removeEventListener("beforeunload",de)),f={dirty:()=>x.peek(),dispose(){k=!0,ue(),u.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(n),$();let h=null;try{h=S.get(w)}catch{}if(h?.form){try{S.remove?S.remove(w):S.set(w,null)}catch{}const e=h.base&&h.base!==q.peek(),t=h.at?` (${ze(h.at)})`:"",a=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(a)&&i()){const c={...b.peek()};for(const[y,W]of Object.entries(h.form))y in c&&y!=="smtpPass"&&(c[y]=W);ne(c),F.peek()&&(V.value=s.adminHome.peek())}}}};
11
+ </div>`,we=["general","branding","favicon","seo","footer","email","pageNavigation","cookies","analytics","breadcrumbs","motion","cache"];async function Qe(c,u){const[o,w]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),s=new Map([["/","Dashboard (built in)"]]),i=(v,d)=>{for(const r of v||[]){if(!r||r.hidden||r.restricted)continue;const p=typeof r.url=="string"?r.url:"";p.startsWith("#/")&&p.length>2&&!s.has(p.slice(1))&&s.set(p.slice(1),`${d?`${d} \u203A `:""}${r.text||p}`),i(r.items,r.url?d:r.text||d)}};i($e(o?.items||[],w||[]),""),s.has(u)||s.set(u,`${u} (not in the sidebar)`),c.replaceChildren();for(const[v,d]of s){const r=document.createElement("option");r.value=v,r.textContent=d,c.appendChild(r)}}let f=null,ke=!1;function Xe(){ke||!window.R?.use||(ke=!0,R.use((c,u,o)=>!(Y.test(u?.path||"")&&!Y.test(c?.path||""))||!f?.dirty()?o():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(s=>{s?o():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:c}={})=>{if(f&&!Y.test(c?.path||"")){const u=f;f=null,u.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(c){if(Xe(),f){const e=f;f=null,e.dispose()}const u=[],o=e=>(u.push(e),e);let w=!1;const s=c.get(0).querySelector(".stx")||c.get(0),i=()=>!w&&s.isConnected,v=E.loader(c.get(0),{type:"dots"});let d;try{d=await D.settings.get()}catch(e){v.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(v.destroy(),!s.isConnected)return;const r=M.observable(d||{}),p=Fe(d||{}),n={};for(const[e,t]of Object.entries(p))n[e]=M.observable(t);const B=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),J=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),b=M.observable(p),j=M.computed(()=>ge(b.value)),x=M.computed(()=>ge(B())!==j.value),C=M.observable(!1),T=M.observable(""),Q=M.observable(!1);D.plugins.policy().then(e=>{i()&&(Q.value=e?.signedOnly===!1)}).catch(()=>{});const q=M.observable(!1),X=M.computed(()=>{const e=new Set(De(B(),b.value));return Object.fromEntries(Object.keys(ze).map(t=>[t,e.has(t)]))}),F=M.observable([]);o(M.effect(()=>{F.value=Ke(B()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const Z=M.observable(""),L=M.observable(!1),O=M.observable(""),P=M.observable(""),m=M.observable(null),U=M.observable([]),_=M.observable(""),K=M.observable(!0),z=M.observable(""),ee=M.observable([]);o(M.effect(()=>{const e=_.value.trim().toLowerCase(),t=Date.now();ee.value=[...U.value].reverse().filter(a=>!e||String(a.key).toLowerCase().includes(e)||(a.tags||[]).some(l=>String(l).toLowerCase().includes(e))).map(a=>{const l=W(a.key);return{id:a.key,path:l.path,meta:[l.page?`as ${l.role}`:"not a page",(a.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:He(a.expiresAt,t)}})}));const te=Ge(n.favicon,n.brandLogo,i),Ee={...te.data,...n,saving:C,dirty:x,tabs:X,problems:F,testTo:Z,testing:L,testResult:O,testTone:P,cacheQuery:_,cacheLoading:K,cacheError:z,cacheRows:ee,errorText:T,problemsText:M.computed(()=>F.value.length?"yes":""),siteUrlError:M.computed(()=>be(n.siteUrl.value)),gaIdError:M.computed(()=>ve(n.gaId.value)),gaForcesBanner:M.computed(()=>!!String(n.gaId.value||"").trim()&&n.ccEnabled.value!==!0),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),mtSizeText:M.computed(()=>`${n.mtIconSize.value} px`),mtRadiusText:M.computed(()=>`${n.mtIconRadius.value} px`),mayAllowInsecureTLS:Q,dirtyEmail:M.computed(()=>X.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cacheOn:M.computed(()=>m.value?.enabled===!0),cacheState:M.computed(()=>{const e=m.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=m.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=m.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>m.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>_.value.trim()?"Nothing matches.":m.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function N(){if(C.peek())return;const e=J(),t=b.peek(),a=be(e.siteUrl);if(a){E.toast(`Site URL: ${a}`,{type:"error"});return}const l=ve(e.gaId);if(l){E.toast(`Google Analytics: ${l}`,{type:"error"});return}const me=je(e)[0];if(me){E.toast(me,{type:"error"});return}C.value=!0,T.value="";const Oe=String(e.title).trim()!==String(t.title).trim();try{await D.settings.save(_e(e,r.peek(),{adminHomeReady:q.peek()}));const A=await D.settings.get().catch(()=>null);if(!i())return;A&&(r.value=A);const Pe={...e,smtpPass:""};n.smtpPass.value="",b.value=Pe;try{S.remove?S.remove(y):S.set(y,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(Oe||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(pe=>e[pe]!==t[pe]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),he(ye),E.toast("Settings saved.",{type:"success"})}catch(A){T.value=A.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{C.value=!1}}async function Se(){await E.confirm("Throw away your unsaved settings?")&&(ae(b.peek()),T.value="")}function ae(e){for(const[t,a]of Object.entries(e))n[t]&&(n[t].value=a)}async function Me(){if(!L.peek()){L.value=!0,P.value="",O.value="Sending\u2026";try{const e=await k("/settings/test-email",{method:"POST",body:JSON.stringify({to:Z.peek().trim()||void 0})});if(!i())return;O.value=e?.message||"Test email sent.",P.value="is-ok"}catch(e){if(!i())return;O.value=e.message||"Could not send the test email.",P.value="is-bad"}finally{L.value=!1}}}async function $(){K.value=!0;try{const[e,t]=await Promise.all([k("/cache/status").catch(()=>null),k("/cache/keys")]);if(!i())return;m.value=e,U.value=Array.isArray(t?.entries)?t.entries:[],z.value=""}catch(e){if(!i())return;z.value=`Could not read the cache: ${e.message||e}`}finally{K.value=!1}}async function xe(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await k("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}i()&&await $()}}async function Ce(){const e=m.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{m.value=await k("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),he(ye)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}i()&&await $()}}const ne=e=>U.peek().find(t=>t.key===e);function Te(e){const t=ne(e);if(!t)return[];const a=W(t.key);return[{header:a.path},{label:"Open the page",icon:"external-link",disabled:!a.page,action:()=>window.open(a.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...a.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(a.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}s.querySelector(".stx-problems").innerHTML=Ye;const g=s.querySelector(".stx-list");g.innerHTML=Je;const se=E.tabs(s.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Ie=M.applyBindings(Ee,s,{methods:{...te.methods,save:N,discard:Se,sendTest:Me,refreshCache:$,clearCache:xe,toggleCache:Ce,pickBrandImage:async e=>{const t=await Ne({title:"Choose a branding image",current:n[e].peek()});t&&i()&&(n[e].value=t)},clearBrandImage:e=>{n[e].value=""},corner:e=>{n.bcPosition.value=e},motionCorner:e=>{n.mtPosition.value=e},motionPlacement:e=>{n.mtPlacement.value=e},goTab:e=>{const t=we.indexOf(e);t>=0&&se.activate(t)}}}),oe=s.querySelector("#stx-mt-radius");o(M.effect(()=>{const e=Math.floor((Number.parseInt(n.mtIconSize.value,10)||52)/2);oe.max=String(e);const t=Number.parseInt(n.mtIconRadius.peek(),10)||0;t>e?n.mtIconRadius.value=String(e):oe.value=String(t)})),o(()=>Ie.dispose());for(const e of s.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,a=Ve("Choose an icon",n[t].peek());a.input.id=`stx-${t}`,a.input.classList.add("form-input");const l=()=>{n[t].value=a.input.value};a.input.addEventListener("input",l),o(()=>a.input.removeEventListener("input",l)),o(M.effect(()=>{a.input.value=n[t].value})),e.appendChild(a.el)}o(Ae(s,{position:"bottom"}));const Re=e=>{const t=e.target.closest("[data-go]");if(!t)return;const a=we.indexOf(t.dataset.go);a>=0&&se.activate(a)};s.querySelector(".stx-problems").addEventListener("click",Re);const G=s.querySelector("#stx-admin-home");Qe(G,Ue(n.adminHome.peek())).then(()=>{i()&&(G.value=n.adminHome.peek(),q.value=!0)}).catch(()=>{});const Le=E.contextMenu(g,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Te(e.dataset.id)});o(()=>Le?.destroy?.());const re=e=>{const t=ne(e.dataset.id),a=t&&W(t.key);a?.page?window.open(a.path,"_blank","noopener"):V(e)},ie=e=>{const t=e.target.closest(".stx-row");if(!t)return;const a=e.target.closest(".stx-kebab");if(a){V(a);return}re(t)},ce=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),V(t)):e.key==="Enter"&&e.target===t&&re(t))};g.addEventListener("click",ie),g.addEventListener("keydown",ce),o(()=>{g.removeEventListener("click",ie),g.removeEventListener("keydown",ce)}),fe(c,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),fe(c,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>N()});const le=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),N())};document.addEventListener("keydown",le,!0),o(()=>document.removeEventListener("keydown",le,!0));const ue=()=>{if(x.peek())try{S.set(y,{form:Be(J()),base:j.peek(),at:new Date().toISOString()})}catch{}},de=e=>{x.peek()&&(ue(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",de),o(()=>window.removeEventListener("beforeunload",de)),f={dirty:()=>x.peek(),dispose(){w=!0,ue(),u.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(s),$();let h=null;try{h=S.get(y)}catch{}if(h?.form){try{S.remove?S.remove(y):S.set(y,null)}catch{}const e=h.base&&h.base!==j.peek(),t=h.at?` (${We(h.at)})`:"",a=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;await E.confirm(a)&&i()&&(ae(qe(b.peek(),h.form)),q.peek()&&(G.value=n.adminHome.peek()))}}};
@@ -359,6 +359,7 @@ headings (Layouts → Page contents settings, and `[contents]` per page).
359
359
 
360
360
  ### Moving text and swooshing cards
361
361
 
362
+ - **Highlight text in the editor** - Insert… › Content › Highlight text, or select words and Wrap in… › Highlight text: the [text] dialog with a gradient and shine chosen, highlight fields first, and a live gradient preview. Right-clicking a [text] tag offers Highlight, sweeping Highlight, Light up, Rise word by word and Plain.
362
363
  - **Gradient text with a shine** - `[text gradient="sunset" shine="hover|sweep"]` paints words in a gradient (named: brand, sunset, ocean, forest, fire, candy, or two hex colours); the shine runs a highlight through them, following the pointer or sweeping on its own. `[text]` tags nest.
363
364
  - **Light up as you read** - `[text light-up]` dims a passage and lights it word by word as it scrolls up the window.
364
365
  - **Rise word by word** - `[text words]` brings a line in word by word as it arrives.
@@ -1098,8 +1099,9 @@ Where: `server/services/presetCollections.js` (`PRESETS`, `seedAll()`), `server/
1098
1099
  - **Site Settings** - One screen with one Save covering nine tabs:
1099
1100
  - General: title, tagline, Site URL, admin home, default spacer size.
1100
1101
  - SEO defaults: default title, separator, meta description.
1101
- - Footer: copyright, a "Reduce motion" switch, social icons for X, Facebook, Instagram, LinkedIn, GitHub and YouTube.
1102
+ - Footer: copyright, layout, blurb, footer blocks, social icons for X, Facebook, Instagram, LinkedIn, GitHub and YouTube.
1102
1103
  - Showcase footer (Footer tab): `footer.look: showcase` (glowing top line, more room), `footer.cta` (a big call to action, some words in a gradient, optional link and sub-line), `footer.watermark` (a huge faint name behind the footer that drifts on scroll), `footer.email` / `footer.address` under the brand, `footer.status` (green dot) and up to four `footer.facts`, `footer.tagline` in the bottom bar, `footer.gradient` `{from, to}`. Columns layout only; each part shows when it has words. Drawn by `public/js/footer-showcase.mjs` + `footer-showcase.css`.
1104
+ - Reduce motion: on/off, label, the default for new visitors; in the footer (switch or checkbox), a floating button (corner, offsets, size, corners) or the navbar.
1103
1105
  - Email: SMTP host, port, user, password and TLS; From address and name; send a test email.
1104
1106
  - Back to top: on/off, label, threshold, position, offsets, smooth scrolling.
1105
1107
  - Cookie consent: message, position, layout, colours, categories, policy links, button text, consent version.
@@ -137,7 +137,6 @@ The main site-wide configuration. This file is editable from the **Settings** ad
137
137
  "copyright": "© 2026 My Company",
138
138
  "layout": "auto",
139
139
  "blurb": "",
140
- "showMotionToggle": true,
141
140
  "links": [
142
141
  { "text": "Privacy Policy", "url": "/privacy" }
143
142
  ]
@@ -195,7 +194,8 @@ The main site-wide configuration. This file is editable from the **Settings** ad
195
194
  | `theme`, `adminTheme`, `fontFamily`, `fontSize`, `autoTheme`, `baseTheme` | **Mirror only.** These moved to `config/theme.json` (see below) and are rewritten here on every theme save so plugins and fleet tooling that read them keep working. Editing them here has no effect. |
196
195
  | `smtp` | SMTP connection details for outgoing email (password is stripped from GET responses) - see **Outgoing email** below |
197
196
  | `social` | Social profile URLs; empty strings are ignored by the frontend |
198
- | `footer` | Footer copyright, legacy link list, and the "Reduce motion" switch - see below |
197
+ | `footer` | Footer copyright, layout, blocks and classes - see below |
198
+ | `motion` | The "Reduce motion" control: shown, default, footer/floating/navbar placement - see **Reduce motion** below |
199
199
  | `backToTop` | Scroll-to-top button configuration |
200
200
  | `cookieConsent` | Cookie consent banner configuration |
201
201
  | `googleAnalytics` | `{measurementId: "G-…"}` - Google Analytics 4, set at Site Settings > Analytics. See **Google Analytics** below |
@@ -285,15 +285,60 @@ the host is empty you get, in order of how likely you are to see it:
285
285
  | `layout` | string | `auto` (default), `columns` or `row`. **Columns**: the brand (the navbar's text, logo or icon), the blurb and the social links beside one column per group of the `footer-primary` menu (a top-level item with sub-items; its text is the heading, a link only if it has a URL), over a bottom bar of copyright, the `footer-legal` menu and the motion control. **Row**: everything on one wrapped row, as before. **Auto**: columns when the footer menu has groups, else a row. Site Settings > Footer > Layout. |
286
286
  | `blurb` | string | A sentence or two under the brand in the columns layout. Plain text. |
287
287
  | `blocks` | array | Block names (`content/blocks/<name>.html`) drawn side by side at the top of the footer on every page, in order - up to 8, lower-case letters, digits and hyphens. Rendered by the server (`buildFooterBlocks()` in `renderer.js`, both render paths) as `[block template="…" /]`, so sanitised like a page; a name that is not a block is left out. Site Settings > Footer > Footer blocks; Import from WordPress adds a site's footer widget areas here. |
288
- | `showMotionToggle` | boolean | Whether the footer shows the "Reduce motion" accessibility switch. **Defaults to `true`** - omit the key and the switch appears, so sites that predate this setting are unchanged. Editable from Site Settings > Footer. |
288
+ | `showMotionToggle`, `motionDefault`, `motionControl` | boolean, string, string | **Mirror of `motion`** (below) - `enabled`, `default` and `control`. Still honoured key by key where `motion` says nothing, so a site that predates the block renders as before; Site Settings rewrites them on every save for older readers and fleet tooling. A save that sends only these keys moves the `motion` block with them. |
289
289
  | `class` | string | Your own CSS classes on the site footer (`#site-footer`). Site Settings > Footer. |
290
290
  | `socialClass` | string | Your own CSS classes on every social media icon link in the footer. |
291
291
  | `links` | array | Legacy single footer link list. Migrated to `config/menus/` on first boot (see [Multi-menu system](#multi-menu-system)); kept only so the migration can read it. |
292
292
 
293
- Setting `showMotionToggle: false` hides the control, not the capability. A visitor who already
294
- flipped the switch keeps their stored `domma:reduced_motion` preference, and anyone whose browser
295
- sends `prefers-reduced-motion: reduce` still gets the calmer site - they just lose the ability to
296
- override it from the footer.
293
+ ### Reduce motion
294
+
295
+ The accessibility control that lets visitors turn animations off - **Site Settings > Reduce motion**.
296
+ `motion` in `site.json`, normalised by `server/services/motionSettings.js` (whitelisted and clamped: it is
297
+ written into every page as `window.__CMS_SITE__.motion`, on both render paths) and drawn by
298
+ `public/js/motion-control.mjs`.
299
+
300
+ ```json
301
+ "motion": {
302
+ "enabled": true,
303
+ "default": "system",
304
+ "placement": "footer",
305
+ "control": "switch",
306
+ "triggerStyle": "icon",
307
+ "label": "Reduce motion",
308
+ "position": "BL",
309
+ "offsetX": 32,
310
+ "offsetY": 32,
311
+ "iconSize": 52,
312
+ "iconRadius": 26,
313
+ "class": ""
314
+ }
315
+ ```
316
+
317
+ | `motion` field | Type | Description |
318
+ |---|---|---|
319
+ | `enabled` | boolean | Show the control. **Defaults to `true`**. |
320
+ | `default` | string | Reduced motion for a visitor who has not chosen: `system` (follow their device, the default), `off` (full motion) or `on` (calm). Their own choice always wins. Feeds the `{{motionDefault}}` bootstrap in `page.html`. |
321
+ | `placement` | string | `footer` (default - the bottom bar in the columns layout), `floating` (a disc fixed to a corner of the window) or `navbar` (a button at the end of the navbar; on a page with no navbar, or on a phone when the navbar is set to show only its menu button, it floats instead). |
322
+ | `control` | string | The footer's control: `switch` (default) or `checkbox`. |
323
+ | `triggerStyle` | string | Floating and navbar: `icon` (default; the label is still the accessible name) or `icon-text`. |
324
+ | `label` | string | The words on the control - trimmed, at most 40 characters. Default `Reduce motion`. |
325
+ | `position` | string | Floating: the corner - `TL`, `TR`, `BL` (default) or `BR`. |
326
+ | `offsetX`, `offsetY` | number | Floating: px from the side and from the top/bottom, 0-500 (default 32). |
327
+ | `iconSize`, `iconRadius` | number | Floating, icon only: the disc's size (32-96 px, default 52) and corner radius (0 to half the size, default 26) - Search's limits. |
328
+ | `class` | string | Your own CSS classes on the control, wherever it sits. |
329
+
330
+ With no `motion` block (or a key missing from it) the footer keys above decide, so every existing site
331
+ keeps its footer switch with no migration. The defaults are what the footer switch always was.
332
+
333
+ The floating disc defaults to the bottom left, clear of Search (right-centre) and Back to top
334
+ (bottom right). Put it in a corner something else already holds - Search's disc, Back to top or Go to
335
+ bottom, the theme switcher's disc, the breadcrumb pill - and it stacks clear of it, further from the edge;
336
+ the other control does not move. It never animates itself.
337
+
338
+ Hiding the control (`enabled: false`) hides the control, not the capability. A visitor who already
339
+ used it keeps their stored `domma:reduced_motion` preference (the one stored preference, whatever the
340
+ placement), and anyone whose browser sends `prefers-reduced-motion: reduce` still gets the calmer
341
+ site - they just lose the ability to override it.
297
342
 
298
343
  ### SEO and indexing
299
344
 
@@ -788,7 +833,7 @@ navbar, footer, `[menu]` and floating overlays (not the admin sidebar).
788
833
 
789
834
  Items are numbered per list, so a dropdown or footer column staggers in on its own when it opens;
790
835
  items past the twentieth share its delay. Visitors who asked for less motion (the OS setting or the
791
- footer's motion switch) see the items at once.
836
+ Reduce motion control) see the items at once.
792
837
 
793
838
  ### Item highlighting
794
839
 
@@ -708,6 +708,8 @@ Wraps inline text in a `<span>` with custom styles. All attributes are optional.
708
708
  | `light-up` | flag | The text starts dim and lights up word by word as it scrolls up the window |
709
709
  | `words` | flag | The text rises into view word by word |
710
710
 
711
+ In the page editor: select words and right-click **Wrap in… › Highlight text** (or **Insert… › Content › Highlight text**); right-click any `[text]` tag for one-click Highlight, sweeping Highlight, Light up, Rise word by word and Plain.
712
+
711
713
  `[text]` tags nest, so a gradient phrase can sit inside a headline that rises word by word:
712
714
  `## [text words]Nothing goes out [text gradient="sunset" shine="sweep"]without you.[/text][/text]`.
713
715
  Motion is off for visitors who ask for reduced motion (the text shows whole and lit). Drawn by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.13.2",
3
+ "version": "1.15.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1 @@
1
+ .dm-motion-fab{position:fixed;z-index:9997;width:52px;height:52px;border-radius:50%;border:1px solid var(--dm-border, rgba(0, 0, 0, .12));background:var(--dm-surface, #fff);color:var(--dm-text, #111);box-shadow:0 4px 16px #00000024;display:flex;align-items:center;justify-content:center;gap:8px;padding:0;cursor:pointer;white-space:nowrap;font:inherit;transition:none;animation:none}.dm-motion-fab:hover,.dm-motion-fab[aria-pressed=true]{background:var(--dm-primary, #5b8cff);border-color:var(--dm-primary, #5b8cff);color:var(--dm-primary-text, #fff)}.dm-motion-fab:focus-visible,.dm-motion-nav:focus-visible{outline:2px solid var(--dm-primary, #5b8cff);outline-offset:2px}.dm-motion-fab .dm-motion-icon,.dm-motion-fab svg{width:var(--dm-motion-glyph, 20px);height:var(--dm-motion-glyph, 20px);flex-shrink:0;pointer-events:none}.dm-motion-fab--pill{width:auto;height:44px;border-radius:999px;padding:0 18px 0 14px}.dm-motion-fab-label{font-size:.875rem;font-weight:500;pointer-events:none}.dm-motion-nav{display:inline-flex;align-items:center;gap:6px;background:none;border:0;padding:6px;border-radius:var(--dm-radius, 6px);color:inherit;cursor:pointer;font:inherit;font-size:var(--dm-font-size-sm, 14px);transition:none}.dm-motion-nav:hover{background:var(--dm-hover-bg, rgba(128, 128, 128, .14))}.dm-motion-nav[aria-pressed=true]{background:color-mix(in srgb,currentColor 16%,transparent)}.dm-motion-nav svg,.dm-motion-nav .dm-motion-icon{width:18px;height:18px;flex-shrink:0;pointer-events:none}@media print{.dm-motion-fab{display:none}}
@@ -0,0 +1 @@
1
+ import{applyClasses as f}from"/public/js/css-classes.mjs";const x="(prefers-reduced-motion: reduce)";export function reducedMotionOn(t={}){const e=globalThis.S?.get?.("reduced_motion");return e!=null?!!e:t.default==="on"?!0:t.default==="off"?!1:!!(window.matchMedia&&window.matchMedia(x).matches)}function s(t){globalThis.S?.set?.("reduced_motion",t),window.location.reload()}function C(t){const e=document.createElement("span");return e.className="dm-motion-icon",e.setAttribute("data-icon",t?"play":"pause"),e.setAttribute("aria-hidden","true"),e}function v(t,e){const i=t.control==="checkbox",o=document.createElement("input");o.type="checkbox",o.className=i?"footer-motion-checkbox":"form-switch-input",o.id="dm-motion-switch",o.checked=e,o.addEventListener("change",()=>s(o.checked));const n=document.createElement("span");n.className=i?"footer-motion-label":"form-switch-label",n.textContent=t.label;const a=document.createElement("label");return a.className=i?"footer-motion-check":"form-switch footer-motion-switch",a.title=t.label,a.append(o,n),a}function p(t,e,i,o){const n=document.createElement("button");if(n.type="button",n.className=i,n.id="dm-motion-toggle",n.setAttribute("aria-pressed",String(e)),n.setAttribute("aria-label",t.label),n.title=e?`${t.label}: on`:`${t.label}: off`,n.appendChild(C(e)),t.triggerStyle==="icon-text"){const a=document.createElement("span");a.className=o,a.textContent=t.label,n.appendChild(a)}return n.addEventListener("click",()=>s(!e)),n}const h=[".site-search-floating-trigger","#back-to-top-btn","#go-to-bottom-btn",".dm-theme-switcher:not(.dm-ts-inline)","nav.dm-breadcrumbs"].join(","),r=12;function E(t){const e=t.getBoundingClientRect();if(getComputedStyle(t).opacity!=="0"||t.offsetParent!==null)return e;const i=Math.min(e.top,t.offsetTop),o=Math.min(e.left,t.offsetLeft);return{top:i,left:o,right:Math.max(e.right,t.offsetLeft+t.offsetWidth),bottom:Math.max(e.bottom,t.offsetTop+t.offsetHeight),width:e.width,height:e.height}}const S=(t,e)=>t.left<e.right+r&&e.left<t.right+r&&t.top<e.bottom+r&&e.top<t.bottom+r;function g(t,e){const i=e.position==="TL"||e.position==="BL",o=e.position==="TL"||e.position==="TR";t.style.left=i?`${e.offsetX}px`:"auto",t.style.right=i?"auto":`${e.offsetX}px`;let n=e.offsetY;const a=()=>{t.style.top=o?`${n}px`:"auto",t.style.bottom=o?"auto":`${n}px`};a();const l=[...document.querySelectorAll(h)].filter(c=>c!==t&&getComputedStyle(c).position==="fixed"&&getComputedStyle(c).display!=="none");for(let c=0;c<l.length+1;c++){const w=t.getBoundingClientRect(),d=l.map(E).find(m=>m.width&&m.height&&S(w,m));if(!d)break;n=Math.round(o?d.bottom+r:window.innerHeight-d.top+r),a()}}function b(t,e){const i=t.triggerStyle==="icon-text",o=p(t,e,`dm-motion-fab${i?" dm-motion-fab--pill":""}`,"dm-motion-fab-label");return i||(o.style.width=`${t.iconSize}px`,o.style.height=`${t.iconSize}px`,o.style.borderRadius=`${t.iconRadius}px`,o.style.setProperty("--dm-motion-glyph",`${Math.round(t.iconSize*20/52)}px`)),f(o,t.class),o}function y(t,e){const i=()=>{t.isConnected&&g(t,e)};let o=0;const n=()=>{cancelAnimationFrame(o),o=requestAnimationFrame(i)};return new MutationObserver(a=>{a.some(l=>[...l.addedNodes].some(c=>c.nodeType===1&&c.matches?.(h)))&&n()}).observe(document.body,{childList:!0}),window.addEventListener("resize",n),window.addEventListener("load",n,{once:!0}),i}const u=t=>{window.Domma?.icons?.scan&&window.Domma.icons.scan(t)};function k(t,e){const i=b(t,e);return document.body.appendChild(i),y(i,t)(),i}function L(t,e,i){let o=t.querySelector(".navbar-actions");o||(o=document.createElement("div"),o.className="navbar-actions",(t.querySelector(".navbar-container")||t).appendChild(o));const n=p(e,i,"dm-motion-nav","dm-motion-nav-label");f(n,e.class);let a=null,l=null;const c=()=>{if(getComputedStyle(o).display!=="none"){a?.remove(),n.isConnected||(o.appendChild(n),u(n));return}n.remove(),a||(a=b(e,i),l=y(a,e)),a.isConnected||(document.body.appendChild(a),u(a),l())};return c(),window.addEventListener("resize",c),n.isConnected?n:a}export function mountMotionControl(t,e={}){if(!t||t.enabled===!1||document.getElementById("dm-motion-toggle")||document.getElementById("dm-motion-switch"))return null;const i=reducedMotionOn(t);let o=null;if(t.placement==="footer"){if(!e.footer)return null;o=v(t,i),f(o,t.class),e.footer.appendChild(o)}else t.placement==="navbar"&&e.navbar?o=L(e.navbar,t,i):o=k(t,i);return u(o),o}