<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://unysonplus.github.io/decisions</id>
    <title>Unyson+ Blog</title>
    <updated>2026-07-26T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://unysonplus.github.io/decisions"/>
    <subtitle>Unyson+ Blog</subtitle>
    <icon>https://unysonplus.github.io/img/unysonplus-logo.jpg</icon>
    <entry>
        <title type="html"><![CDATA[Smooth scroll belongs to the engine, and the scroll clock is a shared loop — not a ScrollTrigger port]]></title>
        <id>https://unysonplus.github.io/decisions/engine-smooth-scroll-and-shared-loop</id>
        <link href="https://unysonplus.github.io/decisions/engine-smooth-scroll-and-shared-loop"/>
        <updated>2026-07-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Phase 0 of the motion roadmap asked two questions: where does site-wide inertia smooth-scroll live, and how do we make every scroll effect share one clock? We chose a new Animation Engine module (not an extension of the theme's anchor-link smooth-scroll), and a shared requestAnimationFrame loop for unification (not a wholesale GSAP ScrollTrigger port). This records why the lower-risk paths won.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> Phase 0 of the Animation Engine's motion roadmap is "make scrolling feel effortless
everywhere, and give every scroll-driven effect one shared clock." That raised two forks. <strong>(1)</strong> Where
should the new <em>inertia</em> smooth-scroll live — a new engine module, or an extension of the theme's
existing anchor-link "Smooth Scroll"? <strong>(2)</strong> How do we unify the scroll-reactive modules so they stay
in lockstep — port them all to GSAP's ScrollTrigger, or reuse the engine's own shared animation loop?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/engine-smooth-scroll-and-shared-loop#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The runtime today is deliberately lightweight and lazy:</p>
<ul>
<li class=""><strong>Lenis</strong> (the smooth-scroll library) is already vendored, but private to one module (Scroll Loop).</li>
<li class=""><strong>GSAP + ScrollTrigger</strong> is private to one module (Scroll Motion) and loads only on pages that use it.</li>
<li class="">The <strong>sticky scroll modules</strong> (Scrollytelling, Sticky Stack, Horizontal Scroll, Scroll Color Shift,
Image Sequence) are library-free — each reads <code>getBoundingClientRect()</code> in its own small
<code>requestAnimationFrame</code> loop.</li>
<li class="">A shared scheduler already exists — <strong><code>window.upwAnimRaf</code></strong> — one rAF loop that many modules subscribe
to and that <em>pauses itself while the tab is hidden</em>.</li>
<li class="">The <strong>theme</strong> already ships a CSS "Smooth Scroll for Anchor Links" toggle (<code>scroll-behavior: smooth</code>),
which is a different thing from inertia wheel-smoothing.</li>
</ul>
<p>There is no shared GSAP loader, no shared scroll-progress helper, and no shared reduced-motion helper —
each is either reimplemented per module or owned privately by one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/engine-smooth-scroll-and-shared-loop#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<p><strong>Where smooth-scroll lives:</strong></p>
<ul>
<li class=""><strong>Extend the theme's existing "Scrolling" group.</strong> Keeps all scroll UX in one place — but it lives in
the <em>theme</em>, off-pattern for an engine feature, and it conflates CSS anchor-smoothness with real
inertia scrolling under one label.</li>
<li class=""><strong>A new Animation Engine module (chosen).</strong> Mirrors the four existing site-wide effects (Cursor, Page
Transitions, Preloader, Scroll Progress): its own Theme Settings sub-tab, ships assets <em>only when
enabled</em>, and keeps motion where motion belongs. Labeled <strong>"Smooth (inertia) scrolling"</strong> so it reads
as clearly distinct from the theme's anchor-link toggle.</li>
</ul>
<p><strong>How the scroll clock is unified:</strong></p>
<ul>
<li class=""><strong>Port every sticky module to ScrollTrigger.</strong> Industry-standard and powerful — but it's a <em>large</em>,
behaviour-parity-risky rewrite of the flagship Scrollytelling (pinned stage, backdrop scrub, persist
ranges, published progress), and it would force GSAP to load on every page that uses <em>any</em> of these
effects. That fights the engine's "ship only what a page uses" principle.</li>
<li class=""><strong>Reuse the shared <code>upwAnimRaf</code> loop (chosen).</strong> Extract two shared helpers (<code>upwScrollProgress</code> and
<code>upwReduceMotion</code>) and move each sticky module's per-frame reader onto the one ordered loop that
already pauses off-screen. Same felt result — one smooth, synchronized scroll — with no wholesale
rewrite and no forced GSAP.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/engine-smooth-scroll-and-shared-loop#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>A new engine module</strong> (<code>smooth-scroll</code>) providing inertia smooth-scroll site-wide, and a <strong>shared-loop
unification</strong>: sticky modules ride <code>upwAnimRaf</code> and read from shared progress/reduced-motion helpers,
rather than being ported to ScrollTrigger.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/engine-smooth-scroll-and-shared-loop#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Risk.</strong> A faithful ScrollTrigger port of Scrollytelling is a project on its own; the shared-loop path
moves the <em>tick</em>, not the <em>logic</em>, so behaviour can't drift.</li>
<li class=""><strong>Page weight.</strong> The shared loop keeps GSAP where it already earns its place (Scroll Motion). Lenis
still bridges to ScrollTrigger <em>when it's present</em> (<code>gsap.ticker</code> drives Lenis; <code>lenis.on('scroll', ScrollTrigger.update)</code>), so scroll-motion pages stay in lockstep — but a page with only a smooth
background and a sticky stack never pays for GSAP.</li>
<li class=""><strong>Reuse over rebuild.</strong> <code>upwAnimRaf</code> already gives ordered updates and tab-hidden pausing for free;
we add the two helpers the engine was missing and adopt them everywhere, which also closes existing
gaps (e.g. Scroll Color Shift had no JS reduced-motion guard).</li>
<li class=""><strong>On-pattern.</strong> Smooth-scroll as a site-wide engine module is copy-paste consistent with Cursor / Page
Transitions / Scroll Progress — same options shape, same enqueue gating, same config bridge — so it's
predictable to build and to maintain.</li>
</ul>
<p><em>Status: Accepted — Phase 0 of the motion roadmap. Increment 1 (the smooth-scroll module) is built
first; the sticky modules move onto the shared loop one at a time after.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Animation" term="Animation"/>
        <category label="Architecture" term="Architecture"/>
        <category label="Performance" term="Performance"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The GSAP snippet generator mirrors the Custom Code contract — it is not a full GSAP playground]]></title>
        <id>https://unysonplus.github.io/decisions/gsap-generator-matches-the-contract</id>
        <link href="https://unysonplus.github.io/decisions/gsap-generator-matches-the-contract"/>
        <updated>2026-07-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Custom Code (Motion Snippet) field is powerful but has a blank-page problem for GSAP newcomers. We added a code generator to the docs — but scoped it to exactly what the runtime provides (el, a pre-triggered tl, gsap; core only; fixed trigger) rather than exposing GSAP's full surface. This records why matching the contract beat building a general playground.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> the Scroll Motion <strong>Custom Code (Motion Snippet)</strong> field lets an author hand-write
GSAP, but a newcomer faces a blank editor with no idea of the API surface. A code generator would
help — but <em>how much</em> of GSAP should it expose: everything (a general GSAP playground), or only what
the field actually gives you?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/gsap-generator-matches-the-contract#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The field runs the snippet with three things already in scope — <code>el</code> (the element), <code>tl</code> (a
<code>gsap.timeline()</code> <strong>already tied to a ScrollTrigger</strong> on <code>el</code>, start <code>top 80%</code>), and <code>gsap</code>. Two
constraints follow from the runtime that a general GSAP tool would ignore:</p>
<ul>
<li class=""><strong>The timeline and its trigger are provided.</strong> You don't write <code>gsap.timeline()</code> or <code>ScrollTrigger</code>
— you add tweens to <code>tl</code>. The trigger start is fixed in the runtime (<code>runSnippets</code>).</li>
<li class=""><strong>Only GSAP core + ScrollTrigger are guaranteed.</strong> SplitText/MorphSVG and other plugins load only
when their <em>preset</em> effect is used elsewhere on the page, so a snippet can't rely on them.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/gsap-generator-matches-the-contract#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>A general GSAP playground.</strong> Expose timelines, ScrollTrigger config, all plugins — teach GSAP
broadly. But most of it produces code that <strong>won't paste</strong> into the field (the field already owns
the timeline/trigger, and the plugins may be absent), so it would mislead exactly the beginners
it's meant to help.</li>
<li class=""><strong>Mirror the field's contract (chosen).</strong> Generate only <code>tl.from/to(el | el.querySelectorAll(...), {…})</code> chains — the shape the field runs — using core tweens, eases, stagger and the position
parameter. Preview by executing the generated code the <strong>same way the plugin does</strong>
(<code>new Function('el','tl','gsap', code)</code> on a fresh timeline), so what you see is what you paste.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/gsap-generator-matches-the-contract#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>The generator emits snippets against the exact contract: <code>el</code> / a provided <code>tl</code> / <code>gsap</code>, <strong>core-only</strong>,
<strong>trigger fixed</strong>. Everything it produces pastes and runs unchanged. Exposing the trigger start / scrub
is explicitly deferred — it would require extending the runtime (<code>runSnippets</code> reading those off the
element) in lockstep, so it's a "change both sides together" follow-up, not a v1 knob.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/gsap-generator-matches-the-contract#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Correctness over breadth.</strong> A generator whose output sometimes doesn't work is worse than none —
it burns the trust of the beginner it targets. Matching the contract guarantees every snippet pastes.</li>
<li class=""><strong>It teaches the right mental model.</strong> The visible code reinforces "you're handed a scroll-tied
timeline; add tweens to it" — which is the one idea that unlocks the field.</li>
<li class=""><strong>Single source of truth.</strong> The preview runs the generated string through the plugin's own execution
shape, so the tool can't drift from the runtime — if it previews, it ships.</li>
<li class=""><strong>Room to grow honestly.</strong> When we want trigger/scrub controls, we extend the runtime and the
generator together, keeping the "what you see is what you paste" guarantee intact.</li>
</ul>
<p><em>Status: Accepted. Lives at <code>/animation-engine/gsap-generator</code>.</em></p>
<p><em>Update (same day): the deferred trigger controls were added <strong>on both sides together</strong> — the exact
"change the runtime and the generator in lockstep" follow-up this decision anticipated. The Custom
Code field gained <strong>Trigger start</strong> + <strong>Scrub</strong> options (the runtime <code>runSnippets</code> reads them off the
element); the generator mirrors them as a preview mode (play vs. a scrub slider) and a "set these in
the builder" hint. The guarantee still holds: the generated <strong>snippet</strong> is only the tween chain —
trigger/scrub are field options, not code — so what you paste always works.</em></p>
<p><em>Update 2 (same day): v1 mirrored the built-in presets, which made it redundant with the module —
there's no reason to hand-write an effect that's already a tile. So it grew into a <strong>GSAP Timeline
Studio</strong> that does what the options can't: multi-target choreography on one timeline, a full
per-property palette (2D + real 3D transforms, filters, clip-path, colour), keyframe steps, loop/yoyo,
and advanced stagger. The core decision is unchanged and is exactly what keeps this safe — still
<strong>contract-faithful and core-only</strong> (the provided <code>tl</code>, GSAP core, no plugins), so every composed
snippet still pastes and runs. "Fuller tool" ≠ "full GSAP playground": it went deeper WITHIN the
contract, not outside it.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Animation" term="Animation"/>
        <category label="JavaScript" term="JavaScript"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[A dozen uploads/unysonplus-* folders, or one uploads/unysonplus/ parent?]]></title>
        <id>https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent</id>
        <link href="https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent"/>
        <updated>2026-07-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The plugin and parent theme had accumulated a scatter of sibling upload folders in the WP uploads root — unysonplus-icon-packs, unysonplus-asset-optimizer, unysonplus-lottie, unysonplus-rive, unysonplus-templates, unysonplus-designs, unysonplus-shortcodes, unysonplus-presets — plus a plain unysonplus/ for generated CSS. They were consolidated under a single uploads/unysonplus/<subdir>/ parent, routed through one shared helper (fw_upw_uploads_dir), with a guarded one-time migration that renames the legacy folders and a read-time URL normaliser for the only DB-stored URLs (Lottie/Rive src). The move was low-risk because almost every folder's URLs are recomputed per request.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> UnysonPlus had grown a scatter of sibling upload folders in the WordPress uploads
root — <code>unysonplus-icon-packs</code>, <code>unysonplus-asset-optimizer</code>, <code>unysonplus-lottie</code>, <code>unysonplus-rive</code>,
<code>unysonplus-templates</code>, <code>unysonplus-designs</code>, <code>unysonplus-shortcodes</code>, <code>unysonplus-presets</code>, plus a
plain <code>unysonplus/</code> for generated CSS. Should each keep its own top-level folder, or move under a
single <code>uploads/unysonplus/</code> parent?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Every subsystem that needed to write to uploads had hand-rolled its own
<code>wp_upload_dir()['basedir'] . 'unysonplus-&lt;x&gt;'</code> path. The result: nine-plus UnysonPlus folders
littering the uploads root next to the user's own year/month media folders, with no obvious grouping
and nothing to enforce a convention on the next feature that needed a folder (the Lottie and Rive
work had just added two more).</p>
<p>The blocking worry with any move is <strong>stored URLs</strong>: if a folder's file URLs are persisted in the
database (post meta / options), renaming the folder 404s them. So before deciding, we mapped every
folder: which are <strong>computed</strong> (URL rebuilt from the dir helper each request — safe to move) versus
<strong>stored</strong> (URL persisted — needs migration). The finding was decisive: <strong>almost everything is
computed</strong> — regenerable caches (asset-optimizer, generated CSS) or re-downloadable content
(icon-packs, templates, designs, presets) whose URLs are recomputed per request. The <strong>only</strong>
DB-stored URLs are the Lottie/Rive <code>src</code> values, and those are brand-new/unreleased with essentially
no real data. That turned a scary-looking move into a low-risk one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Leave the sibling folders as-is.</strong> Zero risk, zero work — but the uploads root stays messy and
every future feature invents its own top-level folder, compounding the sprawl.</li>
<li class=""><strong>Consolidate under one <code>uploads/unysonplus/&lt;subdir&gt;/</code> parent, via a shared helper</strong> — chosen. Tidy
root, one obvious home, and a single chokepoint (<code>fw_upw_uploads_dir()</code>) that makes the next
folder automatic and impossible to get wrong.</li>
<li class=""><strong>Consolidate but keep each subsystem building its own path</strong> (just change the strings). Rejected —
it fixes today's mess without preventing tomorrow's; the value is the <em>enforced</em> convention, not the
one-time rename.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>All plugin- and theme-written uploads move under <strong><code>wp-content/uploads/unysonplus/&lt;subdir&gt;/</code></strong>
(<code>icon-packs</code>, <code>lottie</code>, <code>rive</code>, <code>templates</code>, <code>designs</code>, <code>shortcodes</code>, <code>asset-optimizer</code>, <code>css</code>,
<code>presets</code>). A single shared helper <strong><code>fw_upw_uploads_dir( $subdir )</code></strong> builds every path; each dir
helper (plugin and theme) routes through it, and new code MUST use it rather than hand-rolling a
path. A one-time, flag-guarded migration (<code>fw_upw_migrate_uploads()</code>, on <code>admin_init</code>) renames the
legacy siblings and the loose generated CSS into the new layout. The lone DB-stored URLs
(Lottie/Rive <code>src</code>) are rewritten on read by <code>fw_upw_normalize_legacy_upload_url()</code>, so values saved
before the move still resolve.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>A single parent is simply how a well-behaved plugin should treat the shared uploads dir — easy to
find, back up, or delete as a unit, and it stops the root from filling with our folders. But the real
win is the <strong>shared helper as a chokepoint</strong>: the sprawl happened because there was no single place
that owned "where do we put uploads," so every feature answered it independently. Centralising that
answer means the convention enforces itself — the next folder is one <code>fw_upw_uploads_dir('thing')</code>
call, already in the right place. The migration + read-time normaliser make the move invisible to
existing sites, which is what made it safe to do now rather than deferring it forever.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://unysonplus.github.io/decisions/uploads-under-one-unysonplus-parent#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="Back-compat" term="Back-compat"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Where do the Animated Icons toggles live — a standalone extension Settings page, or Theme Settings → Icons?]]></title>
        <id>https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons</id>
        <link href="https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons"/>
        <updated>2026-07-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The opt-in Animated Icons extension gained per-technology toggles (Lottie, Rive, Animated SVG, Animated raster). They first lived on the extension's own Settings page (Extensions → Animated Icons → Settings). But all other icon configuration — which webfont packs and SVG libraries are on — already lives in Theme Settings → Icons, which is a box with Library / Browse / Upload sub-tabs. So the toggles moved there as a fourth 'Animated' sub-tab: co-located with the packs they belong beside, discoverable where people configure icons, and consistent with how the Animation Engine contributes its section to Theme Settings. The extension injects the tab via a filter only while active; storage moved from extension-scoped to theme-scoped (same scope as the pack toggles).]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> the Animated Icons extension has per-technology toggles (Lottie, Rive, Animated
SVG, Animated raster). Should they live on the extension's <strong>own</strong> Settings page, or move into
<strong>Theme Settings → Icons</strong> — perhaps as a new tab?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Animated icons are an opt-in extension: activating it reveals an "Animated" tab in the icon picker
and lets you choose which animation technologies are enabled. Those choices started on the
extension's own settings page (<code>settings-options.php</code> → Extensions → Animated Icons → Settings).</p>
<p>Meanwhile, <strong>all other icon configuration already lives in Theme Settings → Icons</strong> — an <code>Icon Settings</code> box whose sub-tabs are <strong>Library / Browse / Upload</strong> (which webfont packs and SVG
libraries are enabled, catalog installs, custom uploads). So a user configuring icons had to look in
two places: packs in Theme Settings → Icons, animation technologies buried under Extensions.</p>
<p>Two codebase precedents pointed the way. The <strong>Animation Engine</strong> extension does not ship a settings
page at all — it contributes a section to Theme Settings via the <code>fw_settings_options</code> filter while
active. And the Icons page is already a tabbed box, so a fourth tab slots in cleanly.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Keep the extension's own Settings page.</strong> Self-contained and extension-scoped (persists across
theme switches). But it splits icon configuration across two screens and hides the toggles where
few people look — the opposite of discoverable.</li>
<li class=""><strong>Fold the toggles into Theme Settings → Icons as an "Animated" sub-tab</strong> — chosen. Co-locates
every icon setting on one screen, puts the toggles exactly where someone configuring icons is
already looking, and matches the Animation-Engine-style "extension contributes to Theme Settings"
convention. The tab appears only while the extension is active.</li>
<li class=""><strong>A separate top-level Theme Settings tab (e.g. "Animated Icons").</strong> More discoverable than the
extension page, but it divorces the toggles from the packs they belong with; a sub-tab of Icons is
tighter.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Move the toggles into <strong>Theme Settings → Icons</strong> as a fourth <strong>"Animated"</strong> sub-tab (beside Library /
Browse / Upload) and drop the extension's standalone <code>settings-options.php</code>. The Icons page exposes a
filter (<code>unysonplus_icons_animated_settings</code>) that it fills into the box; the <strong>extension hooks that
filter only while active</strong>, so with the extension off the Icons page shows just Library / Browse /
Upload. The four gate filters (<code>fw_icon_lottie_enabled</code> / <code>_rive</code> / <code>_svg_animation</code> / <code>_raster</code>) now
read their values from <strong>theme settings</strong> (<code>fw_get_db_settings_option</code>) instead of extension
settings. No data migration was needed — the feature is unreleased.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>It removes the "two places to configure icons" split: packs and animation technologies now sit on one
screen, in the order you'd reason about them. It follows the framework's own pattern (an animation
extension augmenting Theme Settings rather than shipping a bespoke page), so it reads as native rather
than bolted-on. And the injection-via-filter keeps the coupling clean — the Icons page owns the tab
<em>slot</em>, the extension owns the <em>contents</em>, and nothing appears when the extension is inactive.</p>
<p>The one deliberate trade-off: settings are now <strong>theme-scoped</strong> (a theme switch resets them) rather
than extension-scoped. That is a feature, not a regression here — the pack toggles the animated
toggles now sit beside are <em>also</em> theme-scoped, so the whole Icons screen behaves as one consistent
unit.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://unysonplus.github.io/decisions/animated-icons-settings-in-theme-settings-icons#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="Option types" term="Option types"/>
        <category label="Animation" term="Animation"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why cross-column drag-and-drop was a small opt-in, not a new post type]]></title>
        <id>https://unysonplus.github.io/decisions/cross-column-drag-drop-connect-group</id>
        <link href="https://unysonplus.github.io/decisions/cross-column-drag-drop-connect-group"/>
        <updated>2026-07-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The header/footer column builders let you sort elements within a column but not drag them between columns. The question was whether that needs a data-model overhaul or a new post type. It needed neither — jQuery-UI already has connectWith — but the prototype surfaced a real trap: the settings form saves by input name, so a moved item must be re-keyed on receive or it silently reverts.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> Can the header/footer columns' addable-popups drag-and-drop between each other (not
just reorder within one column)? Is that a big refactor of the option type, or do we need a new post
type for it?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/cross-column-drag-drop-connect-group#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Each column is a separate <code>addable-popup</code> option, and each uses jQuery-UI <code>sortable()</code> with <code>axis:'y'</code> —
one vertical list per column, so drags are column-locked. The data already lives as an array of
per-item JSON blobs inside each column's option value.</p>
<p>Two facts made the answer easy:</p>
<ul>
<li class=""><strong>jQuery-UI <code>sortable</code> has <code>connectWith</code> built in</strong> — dragging between lists is a native feature of
the library already in use. No new drag engine.</li>
<li class=""><strong>A new post type would be pure over-engineering.</strong> The item data is already self-contained JSON in
the option value; a post type would add a whole storage/query layer for zero benefit.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/cross-column-drag-drop-connect-group#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>





















<table><thead><tr><th>Option</th><th>Verdict</th></tr></thead><tbody><tr><td><strong>New post type for elements</strong></td><td>Rejected — massive over-engineering; the data model already holds everything.</td></tr><tr><td><strong>Rewrite the addable-popup</strong></td><td>Unnecessary — the change is additive, not structural.</td></tr><tr><td><strong>Opt-in <code>connect_group</code> config</strong></td><td>Chosen — a small, scoped, backward-compatible flag that wires <code>connectWith</code> between same-group lists only.</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/cross-column-drag-drop-connect-group#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Add an opt-in <strong><code>connect_group</code></strong> to the <code>addable-popup</code> option type. Two or more addable-popups that
share the same non-empty group id interlink (<code>connectWith</code>); empty (default) stays self-contained. The
id is scoped per logical group — one footer row's columns, one header bar's Left/Center/Right — so
unrelated addable-popups on the same settings page never interlink. Empty connected columns stay
visible as drop targets. The header/footer column helpers opt in by passing the bar/row id.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why--and-the-trap-the-prototype-caught">Why — and the trap the prototype caught<a href="https://unysonplus.github.io/decisions/cross-column-drag-drop-connect-group#why--and-the-trap-the-prototype-caught" class="hash-link" aria-label="Direct link to Why — and the trap the prototype caught" title="Direct link to Why — and the trap the prototype caught" translate="no">​</a></h2>
<p>The reassuring part (self-contained item data) hid a real trap that only a <strong>test on a real install</strong>
exposed. My first read was "the value is collected by DOM position, so a moved item just works." Wrong:
the settings form saves via native <strong><code>$el.serialize()</code></strong>, and each item's input name encodes its
column (<code>fw_options[…][col_1][]</code>). A dragged item kept its <strong>origin</strong> column's name, so it moved
visually but <strong>reverted on save</strong>. The fix is to <strong>re-key the moved item's input name on jQuery-UI
<code>receive</code></strong> to the receiving column (the name template captured from that column's default item, so it
works even for an empty column).</p>
<p>The lesson: "it drags and drops" in the DOM is not the same as "it saves." When a framework serializes
by input name, moving a node between lists isn't enough — the name has to move too. Prototyping against
the actual save path, rather than reasoning about it, is what turned a plausible-but-wrong assumption
into a shipped, verified feature (drag → save → reload round-trip confirmed in the database).</p>
<p>Status: <strong>Accepted.</strong> Live on all footer rows and header bars; the <code>connect_group</code> flag is reusable by
any addable-popup that wants cross-list DnD.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Option types" term="Option types"/>
        <category label="Header" term="Header"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA["Overlay on last section" as a Footer Layout toggle — positioning only, JS-gated]]></title>
        <id>https://unysonplus.github.io/decisions/footer-overlay-on-last-section-option</id>
        <link href="https://unysonplus.github.io/decisions/footer-overlay-on-last-section-option"/>
        <updated>2026-07-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Cinematic sites put the footer ON the last full-height scene instead of a separate band below it — which also removes the blank tail a pinned last section leaves behind. We graduated that from a one-off demo hack into a reusable Footer Layout toggle, 'Overlay on last section'. This records the three design calls that made it safe and generic: the option changes positioning only (the footer's own background/colours stay user-controlled), a tiny runtime measures the footer and only engages when the last section is tall enough to sit on, and the footer background defaults to transparent in overlay mode.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> on a cinematic reference site the footer isn't a band below the page — its closing
tagline sits <em>on the bottom of the last full-height scene</em>, overlaid. That also removes the blank gap a
pinned last section leaves after it fades out. Should this be a one-off per-site hack, or a first-class
Footer option — and if an option, how do we make it generic and safe?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/footer-overlay-on-last-section-option#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>We first did this by hand on a demo (negative margin + a dark gradient on the theme footer). It
looked right, but it hard-coded a footer height, a scrim colour, and assumed the last section was a
tall pinned scene. As a reusable option it needs to work on <em>any</em> site without those assumptions.</p>
<p>The footer already has a <strong>Footer Layout</strong> settings tab (background, colours, border, spacing). The
front end renders <code>#colophon.footer</code> and reads settings via <code>fw_get_db_settings_option()</code>. So the
natural home is a new toggle there that adds a class the CSS/JS act on.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/footer-overlay-on-last-section-option#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Leave it per-site</strong> (hand-CSS each cinematic site). No new surface, but every such site
re-derives the same fragile margin/scrim math.</li>
<li class=""><strong>A full "footer mode" system</strong> (band / overlay / sticky / …). Over-built for one real need today.</li>
<li class=""><strong>A single <code>switch</code> toggle — "Overlay on last section"</strong> (chosen). One option, one class, minimal
runtime.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/footer-overlay-on-last-section-option#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Add <strong>Footer → Layout → "Overlay on last section"</strong> (<code>footer_overlay_last_section</code>, a <code>yes</code>/<code>no</code>
switch, default <code>no</code>). When on, <code>footer.php</code> adds <code>footer--overlay</code> to <code>#colophon</code>. A tiny inline
runtime measures the footer height into <code>--footer-overlay-h</code> and adds <code>footer--overlay-on</code> — which
pulls the footer up by that height and raises its <code>z-index</code> — <strong>only when the page's last section is
≥ 70vh tall</strong>. Three deliberate constraints:</p>
<ol>
<li class=""><strong>Positioning only.</strong> The toggle moves the footer; it does not restyle it. The footer's own
<code>footer_background</code> and text/link colours stay in the user's hands, so the option composes with
the existing Footer settings instead of fighting them.</li>
<li class=""><strong>JS-gated to a tall last section.</strong> Overlaying a short last section would look cramped, so the
runtime engages only when the last section is tall enough to sit on; otherwise the footer stays a
normal band. Graceful, content-aware fallback with no author configuration.</li>
<li class=""><strong>Transparent background by default in overlay mode.</strong> <code>footer--overlay-on</code> resolves its
background to <code>var(--footer-bg-color, transparent)</code> — so with no explicit footer background the
final scene shows through (the intended look), while a user-set scrim still wins.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/footer-overlay-on-last-section-option#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>It's a real, recurring pattern</strong> (any cinematic / scroll-story page ends better on its last
scene than on a blank band), so it earns a first-class option rather than living as per-site CSS.</li>
<li class=""><strong>Positioning-only keeps it predictable.</strong> The #1 way a "layout" toggle goes wrong is silently
overriding the user's colours/background; scoping the option to <em>where the footer sits</em> avoids that
entirely and lets it stack with everything already in Footer settings.</li>
<li class=""><strong>The height + tallness are runtime facts, not author inputs.</strong> Measuring the footer and checking
the last section's height means the author flips one switch and it does the right thing (or safely
nothing) regardless of their footer's size or their last section — no magic numbers to tune.</li>
<li class=""><strong>Demo stays independent.</strong> The cinematic demo that inspired this keeps its own custom footer and
overlay CSS; the new option serves sites built on the standard footer builder. Two surfaces, no
coupling.</li>
</ul>
<p>The general lesson: when promoting a demo hack to a framework option, strip it to the one thing it
must own (here, <em>position</em>), and let content-measured runtime facts replace the hard-coded constants
the hack relied on — so the option is one switch that can't fight the user's other settings.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Footer" term="Footer"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Animated (Lottie) icons: ship JSON only — .lottie is a delivery optimization, not a render one]]></title>
        <id>https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie</id>
        <link href="https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie"/>
        <updated>2026-07-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The new Animated icon kind plays Lottie animations via the bundled lottie-web (SVG-light) player, which loads raw Lottie JSON. Should it also accept .lottie (dotLottie) files? A .lottie is just a ZIP of the same JSON, so its only benefit is smaller download — and once decoded, a .json and a .lottie render identically for the same CPU cost. Because the icon picker uses one animation per field (not a bundle of many), the download win is marginal and the render cost — the real bottleneck when many animations play at once — is untouched by format. So the value type stays JSON-only; .lottie support is a future convenience (unzip → extract JSON on upload), not a performance feature.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> the Animated icon kind plays Lottie animations from a URL/upload as raw Lottie
<strong>JSON</strong>. Should it also accept <strong><code>.lottie</code></strong> (dotLottie) files, and would that help pages that show
many animated icons at once?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The Animated kind (part of the unified icon engine — see the icon-v3 promotion) plays animations
with the <strong>bundled <code>lottie-web</code> SVG-light build</strong>: <code>window.lottie.loadAnimation({ path: src })</code>,
which fetches a URL and <code>JSON.parse</code>s it. The upload endpoint validates the body is a real
Bodymovin export (<code>"v"</code> + <code>"layers"</code> keys). So today the pipeline is JSON end-to-end — a <code>.lottie</code>
would be rejected on upload and would fail to render from a URL, because a <code>.lottie</code> is <strong>not JSON</strong>:
it is a ZIP archive (a <code>manifest.json</code> + one or more animation JSONs + optional bundled image
assets) that happens to carry a <code>.lottie</code> extension. Plain lottie-web can't open it.</p>
<p>The tempting assumption is that <code>.lottie</code> is "better for performance," especially with many icons on
a page. It isn't — not in the way that matters. <strong>A <code>.lottie</code> is a zipped <code>.json</code>.</strong> Same animation
inside; once the browser unzips and parses it, a <code>.json</code> and a <code>.lottie</code> become the <em>identical</em>
in-memory animation and render with the <em>identical</em> cost. The format only ever affects <strong>delivery</strong>
(bytes on the wire, number of requests), never <strong>render</strong> (CPU/GPU while the frames play).</p>
<p>That split is the whole decision:</p>
<ul>
<li class=""><strong>Download cost</strong> — format-sensitive. A <code>.lottie</code> is smaller on disk (~5–10 KB vs a ~20–40 KB raw
<code>.json</code>). But web servers gzip/brotli text on the wire, and Lottie JSON compresses beautifully, so
a single small <code>.json</code> already arrives at ~5–8 KB — the real-world gap shrinks to almost nothing.</li>
<li class=""><strong>Render cost</strong> — format-<strong>insensitive</strong>. Twenty animations playing at once cost 20× the CPU
whether they were delivered as <code>.json</code> or <code>.lottie</code>. The thing that actually janks a page with many
animated icons is the SVG renderer doing 20× the work per frame, and no file format changes that.</li>
</ul>
<p>The one genuine <code>.lottie</code> advantage — bundling <strong>many</strong> animations (with shared/deduped assets) into
<strong>one</strong> file, i.e. one request instead of twenty — does not apply here: the icon picker stores <strong>one
animation per field</strong>, so there is nothing to bundle.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Keep JSON-only</strong> — chosen. Matches the lean lottie-web runtime already bundled, keeps the whole
store/preview/render pipeline unchanged, and loses nothing that matters for one-icon-per-field
usage.</li>
<li class=""><strong>Swap in the dotLottie player</strong> (<code>@lottiefiles/dotlottie-web</code>, WASM) so <code>.lottie</code> loads natively —
rejected for now. It adds a ~200 KB+ WASM runtime and a different API across three files (picker
preview, <code>syncLottie</code>, the frontend hydrator), for a format whose only edge is download size. That
fights the plugin's lean-DOM ethos.</li>
<li class=""><strong>Accept <code>.lottie</code> uploads and unzip → extract the inner JSON</strong> (PHP <code>ZipArchive</code>, keep lottie-web)
— deferred as a <em>convenience</em>, not a perf feature. It lets users drop in a file they downloaded
from LottieFiles without converting it first. Caveat: a <code>.lottie</code> that bundles <strong>raster images</strong>
would need those extracted and path-rewritten (or inlined) or they render blank — rare for
vector icon animations, but worth a graceful warning.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>The Animated icon value type stays <strong>Lottie JSON only</strong>. <code>.lottie</code> is <strong>not</strong> adopted for
performance, because it offers none over gzipped JSON at one-animation-per-field scale. If we add
<code>.lottie</code> later it will be purely a <strong>convenience</strong> (unzip → extract JSON on upload, keeping the
existing lottie-web player), not a rendering or bundling win.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The benefit people expect from <code>.lottie</code> — "lighter pages with many animations" — is a
misattribution: format governs download, not render, and the download gap is already erased by
server compression. The real cost of many animated icons is render work, which is identical across
formats and is better addressed by <em>behavior</em> (pause off-screen animations, keep them simple, or
fall back to a static image until hover) than by <em>packaging</em>. So there is no performance reason to
take on a heavier player or an unzip step now. The door stays open to accept <code>.lottie</code> uploads for
user convenience — but framed honestly as convenience.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://unysonplus.github.io/decisions/lottie-icons-json-vs-dotlottie#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted. Revisit only if the icon engine ever ships <strong>bundles</strong> of many animations per element (the
one case where dotLottie's single-file packaging would genuinely cut requests), or if users
repeatedly hit the "I downloaded a <code>.lottie</code> and it won't upload" wall — at which point the
unzip-to-JSON convenience path is the minimal answer.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Animation" term="Animation"/>
        <category label="Performance" term="Performance"/>
        <category label="Option types" term="Option types"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Per-batch scatter with synced text: one Stage of scenes, not five separate galleries]]></title>
        <id>https://unysonplus.github.io/decisions/per-batch-scatter-scenes-not-separate-shortcodes</id>
        <link href="https://unysonplus.github.io/decisions/per-batch-scatter-scenes-not-separate-shortcodes"/>
        <updated>2026-07-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A reference site's scroll section is a run of 'batches' — each a set of photos that scatters in and out with its own centered heading. Reproducing that raised a fork: use one cycling Photo Scatter gallery, five separate gallery shortcodes (one per batch), or make each batch a scene in one scrollytelling Stage. We chose scenes: each batch is one editable column (a centered heading + its OWN scatter), and the gallery reads its scene's scroll slice so text and images enter and leave together. This records why the single-gallery and separate-shortcode options lost, and the one framework change that made scenes work.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> a reference site's scroll section plays as a sequence of <em>batches</em> — each batch is a
set of photos that scatters onto the surface, holds around a centered line of text, then scatters away
as the next batch (with different photos and a different line) arrives. How do we build that so each
batch has its <strong>own images, its own text, and its own entrance/exit</strong> — and stays editable in the
builder?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/per-batch-scatter-scenes-not-separate-shortcodes#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>We already had a single Photo Scatter gallery with a <code>scroll_cycle</code> mode that cycles image <em>batches</em>
automatically (<code>batches = images ÷ cards-per-set</code>). That's great when you just want images cycling.
But the reference site pairs <strong>text with each batch</strong> — a centered heading that's synced to that batch's scatter.
A single gallery has one set of options and no per-batch text, so it can't express "batch 2 has
these images, this heading, and enters from the top." The user's instinct was to reach for one
gallery shortcode per batch.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/per-batch-scatter-scenes-not-separate-shortcodes#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>One cycling gallery (<code>scroll_cycle</code>).</strong> Simplest, one element — but it has a single <code>from</code>/<code>exit</code>
and no place to attach per-batch text. Can't do "different heading + different images per batch."</li>
<li class=""><strong>Five separate Photo Scatter shortcodes,</strong> one per batch. Each naturally has its own images and
enter/exit. But free-floating galleries all read the <em>same</em> scroll position, so they'd animate
<strong>simultaneously</strong>, not in sequence; making them play in order means hand-gating each to a scroll
range, and you manage five modals that can drift.</li>
<li class=""><strong>Each batch = a scene in one scrollytelling Stage (chosen).</strong> One pinned Stage; every beat scene
is a column holding a centered heading <strong>and</strong> its own Photo Scatter of that batch's images. The
Stage already sequences scenes in scroll order, so batch 2 waits its turn automatically, and text
↔ image sync is free because they share one scene on one timeline.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/per-batch-scatter-scenes-not-separate-shortcodes#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Build per-batch sections as <strong>scenes in a single Stage</strong>. Each batch is one editable column: a
centered heading + a <code>photo_scatter</code> (its own images, <code>center_clear:'yes'</code> so the photos ring the
title, a single set so all its images scatter together, its own <code>from</code>/<code>exit</code>). Different images,
text, and direction per batch — each batch fully replaceable in the builder.</p>
<p>The one framework change that made it work: a photo_scatter nested in a Stage now reads <strong>its own
scene's scroll slice</strong>. The Stage publishes each beat scene's index (<code>__beatIndex</code>); the gallery
remaps the story's global progress to <code>[beatIndex, beatIndex]</code> (the same mechanism a ranged persist
layer already used for <code>[from,to]</code>). So the scatter flies in, holds, and sweeps out exactly as its
scene enters and leaves. A new <strong>"Keep center clear"</strong> option rings the cards around the middle so
the heading stays readable.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/per-batch-scatter-scenes-not-separate-shortcodes#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>The Stage is the sequencer.</strong> The reason five separate galleries fail — no coordination — is
exactly what a scrollytelling Stage provides for free. Scenes play one at a time, in order.</li>
<li class=""><strong>Sync comes from sharing a scene.</strong> Text and images on the same scene share one scroll timeline,
so they enter and leave together with no manual alignment. Scene-local remapping is what ties the
scatter's motion to that.</li>
<li class=""><strong>Editability is preserved and even improved.</strong> Each batch stays one column (a title + a gallery)
— the "every element user-replaceable" rule holds. And per-batch images are <em>cleaner</em> here than in
the single cycling gallery, where all images share one pool with no way to say "these belong to
batch 2."</li>
<li class=""><strong>The single <code>scroll_cycle</code> gallery still has its place</strong> — when you want images cycling with <em>no</em>
per-batch text, it's the lighter choice. Two tools, two jobs: cycle-in-one-gallery for pure image
batches, scene-per-batch when each batch carries its own copy.</li>
</ul>
<p>The general lesson: when repeated content needs to play <em>in sequence and stay in sync with text</em>,
reach for the scrollytelling Stage's scene model and let child elements read their scene's slice —
not a pile of independent elements racing the same scrollbar.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Animation" term="Animation"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Animated hand-drawn accents: two-state APNG (in → idle) with a cache-bust restart, not SVG]]></title>
        <id>https://unysonplus.github.io/decisions/animated-drawings-two-state-apng</id>
        <link href="https://unysonplus.github.io/decisions/animated-drawings-two-state-apng"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A reference site animates its hand-drawn hero underline — it draws itself on when the hero appears, then settles into a subtle idle wave. Inspecting the source, it does NOT use an animated SVG stroke; it ships TWO animated PNGs per drawing — a '…in.png' (the draw-on intro, played once) and a '…default.png' (the idle loop, always playing) — layered as two <img>s and toggled by JS. We matched that technique in a demo: the idle APNG is the CSS background (so every underline subtly waves), and a tiny registry-driven runtime plays the intro APNG once when an element enters view — appending a fresh ?t=<now> to force the animated PNG to restart from frame 0 — then clears the inline style to hand back to the CSS idle loop. This documents the algorithm and why the cache-bust is the crux.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> a reference site's hero underline is a hand-drawn scribble that <em>draws itself on</em> when
the hero appears and then <em>idly waves</em>. How is that built, and how do we reproduce it — an animated SVG
path (stroke-dashoffset draw-on) or something else?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/animated-drawings-two-state-apng#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Inspecting the source markup, the underline is not an SVG at all. It is two <strong>animated PNGs (APNG)</strong>
layered in a wrapper:</p>
<div class="language-html codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-html codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token tag punctuation" style="color:#393A34">&lt;</span><span class="token tag" style="color:#00009f">div</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">class</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">relative</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token tag punctuation" style="color:#393A34">&lt;</span><span class="token tag" style="color:#00009f">img</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">class</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">in-img … invisible</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">data-id</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">in-hero underline</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag" style="color:#00009f">   </span><span class="token tag attr-name" style="color:#00a4db">src</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">…/hero underline in.png</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token tag punctuation" style="color:#393A34">&lt;</span><span class="token tag" style="color:#00009f">img</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">class</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">idle-img …</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag" style="color:#00009f">         </span><span class="token tag attr-name" style="color:#00a4db">data-id</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">idle-hero underline</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">src</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">…/hero underline default.png</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token tag punctuation" style="color:#393A34">&lt;/</span><span class="token tag" style="color:#00009f">div</span><span class="token tag punctuation" style="color:#393A34">&gt;</span><br></div></code></pre></div></div>
<p>So the source authors each hand-drawn accent as a short <strong>draw-on</strong> clip (<code>…in.png</code>) and a looping
<strong>idle</strong> clip (<code>…default.png</code>), both exported as APNG, and a little JS toggles between the two
<code>&lt;img&gt;</code>s: the intro plays once when the section enters, then the idle loop takes over. Both files carry
an <code>acTL</code> chunk (confirmed) — they are genuinely animated PNGs, ~600×45.</p>
<p>Why APNG and not SVG here: these are <em>irregular, textured, multi-stroke doodles</em> (a double scribble
with pencil grain). Reproducing that as an SVG stroke-draw would mean hand-tracing paths and would
still miss the texture. A designer can draw and export the two clips directly; the web just plays them.
It's the right tool for hand-drawn assets.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-reproduction-algorithm-and-the-one-non-obvious-part">The reproduction algorithm (and the one non-obvious part)<a href="https://unysonplus.github.io/decisions/animated-drawings-two-state-apng#the-reproduction-algorithm-and-the-one-non-obvious-part" class="hash-link" aria-label="Direct link to The reproduction algorithm (and the one non-obvious part)" title="Direct link to The reproduction algorithm (and the one non-obvious part)" translate="no">​</a></h2>
<p>We matched the technique in the demo, but adapted to a CSS-background underline instead of two <code>&lt;img&gt;</code>s:</p>
<ol>
<li class=""><strong>Idle = the CSS background.</strong> <code>…-idle.png</code> (the looping APNG) is set as the <code>&lt;em&gt;</code> underline's
<code>background-image</code>, so every emphasized word subtly waves with no JS at all.</li>
<li class=""><strong>Intro = a JS swap on enter.</strong> A tiny registry-driven runtime (<code>[selector, name, ms]</code> rows) watches
each registered element with an IntersectionObserver; on first enter it sets the background to
<code>…-in.png</code>, then after the intro length clears the inline style so it hands back to the CSS idle loop.</li>
<li class=""><strong>The crux — restart via a cache-bust query string.</strong> An APNG plays once when it is first decoded and
then holds its last frame; there is <strong>no DOM API to rewind it</strong>. To make the intro play <em>on enter</em>
(not merely on page load), we append <code>?t=&lt;Date.now()&gt;</code> to the <code>…-in.png</code> URL, which the browser
treats as a new resource and decodes fresh — restarting the animation from frame 0. Without that
query string the draw-on would only ever fire once, on load, off-screen.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/animated-drawings-two-state-apng#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Animated SVG (stroke-dashoffset draw-on)</strong> — clean and tiny, but can't reproduce a textured
multi-stroke doodle without hand-tracing, and still looks like a vector line, not a pencil.</li>
<li class=""><strong>A single static PNG</strong> (what the demo had) — no animation at all; the accent read as flat.</li>
<li class=""><strong>Two-state APNG (in → idle), the source's own approach</strong> — chosen. Matches the reference exactly,
keeps the hand-drawn texture, and stays authorable by a designer (draw two clips, export APNG).</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/animated-drawings-two-state-apng#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Use <strong>two APNGs per hand-drawn accent</strong> — an idle loop set as the CSS background, and an intro draw-on
played once on enter via a cache-busted URL swap — then hand back to the idle loop. Keep the intro list
in a small registry so more drawings are one line each.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/animated-drawings-two-state-apng#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>It reproduces the effect faithfully (texture and all), it is authorable from design tools rather than
hand-coded paths, and the whole runtime is a dozen lines. The single insight that makes it work is that
<strong>an APNG restarts only when re-fetched</strong>, so the enter-triggered draw-on hinges on the cache-bust query
string — the rest is just an IntersectionObserver and a timeout. If this graduates from the demo into a
framework feature (an "animated drawing" element), this is the mechanism to generalize.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="JavaScript" term="JavaScript"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Is the Animated Heading shortcode redundant now that the Animation Engine has text effects?]]></title>
        <id>https://unysonplus.github.io/decisions/animated-heading-vs-engine-text-effects</id>
        <link href="https://unysonplus.github.io/decisions/animated-heading-vs-engine-text-effects"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Animation Engine's Text Effects already include rotating_words and typewriter, so the standalone Animated Heading shortcode looked like a duplicate. It was kept (and enriched) rather than folded into the engine, because the engine ships inactive by default — the shortcode is the only rotating headline that works out of the box.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> The Animation Engine → Text Effects module already ships <code>rotating_words</code>
and <code>typewriter</code> (among ~40 effects) that can be applied to any text element. Does the
standalone <code>animated_heading</code> shortcode still earn its place, or should it be deprecated in
favor of "special_heading + a text effect"?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/animated-heading-vs-engine-text-effects#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>animated_heading</code> is a purpose-built rotating headline: before-text + rotating words +
after-text, with 6 rotation styles, a word-highlight, speed and colors — ~86 lines of
self-contained JS, no dependency. The Animation Engine's Text Effects cover the same two
<em>concepts</em> (typewriter, rotating words) plus 38 more, applied to any element's own text via a
<code>text_effect</code> option. On paper that's duplication.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/animated-heading-vs-engine-text-effects#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Deprecate the shortcode</strong> — steer users to <code>special_heading</code> + <code>text_effect: rotating_words</code>.
One implementation, but it <em>requires the Animation Engine to be active</em> — and the engine ships
<strong>inactive by default</strong> (opt-in in Extensions). Out-of-box rotating headlines would break.</li>
<li class=""><strong>Make the shortcode delegate to the engine when active</strong> — a hybrid renderer. Adds a hard
dependency branch and loses the no-engine fallback that is the shortcode's whole value; the
engine effect also expects the element's own text, not a before/words/after split.</li>
<li class=""><strong>Keep it standalone, clarify positioning, and enrich it</strong> <em>(chosen)</em> — accept the small,
intentional overlap; make the two easy to tell apart in the UI/docs; and deepen the shortcode
so it's clearly the better tool for its one job.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/animated-heading-vs-engine-text-effects#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Keep <code>animated_heading</code> as a <strong>zero-dependency rotating headline</strong>, distinct from the engine.
Its Animation picker now cross-references the Text Effects (and warns against stacking both),
and the shortcode gained playback controls (loop once / pause on hover / randomize), typewriter
caret controls, two rotation styles (blur, 3D-rotate) and two highlight styles (gradient, pill).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/animated-heading-vs-engine-text-effects#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The deciding fact is that the <strong>Animation Engine is opt-in and off by default</strong> — a deliberate
choice so the base plugin stays lean. That makes the shortcode the <em>only</em> rotating headline that
works without asking the user to activate an extension, which is exactly the moment a beginner
reaches for one. Beyond that, the two serve different mental models — "drop a rotating-headline
element" vs. "sprinkle an effect onto any text" — and the shortcode's headline-specific options
(before/after static text, per-word highlight, purpose-built rotation styles) are richer for
that narrow job than the generic <code>rotating_words</code> effect. Folding it into the engine would trade
a tiny amount of duplicated code for a worse out-of-box experience and a new dependency. The
overlap is acceptable; the fix was positioning and depth, not consolidation.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Shortcodes" term="Shortcodes"/>
        <category label="Architecture" term="Architecture"/>
        <category label="Back-compat" term="Back-compat"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[The cinematic "camera pan" is a moving backdrop, not a camera — so we made it a Stage option]]></title>
        <id>https://unysonplus.github.io/decisions/camera-pan-as-backdrop-motion-option</id>
        <link href="https://unysonplus.github.io/decisions/camera-pan-as-backdrop-motion-option"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A reference site's mid-scroll shots feel like a camera gliding across a scene. Watching the recording frame-by-frame, there is no camera — the whole backdrop simply pans and scales as you scroll. We first faked it with hand-written CSS on a demo's backdrop image, then promoted it to a first-class Animation Engine option: the scrollytelling Stage's Backdrop Motion (pan / dolly / pan+dolly + direction + intensity), scrubbed by the published story progress. This records why the illusion is worth generalising and why it belongs on the backdrop container, not the image.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> a reference site's scene sections look like a film camera slowly glides across the
scene as you scroll — a genuinely cinematic touch. Is that a real 3D camera move (rendered footage), and
if not, how do we reproduce it in UnysonPlus without baking a video?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/camera-pan-as-backdrop-motion-option#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Stepping through a mid-scroll stretch of the recording frame-by-frame, the "camera move" is an illusion:
<strong>the backdrop itself translates sideways and scales up a touch as the scroll advances.</strong> There is no
perspective shift, no parallax between foreground and background layers — the whole still image just
pans and dollies. The insight came straight from watching it ("the camera panning is just an illusion
of the background moving"), which is exactly right and is the entire trick.</p>
<p>That reframes the problem. We don't need footage or a WebGL camera; we need to transform a <em>static</em>
backdrop by scroll progress. The scrollytelling Stage already publishes its 0→1 progress every frame
(the <code>--story-progress</code> CSS var and <code>el.__storyProgress</code>), so the demo's first cut was a few lines of
hand-CSS on the demo child theme: <code>transform: scale(calc(1.16 + var(--story-progress) * 0.1)) translateX(calc((var(--story-progress) - 0.5) * -14%))</code> on the backdrop <code>&lt;img&gt;</code>. It worked — and that
was the tell that this is a <em>reusable</em> capability, not a one-demo hack.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/camera-pan-as-backdrop-motion-option#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Leave it as per-site hand-CSS.</strong> Cheapest, but every site that wants the effect re-derives the same
transform math, has to know the <code>--story-progress</code> var exists, and risks stacking its transform
against anything else on the image (a blur, a fit tweak). It also isn't discoverable in the builder.</li>
<li class=""><strong>Bake it as footage / a frame <code>sequence</code> backdrop.</strong> Highest fidelity, but violates the standing rule
that demo content stays live and user-replaceable, and it's heavy. A still image + a transform gets
95% of the look for kilobytes.</li>
<li class=""><strong>Promote it to a Stage option (chosen).</strong> Add <code>backdrop_motion</code> (<code>none</code> / <code>pan</code> / <code>dolly</code> /
<code>pan_dolly</code>) with <code>motion_direction</code> and <code>motion_intensity</code>, computed in the Stage's frame loop and
applied to the backdrop <strong>container</strong>. Discoverable, reusable, and composes with the image's own CSS.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/camera-pan-as-backdrop-motion-option#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Ship <strong>Backdrop Motion</strong> as a first-class scrollytelling Stage option. The module reads the three
data-attributes at render time and, in the same rAF loop that scrubs the backdrop, sets a
<code>scale(...) translate(...)</code> on the backdrop <strong>container element</strong> (not the <code>&lt;img&gt;</code>). The demo now uses
<code>backdrop_motion: 'pan_dolly'</code> and drops its hand-CSS transform, keeping only the blur on the image.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/camera-pan-as-backdrop-motion-option#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>The illusion is the mechanism.</strong> Once you see the camera move <em>is</em> a moving backdrop, the honest
implementation is a scroll-scrubbed transform — so the framework should own that math, not each site.</li>
<li class=""><strong>Container, not image, is the right layer.</strong> Sites routinely style the backdrop image (blur for
depth-of-field, <code>object-fit</code>). Transforming the <em>container</em> leaves the image's own CSS untouched, so
the motion composes instead of fighting it. (The demo's first cut transformed the <code>&lt;img&gt;</code> and would
have stacked against its blur rule — the migration to the option removed that footgun, and the kit
doc now explicitly warns against writing a second transform on the image.)</li>
<li class=""><strong>It keeps the "live elements, not footage" promise.</strong> A single still backdrop + a scrubbed transform
reproduces the reference site's rendered camera glide while the backdrop stays a swappable Media layer
— consistent with why we chose editable elements over baked cinematic footage in the first place.</li>
<li class=""><strong>Intensity + direction make it a general tool,</strong> not a one-site constant: a gentle ~40 reads as a
slow drift; higher values give a strong sweep, and the pan axis is author-chosen.</li>
</ul>
<p>The broader pattern worth remembering: when a site's "expensive" motion turns out to be a simple
transform driven by scroll, the win is to expose the <em>published progress</em> (which the Stage already does)
and add a small, well-named option that scrubs against it — rather than either hand-coding it per site
or reaching for footage.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Animation" term="Animation"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Replicating a cinematic site: live, user-replaceable elements over baked-in fidelity]]></title>
        <id>https://unysonplus.github.io/decisions/editable-elements-over-baked-cinematic-fidelity</id>
        <link href="https://unysonplus.github.io/decisions/editable-elements-over-baked-cinematic-fidelity"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[When rebuilding a heavily scroll-animated reference site as a UnysonPlus demo, there were two ways to hit the same look: bake the whole camera ride into one pre-rendered video/frame sequence that scrubs with scroll (max pixel fidelity, but the content is frozen in the footage), or build every card, device screen, image and heading as a LIVE page-builder element animated over an optional decorative backdrop (slightly less pixel-perfect, but every element stays editable/replaceable). We chose live elements. The whole reason people reach for UnysonPlus is to capture a site for inspiration and then easily add or swap any element in the builder — so content must never be locked inside baked footage. The only baked layer allowed is the decorative backdrop, and even that stays an uploadable option.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> to make a demo's animation flow match a heavily scroll-animated reference site, do
we bake the continuous "camera ride" into one pre-rendered video / frame sequence that scrubs with
scroll (the way the reference site itself does it), or do we keep every card, device screen, image and
line of copy as a <strong>live page-builder element</strong> and drive the camera moves with scroll animation over
an optional decorative backdrop?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/editable-elements-over-baked-cinematic-fidelity#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The reference site achieves its look by pre-rendering the entire journey as 3D footage: the scattered
file cards, the film strip, the device mockup, the camera pan — all of it is baked into the video, and
the only live DOM on the page is the headings and the search box. That is why it looks flawless and
also why, as a <em>product</em> to hand a user, it would be a dead end: you cannot replace a card, swap the
device's screenshot, or change a photo, because none of those are elements — they are pixels in a movie.</p>
<p>That collides head-on with the reason UnysonPlus exists. The pitch is: <strong>capture a site you like for
inspiration, then add or replace any element right in the page builder.</strong> A user converts a reference
site as a starting point and then makes it theirs, element by element, entirely through the builder UI
— the same principle already written into the "every media/content element must be user-replaceable"
rule and the converter's whole purpose.</p>
<p>A practical fact sealed it: we only had pre-rendered sequences for the hero and the outro. There was no
baked footage for the long middle journey, so its content was always going to be live elements
regardless.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/editable-elements-over-baked-cinematic-fidelity#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Bake the journey as one scrubbed backdrop video/sequence</strong> (max fidelity). The camera pan, the
scatter, the card rotations all come for free because they are in the footage. But the content is
frozen: no swapping cards, screenshots, or copy in the builder. It turns the "starter you make your
own" into a "video you cannot touch." Rejected — it breaks the core value even though it looks best.</li>
<li class=""><strong>All content as live page-builder elements</strong> (galleries, image boxes, device mockups, text)
animated with the Animation Engine over an optional decorative backdrop. Slightly less pixel-perfect
than pre-rendered 3D, and it means building real scene-galleries (a scroll-driven scatter→organize
morph, a device view-mode cycler, directional scene transitions) instead of getting them free from
footage — but every element stays editable and replaceable. Chosen.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/editable-elements-over-baked-cinematic-fidelity#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Content is always live and user-replaceable; only the decorative backdrop may be baked, and even it
stays an uploadable option.</strong> We replicate the <em>motion</em> (the pinned scene, the beats that cross-fade
and pan, the scatter→organize, the device screen cycling) using live builder elements plus the
scrollytelling Stage, not by freezing the content into a rendered movie. The new capabilities this
requires — scene-based ("pinned Stage + beats") structure, new scene-galleries, and directional/whoosh
transitions — get built as real, editable framework features rather than shortcuts baked into media.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/editable-elements-over-baked-cinematic-fidelity#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>Fidelity is worth nothing if the user can't edit the result. The one durable advantage UnysonPlus has
over "just export the design as a video" is that everything on the page is a real element the user owns
and can change. Chasing pixel-perfect parity by baking the camera ride would spend that advantage to
win a screenshot contest. Building the motion out of live elements costs more engineering up front, but
it is reusable across every future site (not just this one demo), it keeps the convert→customize
promise intact, and it is what turns "capture for inspiration" into "capture, then make it yours." When
fidelity and editability conflict, editability wins.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="Conversion" term="Conversion"/>
        <category label="Shortcodes" term="Shortcodes"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How far should the Animation Engine expose GSAP — and how do we teach it?]]></title>
        <id>https://unysonplus.github.io/decisions/gsap-exposure-ladder</id>
        <link href="https://unysonplus.github.io/decisions/gsap-exposure-ladder"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[GSAP already powers Scroll Motion, but the options only exposed a curated slice — no easing control, no timelines, no escape hatch. The question was how much of the underlying library to surface without turning a builder into a code editor. The answer became a five-rung ladder from presets to a raw snippet field, with a "show me the generated GSAP" panel as the rung that teaches the library instead of hiding it.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> GSAP + ScrollTrigger already drive the Scroll Motion module, but the builder only
exposed a hand-picked slice — Style presets, direction, distance, start. Power users kept hitting a
wall (no easing choice, no way to choreograph several elements, no way to write anything the presets
couldn't express). How much of GSAP should we surface, and where does exposing-more stop being
"friendly to advanced users" and start being "a code editor bolted onto a page builder"?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/gsap-exposure-ladder#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Two forces pulled against each other. The framework's whole promise is <em>"native options before
code"</em> — a non-developer should build motion by clicking, not by learning an API. But GSAP is a real
library that's <em>already loaded</em>, and refusing to expose any of it wastes a capability the site is
already paying for. A blunt "add a GSAP code field" answers the power user and betrays the beginner;
"never expose anything" does the reverse.</p>
<p>There was also a stated goal beyond either audience: the owner wanted the engine to help people
<em>learn</em> GSAP and web animation "in our own way" — the builder as a teaching tool, not just a
generator.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/gsap-exposure-ladder#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Stay curated</strong> — keep the presets, add nothing. Simple, but the wall stays and nothing is
learned.</li>
<li class=""><strong>One raw GSAP code field</strong> — maximal power, minimal work. But it's the antithesis of the
clean-DOM / no-code promise, a support and security burden, and it teaches nothing (you either
already know GSAP or you don't).</li>
<li class=""><strong>A graduated ladder</strong> <em>(chosen)</em> — expose GSAP in rungs, each optional, each a no-op at its
default, with a rung whose entire job is to <em>show</em> the code the earlier rungs generate.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/gsap-exposure-ladder#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Five rungs, shipped across engine 1.2.38–1.2.41, on the Scroll Motion module:</p>
<ol>
<li class=""><strong>Presets</strong> — pick a tile, it looks good. Most users never leave here.</li>
<li class=""><strong>Advanced options</strong> (1.2.38) — the GSAP knobs the presets normally decide: easing (curated eases
<ul>
<li class="">a validated custom string), scrub smoothing, ScrollTrigger markers. Collapsed behind a single
"Default" row; every field renders byte-identically to before when left alone.</li>
</ul>
</li>
<li class=""><strong>Motion Sequence</strong> (1.2.40) — choreography without code: a Section plays its children's
Reveal/Stagger as one <code>gsap.timeline()</code>, the <em>steps are the children</em>, one Overlap knob tunes the
flow. (Its own decision entry covers the timeline→options translation.)</li>
<li class=""><strong>Show generated GSAP</strong> (1.2.39) — a read-only panel inside each effect that prints the exact
<code>gsap.from(el,{…})</code> the current settings produce, live. The teaching rung.</li>
<li class=""><strong>Motion Snippet</strong> (1.2.41) — a "Custom Code" effect: write your own GSAP with <code>el</code> / <code>tl</code> / <code>gsap</code>
provided; the runtime manages lifecycle. The lossless escape hatch. (Security in its own entry.)</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/gsap-exposure-ladder#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Every rung is opt-in and default-inert</strong>, so the beginner experience is untouched — the ladder
adds ceiling without raising the floor. That's what lets one panel serve both audiences.</li>
<li class=""><strong>Rung 4 is the keystone.</strong> Exposing knobs (rung 2) or a code field (rung 5) is common; a panel
that shows you <em>the code your own clicks generate</em> is not, and it's what turns the engine from a
black box into a way to learn GSAP. The intended path is legible: build with options → read the
generated GSAP → Copy → paste into a Motion Snippet → tweak. Each rung hands off to the next.</li>
<li class=""><strong>It respects "native options before code" without pretending code never matters.</strong> Rungs 1–4 are
all options; code only appears at rung 5, as the explicit, gated last resort — the same shape as
styling's "everything is an option until Custom CSS."</li>
<li class=""><strong>Reuse over new surface.</strong> Rungs 2 and 5 live inside the existing <code>gsap_motion</code> control; rung 4 is
a value-less option type mirroring the 3D-gallery preview; rung 3 reuses the runtime's own config
builder. No parallel systems.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="note-on-the-capture-pipeline">Note on the capture pipeline<a href="https://unysonplus.github.io/decisions/gsap-exposure-ladder#note-on-the-capture-pipeline" class="hash-link" aria-label="Direct link to Note on the capture pipeline" title="Direct link to Note on the capture pipeline" translate="no">​</a></h2>
<p>The ladder also gave the site converter somewhere to put motion it detects but can't map to an
option: an advanced-tuned effect, or — when nothing fits — a Motion Snippet reconstructed from the
traced ScrollTrigger/transform data. Motion now has the same <em>map → tune → snippet-fallback → report</em>
ladder that styling has (<em>map → tune → Custom CSS → report</em>).</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="JavaScript" term="JavaScript"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Securing author-written JS: gate execution, not the UI]]></title>
        <id>https://unysonplus.github.io/decisions/motion-snippet-execution-gate</id>
        <link href="https://unysonplus.github.io/decisions/motion-snippet-execution-gate"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Motion Snippet field lets a user write raw GSAP that runs on the front end — arbitrary JavaScript, the classic privilege-escalation surface. The first instinct was to hide the field from users who lack unfiltered_html. That turned out to be both insecure-feeling and functionally broken — gating the choice made the saved value fragile, and hiding UI is not a security boundary anyway. The real gate is at execution, keyed to the page author's capability, exactly as WordPress gates page-level custom scripts.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> the Motion Snippet (rung 5 of the GSAP ladder) lets a user type GSAP that executes
on the front end. That's arbitrary JavaScript — the same trust surface as a Custom HTML block or a
theme's custom-JS box. How do we make it safe without breaking it, and where does the capability
check actually belong?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/motion-snippet-execution-gate#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>WordPress already has a bar for "this user may supply raw markup/JS": the <code>unfiltered_html</code>
capability (admins, and editors on single-site; nobody below). The naive design is "only show the
code field to users who have it." But two things went wrong with that, discovered by building it:</p>
<ol>
<li class=""><strong>Gating the <em>choice</em> corrupted saved data.</strong> The snippet lived as a <code>custom</code> choice in the Scroll
Effect multi-picker. When the choice was only <em>registered</em> for capable users, any server-side
re-validation that ran without that user context — notably <code>json_to_shortcodes</code>, which regenerates
post_content — didn't see <code>custom</code> as a valid choice and silently reset the value to <code>none</code>. The
snippet vanished on the next content regeneration.</li>
<li class=""><strong>Hiding UI is not a trust boundary.</strong> A hidden field is still POST-able; a custom role could
expose it; and the value has to survive round-trips regardless of who's looking. "They can't see
it" was never actually the security property.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/motion-snippet-execution-gate#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>UI gate</strong> — only capable users see the field. Fragile (above) and not a real boundary.</li>
<li class=""><strong>Save-time sanitize</strong> — strip the code on save for users without the cap (kses-style). Correct as
<em>defense in depth</em>, but on its own it's brittle: it depends on the exact save path running kses,
and the field's value shape isn't plain markup.</li>
<li class=""><strong>Execution gate</strong> <em>(chosen)</em> — always allow the field and store the code; only ever <em>run</em> it when
the current page's <strong>author</strong> has <code>unfiltered_html</code>, checked per request at output.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/motion-snippet-execution-gate#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>The <code>custom</code> choice is <strong>always</strong> registered (so the value is never reset by re-validation). The code
is base64-stamped into <code>post_content</code> as a <code>data-upw-snip</code> attribute — static, exactly like every
other <code>data-upw-g*</code> attribute, so it survives without re-running shortcodes. Execution is gated at
render: a footer script emits <code>window.upwSnippetsOK = 1</code> <strong>only</strong> when the queried singular's author
can <code>unfiltered_html</code>, and the runtime runs a snippet <strong>only</strong> if that flag is set. This mirrors the
theme's existing <code>page_custom_js</code>, which checks the author capability at <code>wp_footer</code> output.</p>
<p>kses stripping the attribute from a non-capable user's save remains true as a second layer, but the
primary guarantee is the per-request execution gate.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/motion-snippet-execution-gate#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>The capability question is really "does the responsible author get to run JS on this page?"</strong> —
and that's an author-and-request property, not a "who is currently editing" property. Checking the
author at output is the only place that answer is stable and correct. It's also precisely how
WordPress already treats page-level scripts, so it's a known-good model, not a bespoke one.</li>
<li class=""><strong>Separating storage from execution fixes the fragility.</strong> The value is plain data that always
round-trips; safety doesn't ride on the value being present or absent. A contributor-authored page
can <em>contain</em> a snippet (e.g. pasted by an admin earlier) and it simply won't run until a trusted
author owns the page — no data loss, no silent reset.</li>
<li class=""><strong>Defense in depth without depending on any single layer.</strong> Execution gate (primary) + kses on
untrusted saves (secondary) + reduced-motion skip + a <code>new Function</code> boundary that can't reach the
options-render AJAX. Any one failing doesn't open the door.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://unysonplus.github.io/decisions/motion-snippet-execution-gate#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted. The rule generalizes: <strong>for any author-supplied executable content, store it as inert data
and gate execution on the responsible author's capability per request — never rely on hiding the
input.</strong> Applies to future snippet-like surfaces (custom cursors, custom shaders, etc.).</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Security" term="Security"/>
        <category label="JavaScript" term="JavaScript"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[One data repo for all content libraries; presets get a browse option, not a rewrite]]></title>
        <id>https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse</id>
        <link href="https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[As the Template Library grew and a presets library was proposed, the org was about to sprout a data repo per library type (templates, presets, icon packs). That reads as clutter. The resolution splits the world into code repos (must stay separate, one per extension, for the auto-updater) and data repos (pure content, safe to consolidate) — so all library CONTENT lives in one UnysonPlus-Library repo under per-type subfolders, while extension code stays one-repo-per-extension. Presets then gain the library by augmenting their existing Apply/Upload-JSON UI with a Browse option, not by replacing it — and only self-contained layout presets ship as singles; interdependent style tokens ship as cohesive packs.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> every new content library (page-builder templates, then component
presets, then icon packs) was about to get its own GitHub <strong>data</strong> repo. That felt messy.
Can we put all library content in one repo — and if so, what actually has to stay split?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The Template Library ships as two repos: <code>UnysonPlus-Template-Library-Extension</code> (the plugin
<strong>code</strong>) and <code>UnysonPlus-Templates</code> (the <strong>content</strong> it downloads — a <code>catalog.json</code> plus
template JSON, fetched over <code>raw.githubusercontent.com</code>). Icon packs already have their own
content repo (<code>UnysonPlus-Icon-Packs</code>), and a presets library was on the table. On that
trajectory the org accretes a data repo per library type, which is organizational clutter for
a solo maintainer.</p>
<p>Two facts made the call clear:</p>
<ol>
<li class=""><strong>Code and content are not the same kind of repo.</strong> Each extension's <code>manifest.php</code> carries a
<code>github_update</code> header the plugin's auto-updater keys off. That coupling is what <em>forces</em>
one code repo per extension. Content repos have no such coupling — the client resolves a
catalog via a filterable <code>*_catalog_url()</code> and fetches individual files by raw URL. Nothing
about the updater touches them.</li>
<li class=""><strong>End-user cost is per-file, not per-clone.</strong> A site only ever <code>wp_remote_get</code>s the specific
<code>catalog.json</code> and item files it needs; it never clones the repo. So consolidating content
doesn't grow any site's footprint — repo size only affects the maintainer's own clone.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>A repo per library type</strong> (status quo trajectory) — clean isolation, but N repos to create,
README, and remember; the clutter that prompted the question.</li>
<li class=""><strong>One repo for everything, code included</strong> — rejected: merging extension code breaks the
per-extension <code>github_update</code> auto-updater. Non-negotiable.</li>
<li class=""><strong>One repo for all <em>content</em>, code stays split</strong> <em>(chosen)</em> — a single <code>UnysonPlus-Library</code>
data repo with one self-contained subfolder per type (<code>templates/</code>, <code>page-presets/</code>,
<code>header-presets/</code>, <code>style-packs/</code>, <code>icon-packs/</code>, …), each with its own <code>catalog.json</code> whose
<code>base_url</code> points at that subfolder; each extension points its catalog URL at its subfolder.</li>
</ul>
<p>On the presets library specifically, a second question surfaced: is it a browse-and-install of
<em>individual</em> presets, or of <em>packs</em>? Component presets split into two natures — <strong>layout
presets</strong> (Page, Header, Footer) are self-contained whole-region configs with no cross-refs,
while <strong>style-token presets</strong> (Color, Typography, Button, Box) reference each other (a button
preset points at a color slug). Installing a lone button preset can therefore dangle a missing
reference; installing a lone header preset cannot.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<ul>
<li class=""><strong>One <code>UnysonPlus-Library</code> data repo</strong>, per-type subfolders, each self-contained. Extension
<strong>code</strong> stays one-repo-per-extension (the updater requires it). <code>UnysonPlus-Templates</code> was
renamed to <code>UnysonPlus-Library</code>; templates moved under <code>templates/</code> with the catalog inside it,
and the extension repointed. The install-path builder dropped its hardcoded <code>templates/</code>
segment so every library resolves items as <code>base_url + &lt;slug&gt;/…</code> within its own subfolder.</li>
<li class=""><strong>Presets get the library by augmentation, not rewrite.</strong> The existing preset UI (a card grid
with <em>Apply</em> / <em>Upload JSON</em> / <em>Export current</em>) stays; a <strong>Browse Library</strong> button is added
next to <em>Upload JSON</em>. The library is simply the curated, browsable form of the upload path —
an installed preset becomes another applyable card.</li>
<li class=""><strong>Layout presets ship as singles; style tokens ship as cohesive packs.</strong> Page/Header/Footer
presets are self-contained, so individual catalog entries are safe and highest-value.
Color/Typography/Button/Box travel together as a coordinated <em>style pack</em> so nothing dangles.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>The split falls on the real seam.</strong> The only thing that <em>must</em> be one-repo-per-thing is what
the auto-updater is wired to — the code. Everything the updater doesn't touch (content) is free
to consolidate, and consolidating it removes clutter with no downside a site ever feels.</li>
<li class=""><strong>Augmenting reuses a working flow.</strong> Presets already round-trip as exported/uploaded JSON, so a
library is 90% built — Browse is just Upload with a catalog in front of it. Replacing the preset
UI would throw away the Apply/Export plumbing for no gain.</li>
<li class=""><strong>Match the distribution unit to the dependency graph.</strong> Self-contained things distribute
cleanly as singles; interdependent things must travel as a set or they arrive broken. Shipping
style tokens as packs is the same "install a consistent whole" principle that makes a page
template safe to drop in.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://unysonplus.github.io/decisions/one-library-data-repo-and-preset-browse#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted. Rule of thumb going forward: <strong>split repos on the updater seam (code per extension), and
consolidate everything the updater doesn't touch (all library content in <code>UnysonPlus-Library</code>).</strong>
When adding a browsable library to an existing feature, prefer augmenting its current
Apply/Upload/Export surface over replacing it, and let each item's dependency graph decide whether
it ships as a single or a pack.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="Naming" term="Naming"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How does a GSAP timeline become builder options a non-coder can drive?]]></title>
        <id>https://unysonplus.github.io/decisions/timeline-as-builder-options</id>
        <link href="https://unysonplus.github.io/decisions/timeline-as-builder-options"/>
        <updated>2026-07-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A GSAP timeline sequences several elements' animations with relative positions — the concept our per-element effect model couldn't express. Rather than a keyframe editor or a code field, the timeline became a Section behavior where the steps ARE the child elements in document order and one Overlap knob tunes how they flow, reusing the runtime's existing config builder so a step looks identical whether standalone or sequenced.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> the engine's model was one effect per element, each firing independently when it
scrolls into view. A GSAP <em>timeline</em> is the opposite — several elements animating in a choreographed
order with relative timing. How do you translate that into page-builder options a non-coder can
drive, without shipping a keyframe editor or a code field?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/timeline-as-builder-options#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Users faked choreography by hand-tuning per-element <code>delay</code> values ("heading at 0, subtitle at 0.2,
button at 0.4") — brittle, because changing one duration means re-tuning every downstream delay. That
brittleness is exactly what timelines exist to remove: positions are relative, so the sequence holds
together when any single tween changes. But a timeline is a <em>code</em> construct; the builder needed a
shape a visual editor could express.</p>
<p>The framework had already solved a structurally identical problem twice: Sticky Card Stack and Scroll
Story both treat <em>"the child columns"</em> as the ordered units of a Section-level effect.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/timeline-as-builder-options#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>A keyframe/timeline editor UI</strong> — most faithful to GSAP, but heavy, and it drags the builder
toward being an animation tool rather than a page tool.</li>
<li class=""><strong>Per-element "start at N seconds" fields</strong> — familiar, but it recreates the brittle absolute-delay
problem the timeline was meant to solve.</li>
<li class=""><strong>A Section behavior where steps = children</strong> <em>(chosen)</em> — mark a Section as a Motion Sequence and
its children's existing Reveal/Stagger animations assemble into one <code>gsap.timeline()</code> in document
order; one <strong>Overlap</strong> knob sets how far each step starts before the previous ends.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/timeline-as-builder-options#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Motion Sequence (engine 1.2.40), a Section-level module mirroring Sticky Stack / Scroll Story. Turn it
on and the descendant Reveal/Stagger steps play as one timeline on a single trigger (on-view or
scrub). The steps are the children in builder order; Overlap is the only timing control. Children keep
the effects they already have — the sequence only decides <em>when</em> each fires.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/timeline-as-builder-options#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>The translation is "positions become one knob, steps become the children."</strong> <code>.to(b)</code> sequential,
<code>.to(c,'&lt;')</code> together, <code>.to(d,'+=0.5')</code> offset — the full position vocabulary collapses, for the
common case, into "each step starts <code>overlap</code> seconds before the previous ends." A non-coder reads
that as a single slider, not a timeline API.</li>
<li class=""><strong>No new vocabulary.</strong> A child in a sequence uses the same Reveal it would use standalone; the
runtime reuses its own <code>compound()</code> config builder, so a step is byte-identical either way
(direction, distance, style, ease — including the Advanced ease). Existing pages gain choreography
by flipping one Section option.</li>
<li class=""><strong>It matches a shape the framework and its users already know</strong> (cards = columns, scenes = columns
→ steps = children), which is why it needed no new mental model and no new front-end asset — it
rides the Scroll Motion runtime that's already present because the children use it.</li>
<li class=""><strong>Scoped to Reveal/Stagger first.</strong> Those are the 95% choreography case (heading → subtitle →
button) and the two effects that share the entrance config builder; other effects stay standalone.
Per-element after/with/offset positioning is the natural next increment, once the one-knob version
proves the shape.</li>
</ul>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="Page builder" term="Page builder"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Should Page Builder pages show the theme's page title?]]></title>
        <id>https://unysonplus.github.io/decisions/builder-pages-hide-theme-title</id>
        <link href="https://unysonplus.github.io/decisions/builder-pages-hide-theme-title"/>
        <updated>2026-07-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A global "Show Page Title on Builder Pages" toggle briefly shipped defaulting to yes, injecting an H1 title bar above every builder-built page. The default flipped to no — a builder page is a designed composition whose hero owns the H1 — matching the Elementor/Divi/Astra convention, while the toggle and the per-page Hide Page Title checkbox both survive.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> Builder pages started rendering the theme's page title as an H1 bar above the
built content (so "every page has exactly one H1"). Was that a good default — or should the
theme title simply be disabled whenever the Page Builder is used, leaving the per-page
"Hide the page title" checkbox to classic-editor pages only?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/builder-pages-hide-theme-title#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The theme renders a classic title header on ordinary pages, with a per-page <strong>Hide the page
title</strong> checkbox (Page Settings). A header/footer batch added a global default —
<code>pages_show_title_on_builder</code>, shipping as <code>yes</code> — that extended a title-only H1 header to
builder pages too, motivated by the heading-outline audit (every page should have exactly one
H1). The immediate effect: existing builder-built sites (the marketing home, the demos hub)
suddenly grew a duplicate title bar above their heroes.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/builder-pages-hide-theme-title#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>Keep showing by default</strong> — guarantees an H1 on builder pages whose authors forgot one, but
breaks every existing designed page and duplicates the H1 on every page that <em>does</em> have a
hero heading (the normal case).</li>
<li class=""><strong>Hard-disable on builder pages</strong> — the Bricks/Oxygen philosophy: a built page owns its
markup, the checkbox becomes classic-editor-only. Simplest, but removes the option for
docs-style sites that genuinely want a uniform title bar above builder content.</li>
<li class=""><strong>Hide by default, keep the opt-ins</strong> <em>(chosen)</em> — flip the global default to <code>no</code>; the
site-wide toggle remains for uniform-title-bar sites, and the per-page checkbox still
force-hides when the global is on.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/builder-pages-hide-theme-title#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Builder pages hide the theme title <strong>by default</strong>. <code>pages_show_title_on_builder</code> defaults to
<code>no</code> (declaration and code fallback both), the global toggle stays available as a site-wide
opt-in, and the per-page <strong>Hide the page title</strong> checkbox keeps working everywhere — it is the
control that matters on classic-editor pages, and it still wins on builder pages when the
global is enabled.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/builder-pages-hide-theme-title#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>Every major builder ecosystem treats the theme title as opt-out-by-design on built pages:
Elementor's Canvas/Full-Width templates and starter kits ship with the title hidden, Divi and
the Astra/GeneratePress/Kadence companion themes expose a per-page "Disable Title" that their
starter sites pre-check, and Bricks/Oxygen render no theme title at all. The common thread is
that a builder page is a designed composition whose hero supplies its own H1 — injecting the
theme title above it duplicates headings and defaces the design, which is exactly what happened
to the existing sites. The one-H1 accessibility goal is better enforced by the ship-gate
checklist (the hero must contain the H1) than by forcing a title bar onto every built page.
Flipping one default restored every existing site without editing a single page, and no
capability was lost: both the global and per-page controls survive.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Page builder" term="Page builder"/>
        <category label="Accessibility" term="Accessibility"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[3D Gallery Motion: a nested multi-picker (via a NEW key) plus CSS-sticky scroll pinning]]></title>
        <id>https://unysonplus.github.io/decisions/gallery-3d-motion-picker-and-scroll-pinning</id>
        <link href="https://unysonplus.github.io/decisions/gallery-3d-motion-picker-and-scroll-pinning"/>
        <updated>2026-07-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Scroll-scrub barely registered when the gallery scrolled past like normal content, and the flat Motion group mixed mode-specific sliders with no hint of what applied when. The gallery gained "Pin while scrubbing" (a pure CSS-sticky pin, no scroll hijacking) and its Motion options became a multi-picker nested inside the Design picker — stored under a NEW key so the old scalar `drive` never meets an array and the "blank error modal" migration class is unreachable.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> for the 3D Gallery's Scroll-scrub motion, should the gallery <em>pin</em> on screen while
the visitor's scroll drives the animation (instead of scrolling past with only a sliver of movement
visible)? And should the Motion options become a multi-picker so each mode reveals only its own
settings?</p>
<!-- -->
<p><strong>Context:</strong> with Motion = Scroll-scrub, the gallery owned only the ~1–1.5 viewport-heights of
scroll it took to pass through the viewport — a ring turned ~90° and was gone, so visitors barely
registered that they were driving it. At the same time the Motion group had grown flat and
confusing: Loop Duration applies only to Auto-rotate, the incoming pin controls only to
Scroll-scrub, On Hover only to Auto — all stacked together with no hint of what applied when. And
the framework carries a documented scar here: converting a saved <em>scalar</em> option to a multi-picker
<em>array</em> previously corrupted the options-render AJAX and produced the blank "error:" modal on
pre-existing items (the easing-options incident).</p>
<p><strong>Options considered:</strong></p>
<ul>
<li class=""><strong>Pinning: scroll-hijack JS vs CSS <code>position: sticky</code>.</strong> Hijacking (wheel interception) gives
exact control but breaks native momentum, accessibility and mobile feel. CSS sticky — wrapper
stretched to <code>stage + N × 100vh</code>, stage stuck viewport-centred inside it — keeps native scrolling,
degrades to the old pass-through when unsupported, and needs only a progress-formula change in the
shared driver.</li>
<li class=""><strong>Motion UI: flat group with better labels vs a nested multi-picker.</strong> Labels don't scale — every
new mode multiplies the irrelevant-slider pile. A multi-picker reveals per-mode settings, but had
never been nested <em>inside</em> another multi-picker (the Design picker) before; verification showed
both the PHP (render + value round-trip) and the JS (instance-scoped <code>&gt; .choice-group</code> selectors,
<code>pickerDescriptor.el === optionDescriptor.el</code> filtering, lazy <code>data-options-template</code> injection
re-firing <code>fw:options:init</code>) handle nesting, provided choice keys never collide across levels.</li>
<li class=""><strong>Migration: reuse the <code>drive</code> key with JS+PHP migrators vs a NEW <code>motion</code> key.</strong> Reusing the key
re-enters the easing-options failure class and demands a custom builder-item JS migrator. A new
key means existing saves simply have <em>nothing</em> there — the picker renders its default and the
illegal-string-offset path is unreachable; the views fall back to the legacy flat keys so saved
galleries keep behaving identically on the front end.</li>
</ul>
<p><strong>Decision:</strong> both. Scroll-scrub gained <strong>Pin while scrubbing</strong> (default on) + <strong>Scroll Length
(viewports)</strong>, implemented as CSS sticky in the shared layer so every design — current and future —
inherits it. Motion became an inline multi-picker (Auto/Continuous → loop/direction/hover;
Scroll-scrub → pin/length/direction; Static → nothing) stored under the <strong>new <code>motion</code> key</strong>, with
Drag to spin + Drag Momentum kept outside the picker because they layer over every mode.</p>
<p><strong>Why:</strong> pinning is what makes scroll-scrub legible — the visitor holds the scene and <em>feels</em> the
scroll steering it, which is the entire point of the mode. The nested picker keeps the Motion UI
truthful (you only see what the chosen mode uses) and scales as designs and modes accumulate. The
new-key strategy was the decisive safety call: it converts a historically dangerous value-shape
migration into a no-op, at the negligible cost that pre-picker saves show the default Motion in the
modal until re-saved. Doing it days after the gallery shipped — before real saves accumulate — made
this the cheapest moment the change will ever have.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Option types" term="Option types"/>
        <category label="Architecture" term="Architecture"/>
        <category label="Page builder" term="Page builder"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why gallery card links live ON the image (attachment meta), and post-driven galleries came free]]></title>
        <id>https://unysonplus.github.io/decisions/gallery-card-links-live-on-the-attachment</id>
        <link href="https://unysonplus.github.io/decisions/gallery-card-links-live-on-the-attachment"/>
        <updated>2026-07-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The 3D Gallery needed an "Open Link" card click, but its multi-upload Images option stores bare attachment IDs with no per-item fields. The URL became a "Link URL" field on the Media Library attachment itself (meta _upw_link_url, added via attachment_fields_to_edit) rather than an order-matched URL list — and a new Post Type source makes cards from featured images that link to their posts automatically.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> cards needed to link somewhere on click — a per-image URL, and ideally "each
portfolio card links to its portfolio page." Where does a per-image URL live when the Images option
is a <code>multi-upload</code> that stores only attachment IDs?</p>
<!-- -->
<p><strong>Context:</strong> <code>multi-upload</code> has no per-item fields, and the gallery's whole content model reads
per-image data (alt, caption, title, description) from the Media Library. The click action had only
Lightbox / Do Nothing.</p>
<p><strong>Options considered:</strong></p>
<ul>
<li class=""><strong>An order-matched URL list</strong> (addable rows beside the images) — breaks silently the moment images
are reordered or removed; two lists pretending to be one.</li>
<li class=""><strong>Abusing the attachment Description field as a URL</strong> — collides with Caption Source, which already
offers Description as caption text.</li>
<li class=""><strong>Restructuring Images into an addable-box of {image, link}</strong> — per-item fields, but a breaking
value-shape change to the images option and a worse multi-image picking UX.</li>
<li class=""><strong>A "Link URL" field on the attachment itself</strong> — <code>attachment_fields_to_edit</code>/<code>_save</code> puts a field
in the media modal under Alt/Caption, stored as <code>_upw_link_url</code> meta.</li>
<li class=""><strong>For the portfolio case specifically: a Post Type source</strong> — cards built from a post type's
featured images, link = permalink, no per-image config at all.</li>
</ul>
<p><strong>Decision:</strong> both ends. The link lives <strong>on the attachment</strong> (<code>_upw_link_url</code> via the media-modal
field), read when On Card Click = Open Link, with external hosts auto-opening a new tab (the
<code>tag_list</code> convention) and alt falling back caption → title so linked cards always have an
accessible name. And the Content tab gained a <strong>Source picker</strong> (Media Library | Post Type — a NEW
key, so old saves are untouched and <code>images</code> keeps its original path): the Post Type choice lists
public types with featured-image support dynamically, so Portfolio appears when that extension is
active with no hard dependency, and each card links to its post automatically.</p>
<p><strong>Why:</strong> the URL belongs to the <em>image</em>, in the same place its caption and alt already live — it
follows the image wherever it is reused, survives reordering, and is edited through the builder's
normal "Edit image" flow (the media-replaceability rule). And the strongest use case for per-card
links — "my portfolio, linked" — is better served by not entering URLs at all: the Post Type source
derives cards <em>and</em> links from content that already exists, and stays fresh as posts are published.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Option types" term="Option types"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Where does a new gallery design belong — the core Gallery or the 3D Gallery?]]></title>
        <id>https://unysonplus.github.io/decisions/gallery-vs-3d-gallery-boundary</id>
        <link href="https://unysonplus.github.io/decisions/gallery-vs-3d-gallery-boundary"/>
        <updated>2026-07-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A scattered-tabletop photo effect (fly-in, settle, reshuffle) could have been a 21st layout of the core Gallery or a new design of the Animation Engine's 3D Gallery. It became the 3D Gallery's first Stack & Scatter design, and the choice produced a reusable boundary rule — arrangement + lightbox belongs to the core Gallery; choreography-first showcases belong to the 3D Gallery.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> a "Photo Scatter" effect — photos scattered flat on a tabletop, gliding in from
the edges, dwelling, then reshuffling to the next set — is unmistakably a <em>gallery</em>. The core
<code>[gallery]</code> element already has 20+ layout designs. So does a new gallery design belong there, or
in the Animation Engine's <code>[gallery_3d]</code>?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://unysonplus.github.io/decisions/gallery-vs-3d-gallery-boundary#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The effect came from replicating a cinematic product site whose search moment scatters file cards
across a photographic desk. Nothing in the framework matched: the core Gallery's designs (grid,
masonry, carousel, coverflow) are arrangements; sticky-stack's <code>messy</code> is a pinned scroll stack;
<code>image_box</code>'s polaroid frame is a static card. The scatter is defined by its <em>choreography</em> —
stagger-in, rest, sweep-out, cycle.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://unysonplus.github.io/decisions/gallery-vs-3d-gallery-boundary#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ul>
<li class=""><strong>A 21st core Gallery design</strong> — "it's where users look for galleries", always available
(engine-off). But the core Gallery has no animation runtime: it would need the rAF driver,
drag plumbing and reduced-motion handling duplicated from the 3D Gallery — exactly the
parallel-machinery smell the CATALOG distinctness rule exists to prevent. And its designs would
then split into two kinds (static vs choreographed) inside one picker.</li>
<li class=""><strong>A new design of <code>[gallery_3d]</code></strong> <em>(chosen)</em> — the 3D Gallery is the choreography line: a
categorized Design picker where each design is a self-contained motion system over one shared
rAF driver, with drag physics, per-card Box Styles, captions/lightbox/link plumbing, and
"Use as Section Background". Its picker taxonomy had already reserved a <strong>Stack &amp; Scatter</strong>
category; Photo Scatter is its first occupant.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://unysonplus.github.io/decisions/gallery-vs-3d-gallery-boundary#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><code>photo_scatter</code> shipped as a <code>[gallery_3d]</code> design (engine 1.2.36), not a core Gallery layout.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://unysonplus.github.io/decisions/gallery-vs-3d-gallery-boundary#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>The test is one question: "is it still a design if nothing moves?"</strong> A static scatter is just
overlapping polaroids — the motion IS the design. Anything failing that test is choreography and
belongs to the 3D Gallery; anything passing it (a grid, a justified layout) is an arrangement
and belongs to the core Gallery.</li>
<li class=""><strong>Machinery gravity.</strong> The 3D Gallery already owns everything the scatter needs — server-side
card rendering (links/captions ride each card), the generation-guarded rAF runtime, Section
Background mode, reduced-motion fallback. Zero duplication.</li>
<li class=""><strong>Engine-gating is product separation, not a loss.</strong> Cinematic showcases are the Animation
Engine's line; the core Gallery stays lean and always-available. Degradation is graceful either
way (reduced motion = a static scatter; content is never lost).</li>
<li class="">The boundary rule keeps future proposals from re-litigating this: file by the one-question test.</li>
</ul>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Architecture" term="Architecture"/>
        <category label="Shortcodes" term="Shortcodes"/>
    </entry>
</feed>