<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://docs.unysonplus.com/decisions</id>
    <title>Unyson+ Blog</title>
    <updated>2026-08-25T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://docs.unysonplus.com/decisions"/>
    <subtitle>Unyson+ Blog</subtitle>
    <icon>https://docs.unysonplus.com/img/unysonplus-logo.jpg</icon>
    <entry>
        <title type="html"><![CDATA[Adding entrance animations on conversion — should the local AI drive it, or the deterministic converter?]]></title>
        <id>https://docs.unysonplus.com/decisions/entrance-animations-deterministic-with-ai-refine</id>
        <link href="https://docs.unysonplus.com/decisions/entrance-animations-deterministic-with-ai-refine"/>
        <updated>2026-08-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[We wanted a Convert-panel option that gives a converted page tasteful, sequential reveal-on-scroll animations 'with the help of the local AI'. The question was where the intelligence lives. We decided the DETERMINISTIC converter owns the sequencing (walk each section, stagger a role-appropriate effect per element, reset per band) because it is fast, free, offline and repeatable, and the local AI is an OPTIONAL refinement that only re-picks the effect/timing per element — so the feature always works even when the AI backend is off or slow, and never breaks a conversion.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> We're adding an "Add entrance animations" checkbox to the Site Converter that
gives a converted page sequential reveal-on-scroll motion. The user framed it as "use the
deterministic <strong>with the help of the local AI</strong>." Where should the intelligence actually live —
does the AI decide the animations, or does the deterministic converter, with the AI as a helper?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/entrance-animations-deterministic-with-ai-refine#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The converter already ships a complete entrance-animation surface: every content shortcode
registers the <strong>Entrance Animation</strong> option (Animate.css effect + a shared <code>animation_settings</code>
panel with trigger / delay / stagger / easing), and the mapper already knows every node's <em>role</em>
(heading, text, button, image, card grid, pricing table). The render reader honours the modern
<code>animation.effect</code> + <code>animation_settings</code> shape and hides each element until it scrolls into view.</p>
<p>So the raw capability to sequence reveals is entirely mechanical — the converter can walk the built
node tree and assign effects itself. The only thing that genuinely benefits from <em>taste</em> is the
<strong>choice</strong> of effect per element (should the hero CTA slide up last for emphasis? should a
testimonial row cascade left-to-right?). The local AI (Ollama / Claude Code / an API key, via the
capture service) is good at exactly that semantic judgement — but it is slower, varies run to run,
and is only present when the user has a backend configured and the capture service running.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/entrance-animations-deterministic-with-ai-refine#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 — AI drives the animations.</strong> The AI decides which elements animate and how, and the
converter just applies its output. Rejected: it makes a core feature depend on a slow, optional,
non-deterministic component. With the AI off (the common case for an offline <code>.zip</code> upload), the
feature would do nothing; with it on, two runs of the same page could differ, and a bad/empty AI
response could leave elements stuck hidden (<code>sc-anim-pending</code> with no effect written).</li>
<li class=""><strong>B — Deterministic only.</strong> A role → effect rule table with a per-section stagger. Fast, free,
offline, 100% repeatable, and good for ~90% of pages. But it can't tell that <em>this particular</em>
element deserves a different treatment.</li>
<li class=""><strong>C — Deterministic base + AI as an optional refinement layer.</strong> The deterministic pass always
runs and sets a tasteful base (fade-up for content, soft fade for media, stagger reset per band).
When "Refine with AI" is additionally ticked <em>and</em> a backend is reachable, the AI only <strong>re-picks
the effect/timing per element</strong> on top of that base, constrained to the Animate.css entrance
vocabulary, with any failure or absence silently falling back to the deterministic result.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/entrance-animations-deterministic-with-ai-refine#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>C.</strong> The deterministic converter owns the sequencing; the local AI is an opt-in refinement that
only adjusts the per-element effect and delay. The "Add entrance animations" checkbox runs the
deterministic pass alone; a secondary "Refine with AI" sub-option layers the AI on top when it's
available.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/entrance-animations-deterministic-with-ai-refine#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Robustness first.</strong> A conversion feature must never depend on an optional, variable component to
function. Making the deterministic pass the base means the feature works for every path — the
offline <code>.zip</code> upload included — and the AI can never <em>break</em> a conversion, only <em>improve</em> it.</li>
<li class=""><strong>The AI is applied where it actually adds value.</strong> Sequencing and staggering are mechanical;
effect <em>taste</em> is not. Letting the AI touch only the effect/timing (not whether an element
animates, not the hide-until-play plumbing) keeps its blast radius tiny and its output easy to
validate against a fixed vocabulary.</li>
<li class=""><strong>Consistency with the converter's whole philosophy.</strong> This mirrors the standing rule that the
deterministic path is the source of truth and the AI is a helper on top (the same shape as the
always-on local micro-pass that only <em>names</em> sections while Claude/deterministic own the
structural mapping). Determinism stays the default; intelligence is additive.</li>
<li class=""><strong>Graceful degradation is explicit.</strong> "Refine with AI" is only offered when a backend + the
capture service are present, and any error returns the deterministic base — so there is no state
in which turning it on makes the result worse than leaving it off.</li>
</ul>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Architecture" term="Architecture"/>
        <category label="Animation" term="Animation"/>
        <category label="JavaScript" term="JavaScript"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Which container owns a card's box on conversion — the icon_box, or its column?]]></title>
        <id>https://docs.unysonplus.com/decisions/box-preset-owner-by-decomposition-count</id>
        <link href="https://docs.unysonplus.com/decisions/box-preset-owner-by-decomposition-count"/>
        <updated>2026-08-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A source feature card is a bordered box holding an icon, a heading and some content. When the converter turns it into native shortcodes, who should carry the box — the icon_box's own Box Style, or the column's Border Preset? We decide by the decomposition the converter already computes: a card that collapses to ONE icon_box lets the icon_box own the box; a card that becomes TWO OR MORE shortcodes (icon_box + a list/button) lets the column own it, so the box wraps everything. And it's always a Box Preset, never a one-off CSS class.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> A source "feature card" is a bordered panel (fill + border + radius + padding) holding an
icon, a title and some content. When the deterministic Site Converter reproduces it natively, <strong>which
container should carry the box</strong> — the <code>icon_box</code>'s own <strong>Box Style</strong>, or the enclosing column's <strong>Border
Preset</strong>? And should the box be a real <strong>Box Preset</strong> or a one-off scoped CSS class?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/box-preset-owner-by-decomposition-count#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Two bugs surfaced it. First, a converted section-3 card showed the box dropped into the icon_box's <strong>Custom
CSS</strong> with a bare <code>box</code> utility class — no Box Preset selected — so it wasn't reusable, wasn't editable as a
preset, and didn't travel like one. Second, and more structural: once a card that contains a <strong>nested list</strong>
is decomposed (icon + heading + description → <code>icon_box</code>, and the list → a native <code>feature_list</code>), a box put
on the icon_box alone would only wrap the header and leave the list outside the card.</p>
<p>So there are really two questions: <strong>who owns the box</strong>, and <strong>is it a preset or a class</strong>. The owner
question needs a rule that's deterministic (same source → same output) and doesn't re-sniff content the
converter has already classified.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/box-preset-owner-by-decomposition-count#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class="">
<p><strong>Decide by content type.</strong> Inspect the card: icon + title + a single paragraph → icon_box owns it;
contains a list or a button → column owns it.</p>
<ul>
<li class=""><em>For:</em> Matches intuition directly.</li>
<li class=""><em>Against:</em> Re-inspects and re-classifies content the converter already turned into shortcodes; a second
heuristic to keep in sync with the recognizers, and fragile at the edges ("is this a list?").</li>
</ul>
</li>
<li class="">
<p><strong>Decide by decomposition count.</strong> The converter already knows how many shortcodes a card cell becomes.
If it collapses to <strong>one</strong> <code>icon_box</code>, the icon_box owns the box (<code>box_style</code>). If it becomes <strong>two or
more</strong> shortcodes (icon_box + feature_list / button / …), the <strong>column</strong> owns it (<code>border_preset</code> on the
inner wrapper), so the box wraps all of them. N boxed icon_boxes in one column → each owns its own box.</p>
<ul>
<li class=""><em>For:</em> Uses a signal the pipeline already computes — zero extra heuristic, and it's exactly the
content-type rule expressed structurally (a list/button is <em>why</em> the card became 2+ shortcodes).</li>
<li class=""><em>Against:</em> None material; it is the same decision, sourced from data instead of a re-scan.</li>
</ul>
</li>
<li class="">
<p><strong>Always the column.</strong> Put every card's box on the column.</p>
<ul>
<li class=""><em>Against:</em> A self-contained single icon_box is more portable when the box is its own (<code>box_style</code>
travels with the element on export); forcing it onto the column loses that and over-nests.</li>
</ul>
</li>
</ol>
<p>Orthogonally, for the skin itself: a <strong>Box Preset</strong> (registered once, emitted in Theme Settings, referenced
by <code>box_style</code> / <code>border_preset</code>) versus a <strong>one-off scoped box class + Custom CSS</strong>. The preset is
reusable, editable in one place, and portable; the class is a per-element dead-end.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/box-preset-owner-by-decomposition-count#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Own the box by decomposition count, always as a Box Preset.</strong> One <code>icon_box</code> → the icon_box owns it via
<code>box_style = boxp-&lt;slug&gt;</code>. Two or more shortcodes → the column owns it via <code>border_preset = boxp-&lt;slug&gt;</code> on
the inner wrapper, wrapping every child. Multiple boxed icon_boxes in a column → each owns its own preset.
In every path the skin is <strong>registered</strong> (<code>register_box_preset</code>, keyed by a hash of the normalized skin so
identical cards share one preset) and emitted as a real Box Preset — never a bare <code>box</code> class + Custom CSS.</p>
<p>This also corrected a latent bug in the other card paths (counter grids, stacked cards, nested grids): they
were matching against <em>pre-existing</em> theme presets only (<code>box_preset_slug</code> → the box lookup), so a freshly
captured skin fell through to one-off CSS. They now <code>register_box_preset</code> like the primary path, so every
captured card box becomes a real, deduped preset.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/box-preset-owner-by-decomposition-count#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The decomposition count <strong>is</strong> the content-type signal, already computed — a card becomes 2+ shortcodes
precisely <em>because</em> it holds a list or a button beyond what one icon_box can represent. Keying the owner off
that count means one source of truth and no second heuristic drifting out of sync with the recognizers. The
box must enclose everything it enclosed in the source, so a multi-shortcode card has to be boxed at the
column; a lone icon_box is most portable owning its own box. And registering a real Box Preset — rather than
stamping a one-off class — is what makes the converted box behave like a designed box: reusable across the
cards that share it, editable in Theme Settings, and portable on template export. The bug where fresh skins
silently degraded to Custom CSS was the same mistake in four other places (match-only lookup instead of
register), now fixed uniformly.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Architecture" term="Architecture"/>
        <category label="Shortcodes" term="Shortcodes"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[A converter option is “unmapped” — is that a bug to fix or a default to keep?]]></title>
        <id>https://docs.unysonplus.com/decisions/converter-gaps-vs-intentional-defaults</id>
        <link href="https://docs.unysonplus.com/decisions/converter-gaps-vs-intentional-defaults"/>
        <updated>2026-08-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The element-mapping tables showed lots of options as ‘unmapped’, which read as failure. We decided the goal is FAITHFUL REPRODUCTION, not maxing the native %: an option with no source signal is correctly left at default, so we split ‘unmapped’ into a GAP (a real source signal the converter doesn't derive yet — a to-do) versus DEFAULT (no reliable signal or a UnysonPlus-specific choice — correct as-is), and we improve the converter for gaps rather than fabricating mappings.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> The <a class="" href="https://docs.unysonplus.com/ai-dev-kit/element-mapping">Element Mapping</a> tables classify every shortcode
option as native / via-CSS / unmapped. Accordion showed <strong>18 unmapped</strong> — which <em>looks</em> like the
converter is failing. Is a high unmapped count a problem to drive to zero? Should we hand-author
“pre-mapping” data, or do we need a pile of real sites to map everything properly?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/converter-gaps-vs-intentional-defaults#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The element-mapping data is a <strong>mirror of what the converter code derives</strong> — it's generated by
classifying each option against the <code>n_*</code> block builders. So “unmapped” means <em>“left at the shortcode
default.”</em> Crucially, that is often the <strong>correct, faithful</strong> result: if a source has no signal for an
option (e.g. an accordion that never specifies an icon animation), the default <em>is</em> the accurate
reproduction. A conversion's job is faithful reproduction, not maximizing a coverage percentage.</p>
<p>But the single “unmapped” bucket hid an important distinction. Of accordion's 18: about half are
options a real source <strong>does</strong> express and the converter simply doesn't read yet
(<code>is_open</code> from <code>aria-expanded</code>, single/multiple-open from <code>data-bs-parent</code>, header background/alignment
from computed styles, <code>faq_schema</code> from a <code>FAQPage</code> JSON-LD block, …). The other half have <strong>no reliable
signal</strong> or are UnysonPlus-specific (<code>animation</code> = an Animation Engine effect, <code>css_class</code>, our preset
<code>style</code>s, <code>numbering</code>). Lumping both as “unmapped” made the table read as failure and gave no work-list.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/converter-gaps-vs-intentional-defaults#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 — Drive unmapped to zero.</strong> Wrong target: it would force fabricated or unreliable mappings for
options no source expresses, hurting fidelity to chase a vanity metric.</li>
<li class=""><strong>B — Hand-author “pre-mapping” data.</strong> Rejected: the mapping doc mirrors the converter; authoring
mappings the code doesn't perform makes the doc <em>lie</em>. The lever is the converter, not the doc.</li>
<li class=""><strong>C — Split “unmapped” into GAP vs DEFAULT, improve the converter for gaps only.</strong> A <strong>gap</strong> is a
derivable option with a real source signal the converter doesn't read yet (a genuine to-do); a
<strong>default</strong> is intentional — no reliable signal, or a UnysonPlus-specific choice — and is <em>correct</em>
left unmapped.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/converter-gaps-vs-intentional-defaults#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Option C.</strong> We added a fourth design status — <strong>⚠️ Gap</strong> — alongside ✅ native / 🟡 via-CSS / ⚪ default,
and the element-mapping generator now shows, per shortcode, “N native, M gaps (derivable, not yet), K
default.” Each gap row names the <strong>source signal</strong> the converter could read, so the table becomes a
prioritized work-list instead of a wall of ⚪. We then <strong>improve the converter</strong> to close gaps — never
by fabricating mapping data.</p>
<p>On real sites: they're for <strong>validation and prioritization</strong>, not a prerequisite. Common element
patterns (Bootstrap collapse, <code>&lt;details&gt;</code>, FAQ/Elementor/Divi exports, schema.org) are well-known and
detectable, so gaps can be closed from the patterns; a small curated set of real captures then confirms
the signals and catches edge cases. We do <strong>not</strong> need a large corpus hand-mapped by the user.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/converter-gaps-vs-intentional-defaults#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Faithful reproduction is the real goal.</strong> An option with no source signal is <em>correctly</em> at its
default; a metric that punishes that is measuring the wrong thing.</li>
<li class=""><strong>Honesty + actionability.</strong> Separating gaps from defaults turns an alarming “18 unmapped” into “~11
real gaps + 7 intentional defaults”, and every gap carries the signal to derive — a concrete to-do.</li>
<li class=""><strong>The doc must not diverge from the code.</strong> Summaries and mappings live in / reflect the source;
hand-authored mappings the converter doesn't perform would reintroduce drift.</li>
<li class=""><strong>Effort goes where it pays.</strong> Prioritize gaps on commonly-converted elements; leave genuine defaults
alone.</li>
</ul>
<p>Status: <strong>Accepted</strong> — the ⚠️ Gap status ships with the element-mapping generator; accordion is the pilot
reclassification, with the remaining shortcodes and the converter improvements to follow.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Documentation" term="Documentation"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Should we document every PHP function — and how?]]></title>
        <id>https://docs.unysonplus.com/decisions/php-api-reference-generated-not-exhaustive</id>
        <link href="https://docs.unysonplus.com/decisions/php-api-reference-generated-not-exhaustive"/>
        <updated>2026-08-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The framework has ~845 public helper functions and ~350 hooks. Do we hand-write a page per function? We decided NO — internal implementation is documented by its docblock in the code; the docs site publishes a GENERATED reference of the public contract (all prefixed helpers + all framework-owned hooks), extracted from PHPDoc + a token scan so it never drifts, organized by subsystem, with hooks given equal billing to functions.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> UnysonPlus has a large PHP surface — ~845 public-prefixed helper functions and ~350
actions/filters. Is it wise to document <em>all</em> of it, the way people mean when they say "document every
function"? And if so, how — hand-written pages, or generated?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/php-api-reference-generated-not-exhaustive#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Documenting functions inline and publishing a browsable reference is a standard, named practice:
<strong>docblocks</strong> (PHPDoc / PSR-5/19) parsed by <strong>doc generators</strong> (phpDocumentor, Doxygen; JSDoc, Javadoc,
rustdoc elsewhere). WordPress itself ships the <strong>Code Reference</strong> auto-generated from PHPDoc via
<code>phpdoc-parser</code>. In the <strong>Diátaxis</strong> documentation model (Tutorials / How-to / <strong>Reference</strong> /
Explanation), a function catalogue is squarely <em>Reference</em> — a different axis from our existing how-to
Manual, The Theme, and AI Dev Kit sections.</p>
<p>The temptation is to generate a page per symbol for all ~845 functions. That's the classic trap:
exhaustive auto-references become dumping grounds nobody reads, they go stale the moment a signature
changes, and 90% of the entries are internal helpers meaningless without their surrounding code.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/php-api-reference-generated-not-exhaustive#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 — Hand-write a reference.</strong> Highest quality per page, but unmaintainable at this scale and
guaranteed to drift from the code. Rejected outright.</li>
<li class=""><strong>B — Auto-generate a page per function for everything (WP-style).</strong> Comprehensive, but noisy and
low-signal; buries the ~5% that authors actually consume (public helpers + hooks) under internal
plumbing. This is the "dumping ground" outcome.</li>
<li class=""><strong>C — Generate a reference for the public <em>contract</em> only, organized by subsystem.</strong> The public
helper functions (the <code>fw_</code> / <code>unysonplus_</code> / <code>upw_</code> / <code>sc_</code> / <code>fw_ext_</code> families) and — with equal
billing — <strong>every framework-owned hook</strong>, since hooks are the real extensibility surface a framework
lives or dies by. Internal closures and WordPress-core hooks are excluded on purpose.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/php-api-reference-generated-not-exhaustive#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Option C.</strong> The docs site gets a dedicated, generated <strong>API Reference</strong> section
(<code>/reference</code>), split into <strong>Functions</strong> and <strong>Hooks</strong>, each grouped by subsystem. It's produced by a
two-step pipeline — <code>scripts/extract-php-api.php</code> (a <code>token_get_all</code> scan that pulls signatures,
docblocks, <code>function_exists()</code>-guarded/pluggable flags, and <code>do_action</code>/<code>apply_filters</code> sites) →
<code>scripts/gen-php-api.mjs</code> (emits the grouped Markdown). The <code>.md</code> is <strong>generated, never hand-authored</strong>:
you edit the framework docblocks and re-run the two commands.</p>
<p>Implementation stance: <strong>the code documents the implementation (via docblocks); the site documents the
contract people depend on (public API + hooks).</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/php-api-reference-generated-not-exhaustive#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Signal over noise.</strong> Authors search for "which function formats an upload path" or "which filter
lets me change X" — the public helpers and hooks. Those are exactly what's published; the internal
90% stays where it belongs (in the source, next to the code it explains).</li>
<li class=""><strong>It can't drift.</strong> Because it's extracted from the shipped source every build, the reference always
matches the code — the failure mode that kills hand-written and even per-symbol-generated references.</li>
<li class=""><strong>Hooks are first-class.</strong> For a <em>framework</em>, the hook catalogue is the single highest-value
reference — it's the supported way to extend without forking. Giving it a peer section to Functions
reflects that.</li>
<li class=""><strong>It slots into the existing IA.</strong> As a Diátaxis <em>Reference</em> section it sits cleanly beside the
how-to Manual / The Theme / AI Dev Kit rather than competing with them.</li>
</ul>
<p>Status: <strong>Accepted</strong> — the generated section ships with the functions + hooks pipeline; future
framework changes flow through by re-running the extractor + generator.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Documentation" term="Documentation"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Testimonials extra stat lines — a repeatable label+value, a single pair, or freeform text?]]></title>
        <id>https://docs.unysonplus.com/decisions/testimonials-extra-texts-label-value</id>
        <link href="https://docs.unysonplus.com/decisions/testimonials-extra-texts-label-value"/>
        <updated>2026-08-24T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Testimonial cards often carry a proof-stat footer (a muted “Total savings” label over a bold “$14,200” figure) below the name/role. The shortcode had no field for it, so conversion crammed it into the author's role line. The question: what shape should the new Extra Texts field take — a repeatable list of label+value rows, exactly one pair, or a freeform rich-text block? We choose a repeatable label+value, because it matches the real pattern, renders consistently, and maps cleanly from source.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> A testimonial card frequently ends with a small <strong>stat/result footer</strong> — a muted label
("Total savings") over an emphasized value ("$14,200", or a lone "40% more closes"). The <code>testimonials</code>
shortcode had no field for it. When we add one — an <strong>Extra Texts</strong> field — what value shape should it take:
a <strong>repeatable list of <code>{label, value}</code> rows</strong>, exactly <strong>one label+value pair</strong>, or a <strong>freeform rich-text</strong>
footer?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/testimonials-extra-texts-label-value#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The trigger was a conversion of a mortgage site whose review cards each had a bordered footer: "Total
savings" in muted small caps, then the figure in bold brand green. The <code>testimonials</code> shortcode captures
rating, quote, avatar, name, role and location — but nothing for that footer, so the deterministic Site
Converter was <strong>folding the stat into the author's role line</strong> (<code>VP Sales, Method Homes · Total savings $14,200</code>). That's lossy and wrong: the stat is a distinct, emphasized element, not part of the role.</p>
<p>So the card needs a real field. The open question was its shape, which drives three things at once: how
authors enter it, how it renders, and how faithfully the converter can populate it from a source card.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/testimonials-extra-texts-label-value#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class="">
<p><strong>Repeatable <code>{label, value}</code> rows.</strong> A small addable list per testimonial; each row is an optional
muted label with an emphasized value. Renders as a footer block (label over value). Placed via a new
"Extra Texts" slot in Card Rows.</p>
<ul>
<li class=""><em>For:</em> Matches the observed pattern exactly (label + figure). Handles the common single-stat case
<strong>and</strong> cards with two stats. The muted-label / bold-value styling is intrinsic, so every card reads
consistently. Converter mapping is a clean split: leaf label → <code>label</code>, figure → <code>value</code>.</li>
<li class=""><em>Against:</em> Slightly more structure than a single pair; two fields per row.</li>
</ul>
</li>
<li class="">
<p><strong>A single <code>{label, value}</code> pair.</strong> Exactly one stat per testimonial.</p>
<ul>
<li class=""><em>For:</em> Simplest field.</li>
<li class=""><em>Against:</em> Can't represent a card with two stats, and the moment one design needs a second line we'd
be back here widening it. A repeatable is barely more code and never boxes us in.</li>
</ul>
</li>
<li class="">
<p><strong>Freeform rich text.</strong> One HTML/text field for the whole footer.</p>
<ul>
<li class=""><em>For:</em> Maximum authoring flexibility.</li>
<li class=""><em>Against:</em> Loses the consistent muted-label / bold-value typography (each author re-styles by hand),
and it's the hardest for the converter to map — it would have to guess where the label ends and the
value begins, or dump the raw run and lose the structure. Freeform trades the one thing that makes the
stat read as a stat (its two-part styling) for flexibility we don't need.</li>
</ul>
</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/testimonials-extra-texts-label-value#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>A repeatable list of <code>{label, value}</code> rows</strong>, surfaced as an <strong>"Extra Texts" slot in Card Rows</strong> (defaults
to a divider + the rows at the card footer, so it can be positioned like any other slot). Each row renders a
muted label above an emphasized value; a value-only row (blank label) is allowed. The slot renders only when
a row has content, so plain testimonials are unaffected. The Site Converter maps a source card's bordered
footer stat into this field — preferring two distinct leaf texts (label + value), else splitting a single
"Label $Figure" run on the figure — and pins Card Rows to surface it, instead of cramming the stat into the
role line.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/testimonials-extra-texts-label-value#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The shape should follow the data, and the data is a <strong>label paired with a figure</strong> — sometimes one, sometimes
two. A repeatable <code>{label, value}</code> is the tightest fit: it captures exactly that structure, so the styling
(muted label / bold value) can be intrinsic and every card looks the same, and the converter maps it
deterministically instead of guessing. The single-pair option is a false economy — it saves nothing and
caps the feature the first time a design shows two stats. Freeform maximizes authoring freedom but throws
away the consistent typography and the clean converter mapping, which is the whole point of making it a
first-class field rather than leaving it as prose. Modeling it as a Card Rows slot keeps it consistent with
how every other card-like shortcode composes, so it's positionable without a bespoke layout.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Shortcodes" term="Shortcodes"/>
        <category label="Conversion" term="Conversion"/>
        <category label="Option types" term="Option types"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How should the Site Converter derive Button Size presets from a source's buttons?]]></title>
        <id>https://docs.unysonplus.com/decisions/button-size-clustering-algorithm</id>
        <link href="https://docs.unysonplus.com/decisions/button-size-clustering-algorithm"/>
        <updated>2026-08-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The converter turns a source site's buttons into a small set of reusable Button Size presets (.btn-lg / .btn-md / …). The naïve version deduped by exact computed values, kept only the top 3 by font-size, and re-matched each button by tolerance — which dropped sizes, mis-ranked them, and left buttons unassigned (rendering as the thin .btn base). We moved to a cluster → rank-by-visual-size → frequency-aware-name pipeline, with a fixed-height→Min-Height mapping and a closest-match fallback.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> A source page has N buttons with slightly different measured skins. How do we distil them into a clean, faithful set of reusable <strong>Button Size presets</strong> (<code>.btn-lg</code>, <code>.btn-md</code>, …) that every button can be assigned to?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/button-size-clustering-algorithm#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The Site Converter derives Theme Settings from the source. For buttons it emits <strong>colour presets</strong> and <strong>size presets</strong>; each converted button then references a size class (<code>btn btn-primary btn-lg</code>). The size preset carries font-size, line-height, padding-x/y, radius and (new) <strong>min-height</strong>.</p>
<p>The first implementation:</p>
<ol>
<li class="">Deduped button skins by <strong>exact</strong> <code>fontSize|px|py|radius</code>.</li>
<li class="">Sorted by <strong>font-size</strong> descending.</li>
<li class="">Kept the <strong>top 3</strong> → named Large / Medium / Small.</li>
<li class="">At map time, re-matched each button to a preset by tolerance (<code>fs ±1, py ±3, px ±4</code>).</li>
</ol>
<p>Three failures showed up on a real site (modfii, 4 distinct button sizes):</p>
<ul>
<li class=""><strong>Dropped sizes.</strong> The top-3 cap discarded the 4th size. A button whose size was dropped matched nothing → <strong>no <code>.btn-{slug}</code> class → the thin <code>.btn</code> base</strong> (the "button stays thin" bug).</li>
<li class=""><strong>Noise as duplicates.</strong> Computed values are noisy (<code>43.99</code> vs <code>44px</code>, <code>31.98</code> vs <code>32px</code>), so one design became several near-identical presets, bloating the list and burning the top-3 budget.</li>
<li class=""><strong>Mis-ranking.</strong> Sorting by font-size alone put a <code>14px / h-11 (44px)</code> button <em>below</em> a <code>16px / h-9 (36px)</code> one, even though it's physically taller.</li>
<li class=""><strong>Fixed height lost.</strong> A source <code>h-11</code> (44px, zero vertical padding, flex-centred) was approximated as a guessed Padding-Y instead of a real height.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/button-size-clustering-algorithm#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class=""><strong>Just raise the cap + relax tolerances.</strong> Keeps the fragile re-match; noise still sprawls; ranking still wrong. Rejected — treats symptoms.</li>
<li class=""><strong>One preset per exact size, no cap.</strong> Fixes dropping but explodes the list on noisy sites (5–6 near-dupes). Rejected.</li>
<li class=""><strong>Cluster → rank-by-visual → frequency-name, keep all clusters, closest-match fallback.</strong> Chosen.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/button-size-clustering-algorithm#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>A four-part pipeline, identical in the PHP (<code>build_button_presets</code>) and JS (<code>deriveButtonPresets</code>) paths:</p>
<ol>
<li class=""><strong>Cluster</strong> near-identical skins within a tolerance (fs ±1, px/py/height ±3). The representative value per property is the <strong>mode</strong> (most-common exact value in the cluster), so preset numbers stay clean (<code>44px</code>, not <code>43.99px</code>). This collapses computed noise into one preset per real design.</li>
<li class=""><strong>Rank by visual size</strong> — <code>max(fixedHeight, fontBox + 2·paddingY)</code> — the height a reader actually perceives. Ties (same height, different font) break by <strong>font-size</strong>, then by <strong>frequency</strong>.</li>
<li class=""><strong>Frequency-aware naming</strong> — the most-used cluster is tagged the base <strong>"(Default)"</strong>, and it wins ranking ties, so the dominant CTA size reads as the primary.</li>
<li class=""><strong>Fixed height → Min Height</strong> — a source sized by <code>h-11</code> populates the preset's new <strong>Min Height</strong> field (the button centres content via inline-flex), the exact reproduction instead of guessed padding.</li>
</ol>
<p>Plus a <strong>closest-match fallback</strong> at assignment: if a button doesn't match a preset exactly, it takes the nearest (weighted to font-size). A real button is <strong>never</strong> left unassigned/thin.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/button-size-clustering-algorithm#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>Clustering is the key insight: the source's <em>intent</em> is a handful of button sizes, but its <em>computed output</em> is dozens of noisy measurements. Deduping on exact values confuses noise for design; clustering with a mode representative recovers the intent. Ranking by <strong>visual size</strong> (not font) matches how a human names Large/Medium/Small. Keeping <strong>every</strong> cluster + a <strong>closest-match fallback</strong> guarantees no button falls through to the unstyled base — the failure mode that caused the recurring "button stays thin" reports. And a dedicated <strong>Min Height</strong> option means a fixed-height button is reproduced exactly rather than approximated.</p>
<p>Deferred: <strong>responsive size layers</strong> (a button that changes size at breakpoints, <code>h-10 md:h-11</code>). That needs the size-preset option itself to become responsive (base/md/lg) plus media-query CSS generation — a separate feature, not a derivation change. The converter currently reproduces the desktop size.</p>
<p><em>Status: Accepted.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Shortcodes" term="Shortcodes"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Should the converter use the local AI to pick a shortcode's style, or stay deterministic?]]></title>
        <id>https://docs.unysonplus.com/decisions/deterministic-style-classification-not-ai</id>
        <link href="https://docs.unysonplus.com/decisions/deterministic-style-classification-not-ai"/>
        <updated>2026-08-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A FAQ accordion that was obviously a Separated card list converted as Flush. The tempting fix was to hand style classification to the local AI companion. We kept it deterministic instead — the signal is measurable in the source (per-item fill + radius + inter-item gap), the converter's contract is reproducibility, and the class-based fallback the detector was missing fixes it cleanly. The AI stays a last-resort verify pass for the genuinely ambiguous tail, which the capture service already wires as a hint.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> The deterministic converter classified a source FAQ accordion — clearly a <em>Separated</em> list of cards — as <em>Flush</em>. Should we fix the classifier, or let the local AI companion pick the closest style?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/deterministic-style-classification-not-ai#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The source items were Radix accordion cards: <code>&lt;div class="bg-card rounded-xl border border-border"&gt;</code> inside a <code>space-y-4</code> container. Unmistakably Separated (per-item fill + rounded corners + a border + vertical gaps). The converter emitted Flush.</p>
<p>Two real bugs, not a fuzzy judgment call:</p>
<ol>
<li class=""><strong>The detector read only computed styles</strong> (<code>data-sc-cs</code>). On a class-based / raw-HTML source with no stamped computed styles, <code>background-color</code>, <code>border-radius</code> and <code>border-width</code> all read empty → no fill, no edge.</li>
<li class=""><strong><code>space-y-4</code> sets margin-<em>top</em> on children</strong>, which neither the flex <code>gap</code> read nor the first item's <code>margin-bottom</code> can see → the inter-item gap read as 0.</li>
</ol>
<p>Result: <code>gap &lt; 4</code>, no bg, no edge → the Flush branch.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/deterministic-style-classification-not-ai#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class=""><strong>Hand style classification to the local AI companion.</strong> The capture service already builds an AI verify pass (<code>d._hint</code> carries the icon markup + item geometry), so extending it to "pick the closest accordion style" is easy.</li>
<li class=""><strong>Fix the deterministic classifier</strong> — read the Tailwind classes (<code>bg-*</code>, <code>rounded-*</code>, <code>border</code>, <code>space-y-*</code>/<code>gap-*</code>) as a fallback/complement to the computed reads.</li>
<li class=""><strong>Both</strong> — deterministic first, AI as a last-resort tie-breaker for the ambiguous tail.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/deterministic-style-classification-not-ai#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Deterministic first (option 2), with the AI kept as the existing last-resort verify pass (the tail of option 3). Not AI-primary.</strong></p>
<p>The classifier now falls back to the item card's + container's utility classes: a non-transparent <code>bg-*</code> ⇒ has-fill; <code>rounded-*</code> ⇒ radius (xl→12, 2xl→16…); <code>border</code>/<code>border-*</code> ⇒ an edge; <code>space-y-N</code>/<code>gap-N</code> on the container (or <code>mb-N</code> on the item) ⇒ the inter-item gap; <code>divide-y</code> ⇒ a flush divider list. The FAQ now classifies as Separated with <code>corner_radius: md</code> and <code>item_spacing: mb-[16px]</code>. Both the PHP and JS paths carry the fallback.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/deterministic-style-classification-not-ai#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Reproducibility is the converter's contract.</strong> Same source → same output, every run. AI style-picking is non-deterministic; the same accordion could map to different styles across runs, which breaks the one guarantee the deterministic path exists to provide.</li>
<li class=""><strong>The signal is measurable, not ambiguous.</strong> <code>bg-card rounded-xl border</code> in a <code>space-y-4</code> container <em>is</em> Separated — the CSS/classes encode it exactly. Reaching for an LLM to read a class list is a sledgehammer where a <code>strpos</code> suffices, and it adds a per-widget round-trip plus cost.</li>
<li class=""><strong>The converter must work with the AI off.</strong> The local companion auto-detects a backend (API key → <code>claude</code> CLI → off); a core mapping decision can't depend on a backend that may be absent (headless/cron runs especially).</li>
<li class=""><strong>AI earns its place on the genuine tail.</strong> A bespoke accordion with no clear style signals is where a verify pass helps — and the capture service already emits <code>_hint</code> for exactly that. Deterministic gets it right for the 95% that is measurable; AI confirms/corrects the ambiguous 5%. That division keeps the common case fast, free, and reproducible.</li>
</ul>
<p>The broader principle: <strong>push the deterministic detector as far as the source's measurable signal allows before delegating to AI.</strong> Most "the converter guessed wrong" cases are a missing measurement (here, classes + <code>space-y</code>), not a case that needs judgment.</p>
<p><em>Status: Accepted.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How should the spacing & gap scales handle modern mid-range and large gutters?]]></title>
        <id>https://docs.unysonplus.com/decisions/gap-scale-mirrors-spacing-scale</id>
        <link href="https://docs.unysonplus.com/decisions/gap-scale-mirrors-spacing-scale"/>
        <updated>2026-08-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The gap scale stopped at slug 5 (3rem/48px) and both scales jumped 1.5rem→3rem with nothing between, so modern 32/40px steps and 64/80px gutters were unrepresentable — a converted `gap-16` (64px) snapped to 48px. We extended the gap scale to mirror the spacing scale (0–12), seeded the [32px]/[40px] mid-range in both, and taught the converter to emit exact `[NNpx]` arbitrary values against a dedicated gap scale — all additively, so existing slugs never change.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> UnysonPlus's spacing and gap scales inherit Bootstrap's <code>$spacers</code>. The gap scale caps at slug 5 (3rem/48px), and both scales jump straight from slug 4 (1.5rem/24px) to slug 5 (3rem/48px). Modern (Tailwind-era) layouts lean on the values <em>in between</em> (32px, 40px) and <em>above</em> (64px, 80px). How should the framework — and the Site Converter that maps real sites onto it — represent those?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/gap-scale-mirrors-spacing-scale#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The trigger was a fidelity bug in the Site Converter. Converting a hero whose grid used <code>gap-10 lg:gap-16</code> (40px mobile, 64px desktop) produced a <strong>48px</strong> gap — because:</p>
<ul>
<li class="">The <strong>gap scale</strong> stopped at slug 5 (48px), so 64px had nowhere to land and clamped.</li>
<li class="">The converter's <code>gap_slug()</code> <em>snapped</em> to the nearest of <code>{4, 8, 16, 24, 48}</code>, so 40px → 48px too.</li>
<li class="">Arbitrary <code>gap-[Npx]</code> tokens harvested from the source were being folded into the <strong>spacing</strong> scale, but the Section's Gap option reads the <strong>gap</strong> scale — so they never reached it.</li>
</ul>
<p>The deeper issue is the <strong>24px → 48px cliff</strong> shared by both scales: <code>2rem</code> (32px) and <code>2.5rem</code> (40px) — the two steps modern designs use most for card/section padding and grid gaps — simply don't exist as named slugs.</p>
<p>The hard constraint: <strong>you cannot renumber existing slugs.</strong> Every built page, every converted site, and every theme preset references <code>.pt-11</code>, <code>.g-5</code>, <code>section--gap-5</code>, etc. Changing what a slug <em>means</em> silently breaks live content. So any fix has to be <strong>purely additive</strong>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/gap-scale-mirrors-spacing-scale#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class=""><strong>Redefine the mid-range slugs</strong> (make slug 5 = 2rem, shift the rest). Cleanest scale, but renumbers every existing reference — breaks all built/converted content. Rejected outright.</li>
<li class=""><strong>Raise the gap cap only</strong> (add 4rem, 5rem to the gap scale). Fixes 64/80px gutters but leaves the 32/40px cliff and the snap-to-48px behaviour. Half a fix.</li>
<li class=""><strong>Extend the gap scale to mirror the spacing scale, seed the mid-range as bracketed arbitrary steps in both scales, and give the converter a dedicated gap scale with exact <code>[NNpx]</code> output.</strong> Chosen.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/gap-scale-mirrors-spacing-scale#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>A four-part, entirely additive change:</p>
<ol>
<li class=""><strong>The gap scale now mirrors the spacing scale</strong> — slugs 0–12 (0…8rem). <code>g-{slug}</code> ≡ <code>p-{slug}</code> mentally, and 56/64/72/80/96/112/128px gutters become expressible. Slugs 0–5 are byte-for-byte unchanged.</li>
<li class=""><strong>The <code>[32px]</code> / <code>[40px]</code> mid-range is seeded into <em>both</em> scale defaults.</strong> Bracket-named so they read as exact lengths in the dropdown. The gap generator and the Section view both sanitise <code>[32px]</code> → <code>.section--gap-32px</code>; on the spacing side the bracketed entries render on demand through the per-page arbitrary-spacing handler (<code>.pt-[32px]</code>).</li>
<li class=""><strong>The Site Converter gained a dedicated <code>build_gap_scale()</code></strong> (parallel to <code>build_spacing_scale()</code>) that emits the extended base and appends any genuinely off-scale gutter the source uses — a <code>gap-[20px]</code> class or a computed <code>gap:Npx</code> — as an exact <code>[NNpx]</code> entry. Gap tokens no longer pollute the spacing scale.</li>
<li class=""><strong><code>gap_slug()</code> now resolves exactly</strong> — an on-scale value returns the clean slug (<code>gap-16</code> = 64px → <code>7</code>), an off-scale value returns a lossless <code>[NNpx]</code> slug that <code>build_gap_scale()</code> has registered a preset for. No more snapping.</li>
</ol>
<p>The PHP (<code>Stitch</code>/<code>Mapper</code>) and JS (<code>to-theme-settings</code>/<code>to-pages</code>) paths carry identical scales and logic.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/gap-scale-mirrors-spacing-scale#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The scale's job is to encode a design system's <em>intent</em> as a small, named, editable vocabulary. Bootstrap's <code>$spacers</code> encodes a 2013-era intent; modern component design leans on a denser mid-range and larger section gutters. Because slug <strong>numbers are a public contract</strong>, the only safe way to modernise is additively: keep 0–12 stable, mirror them across both scales so the mental model is one scale not two, and express everything off-scale as lossless bracketed arbitraries — the same mechanism the converter already used for spacing. The converter change is what makes it faithful: a real site's exact gutter is reproduced, not rounded to the nearest legacy step.</p>
<p>Deliberately <strong>not</strong> done: redefining the gap cap philosophy as "gaps over 3rem are section spacing." In practice hero and feature grids routinely use 64–80px column gaps; treating those as un-representable was the bug, not a feature.</p>
<p><em>Status: Accepted.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Spacing" term="Spacing"/>
        <category label="Conversion" term="Conversion"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How should the converter map ALL shortcode/theme-settings options — a detector per option?]]></title>
        <id>https://docs.unysonplus.com/decisions/option-mapping-registry-and-table</id>
        <link href="https://docs.unysonplus.com/decisions/option-mapping-registry-and-table"/>
        <updated>2026-08-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Writing a bespoke detector per option doesn't scale. Options split into two kinds — preset-backed (value references a Theme Settings preset) and intrinsic scalars. Preset-backed options want ONE shared registration framework (capture → register a preset with a deterministic id → assign the option), which also bakes in the correct build ordering. Intrinsic options want ONE declarative source-signal → option table, so a new option is a row, not a function. The converter's own coverage reports rank what to add next; AI stays the last-resort tail.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> To map every shortcode and Theme-Settings option faithfully, does the deterministic converter need an individual detector per option — or is there a better, registration-style way to make all options map properly?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/option-mapping-registry-and-table#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Each new option so far got its own hand-written detector (the accordion style/gap reader, the button-size algorithm, the gap snapper, the section padding distillation, …). That's dozens of bespoke functions, each re-discovering the same problems: where the signal lives (class vs computed), how to snap a measured value onto a scale, and — for preset-backed options — the build <strong>ordering</strong> (presets are assembled in the theme-settings pass, <em>after</em> the page is built). The box-preset bug (assigned boxes silently falling back to raw CSS because the lookup was empty during page build) was that ordering trap, hit again.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-insight-options-are-two-kinds-not-one">The insight: options are two kinds, not one<a href="https://docs.unysonplus.com/decisions/option-mapping-registry-and-table#the-insight-options-are-two-kinds-not-one" class="hash-link" aria-label="Direct link to The insight: options are two kinds, not one" title="Direct link to The insight: options are two kinds, not one" translate="no">​</a></h2>
<p><strong>A. Preset-backed options</strong> — the value references a Theme Settings preset: <code>background_pattern</code>, box preset / <code>border_preset</code>, color presets, section styles, button color/size, icon badges, text styles, the spacing/gap scale. You can't "detect" these into a scalar — the faithful mapping is <em>register a preset and point the option at it</em>.</p>
<p><strong>B. Intrinsic scalars</strong> — the value is a direct property: alignment, margins, orientation, columns, corner radius, position, width mode. These are reads; there's nothing to register.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/option-mapping-registry-and-table#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class=""><strong>A detector per option.</strong> Maximum control, but O(N) bespoke functions, each re-solving signal-location + snapping + (for presets) ordering. Doesn't scale; it's how we got here.</li>
<li class=""><strong>Hand everything to the local AI.</strong> Non-deterministic; breaks the converter's reproducibility contract (see the "deterministic style classification" decision). Rejected as the primary.</li>
<li class=""><strong>Two shared frameworks + a data-driven backlog.</strong> Chosen.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/option-mapping-registry-and-table#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Preset-backed options → one shared registration framework.</strong> Capture the skin → <code>register_*()</code> returns a <strong>deterministic id</strong> (hash of the normalized skin) → the option is set to that id; the theme-settings pass emits the referenced presets. This is the pattern proven twice (Background Patterns, Box Presets). Generalizing it bakes in, once and for all:</p>
<ul>
<li class="">deterministic ids, so assignment and registration agree with no name/order coupling;</li>
<li class="">the correct ordering (record during page build, emit during the theme-settings pass);</li>
<li class="">reuse — any shortcode that wants a box/color/pattern/badge just registers and assigns.</li>
</ul>
<p><strong>Intrinsic options → one declarative resolver over a spec table:</strong> <code>{ source signal (CSS property or class pattern) → option key + value transform }</code>. Adding an option becomes adding a <strong>row</strong>, not a function; shared value-snappers (spacing scale, gap scale, radius buckets) are called by the table, not re-implemented per option.</p>
<p><strong>What to add next is data-driven.</strong> The converter already emits <code>class-coverage.json</code> and <code>conversion-drops.json</code> — ranked lists of source signals it didn't map. Extend the registry/table by <strong>frequency</strong>, not by guessing. <strong>AI stays the last-resort</strong> for the genuinely ambiguous tail only.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/option-mapping-registry-and-table#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>A detector-per-option model makes coverage a function of how many functions someone hand-writes — unbounded and error-prone. Splitting on <em>how a value is expressed</em> (a preset reference vs a scalar) yields exactly two reusable mechanisms that cover the whole surface: registration handles everything preset-backed (and fixes the ordering class of bugs structurally), and a declarative table handles the scalars (turning "add an option" into "add a row"). The coverage reports turn the remaining work into a ranked backlog instead of a guessing game, and AI is reserved for the small ambiguous remainder where no measurable signal exists. Reproducibility — the converter's core contract — is preserved throughout, because both mechanisms are deterministic.</p>
<p><em>Status: Accepted.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Future-proofing the Steps/Process shortcode — full Card Rows now, or a Box Preset first?]]></title>
        <id>https://docs.unysonplus.com/decisions/steps-box-style-before-card-rows</id>
        <link href="https://docs.unysonplus.com/decisions/steps-box-style-before-card-rows"/>
        <updated>2026-08-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Steps shortcode's converted cards looked flat and dropped their icons. The bigger question behind the fix: should Steps adopt the shared Card Rows slot designer (like Posts / Testimonials / wc_products) to be maximally flexible, or take the smaller step of a Box Preset (box_style) that fits its existing marker + connector engine? We add box_style now and defer Card Rows — because Steps is a CONNECTED flow, not an independent card grid, so the grid Card Rows doesn't model its spine.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> To make the Steps/Process shortcode "future-proof," should it adopt the shared <strong>Card
Rows</strong> slot designer that Posts, Testimonials, wc_products and team-member use — a drag-to-reorder
row/slot layout with a live card preview — or take the smaller step of adding a <strong>Box Preset</strong> (<code>box_style</code>)
that layers onto its existing layout engine?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/steps-box-style-before-card-rows#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The trigger was a conversion complaint: a source "how it works" section whose steps are <strong>boxed cards with
icons</strong> converted to a flat, borderless number list — the boxed look and the per-step icons were both
dropped. Fixing the <em>detection</em> was unambiguous (read the boxed skin from computed styles, capture the icon
glyph, match the marker shape/accent). But it surfaced a design question: how much option surface should
Steps grow to reproduce designs like this faithfully, and to stay flexible for the future?</p>
<p>Card Rows is the obvious "future-proof" answer — it's the system every other card-like shortcode already
shares. The instinct was to give Steps the same treatment.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/steps-box-style-before-card-rows#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class="">
<p><strong>Full Card Rows on Steps now.</strong> Add a Card tab with a slot designer (marker/icon, number, title,
content), a live card preview, and <code>box_style</code>. Maximally flexible and consistent with the other card
shortcodes. <strong>Cost:</strong> a Steps <strong>view rewrite</strong> — the view would have to render from rows while still
drawing the marker + connector spine, which Card Rows has no concept of. High risk for a shortcode whose
value <em>is</em> its connected-flow layouts (horizontal / vertical / alternating / cards).</p>
</li>
<li class="">
<p><strong>Box Preset (<code>box_style</code>) now, Card Rows later.</strong> Add just the shared <code>sc_card_box_style_field</code> +
<code>sc_card_box_style_class</code> (the same Box Preset picker icon-box / testimonials use) to the Steps Design
tab, stamped as <code>.boxp-{slug}</code> on every <code>.fw-steps__item</code>. Keep the marker/connector engine untouched.
Small, shared, immediately useful; the Site Converter can auto-assign a captured box skin. Revisit Card
Rows as a scoped, Steps-specific effort once there's a real need.</p>
</li>
<li class="">
<p><strong>Detection fixes only.</strong> Ship the converter improvements (boxed → cards, icon capture, marker
shape/accent) and add no new option surface. Faithful conversions, but no new editor control for
hand-built steps.</p>
</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/steps-box-style-before-card-rows#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Option 2</strong> — add <code>box_style</code> now and defer Card Rows.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/steps-box-style-before-card-rows#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>Card Rows models an <strong>independent card grid</strong>: each item is a self-contained stack of slots
(image/title/meta/excerpt) with no relationship to its neighbours. That's a perfect fit for Posts,
Testimonials, wc_products and team-member. <strong>Steps is not that</strong> — it's a <em>connected flow</em> with a marker +
connector spine and horizontal/vertical/alternating layouts. Dropping the grid Card Rows onto it would
either discard the connector model or fight it, and would force a view rewrite of the one part of the
shortcode that carries its identity.</p>
<p><code>box_style</code>, by contrast, is pure win at low risk: it's the exact Box Preset system the rest of the
card-like shortcodes already share, it needs no view rewrite (one class on each step item), and it closes
the reported gap directly — a boxed source process now maps to <code>design: cards</code> <strong>plus</strong> a matching
<code>.boxp-</code> preset, while the converter also carries the per-step icon (→ <code>marker: icon</code>), marker shape and
accent colour. If a genuine need for per-step slot reordering appears later, it can be built as a
<strong>Steps-specific</strong> Card variant that keeps the connector spine — not a copy of the grid system. Ship the
80% that fits the shortcode's grain; don't retrofit the 20% that doesn't.</p>
<p><em>Status: Accepted.</em></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="Conversion" term="Conversion"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Where should the deep Site Converter docs live — in the extension, or moved to the AI Dev Kit?]]></title>
        <id>https://docs.unysonplus.com/decisions/consolidate-conversion-docs-by-depth</id>
        <link href="https://docs.unysonplus.com/decisions/consolidate-conversion-docs-by-depth"/>
        <updated>2026-08-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The conversion knowledge was split across two how-it-works pages — the extension's deep architecture/algorithm and the kit's conceptual method — with neither framed as the canonical home. The proposal was to move ALL Site Converter subpages into the AI Dev Kit and leave the extension as a stub-plus-link. We consolidated by DEPTH instead: the deep pipeline/algorithm moves to the kit as one canonical page, but the extension keeps its standalone task manual (convert-from-file/url, capture service, AI-assist, manual tools) so a wp-admin plugin user is never bounced into developer-kit docs.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> The Site Converter's conversion knowledge lived in two places — a deep
<code>how-it-works</code> (architecture + algorithm) under the extension docs, and a conceptual <code>how-it-works</code>
(capture-first → outside-in → measure) under the AI Dev Kit — and neither was clearly the canonical
home. Should we move <strong>all</strong> the extension's conversion subpages into the AI Dev Kit and reduce the
extension section to a basic overview + a link, so all conversion info lives in one place?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/consolidate-conversion-docs-by-depth#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Both doc sets describe the same underlying engine but for different readers. The extension pages
(<code>index</code>, <code>convert-from-file</code>, <code>convert-from-url</code>, <code>capture-service</code>, <code>ai-assist</code>, <code>manual-tools</code>,
<code>how-it-works</code>) are a <strong>product manual</strong> for a WordPress user running the plugin in <code>wp-admin</code>. The
AI Dev Kit pages are for a <strong>developer</strong> running Claude Code to drive an agentic conversion and
improve the converter.</p>
<p>Two facts complicated a wholesale move:</p>
<ol>
<li class=""><strong>The Site Converter extension is a standalone plugin</strong> — fully usable without the kit (its
deterministic engine and the capture service need no AI and no Node tooling from the kit).</li>
<li class=""><strong>The extension has its <em>own</em> optional "AI assist"</strong> — Claude running <em>inside the capture service</em>
to refine the mapping only. That is <strong>not</strong> the same as the kit's full agentic build. So there are
three tiers, not two: extension-deterministic, extension-AI-assist, and kit-full-agentic.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/consolidate-conversion-docs-by-depth#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>Full consolidation (the original proposal).</strong> Move every extension subpage's content into the
kit; collapse the extension section to one overview page + a prominent "see the AI Dev Kit" link.
<em>Trade-off:</em> a user who just installed the plugin to convert an HTML file gets bounced into
developer-kit docs that imply they need Node + Claude — and the extension's light AI-assist gets
conflated with the kit's full agentic build. The plugin stops being self-documenting.</li>
<li class=""><strong>Hybrid.</strong> Keep the pure task pages (file/URL) in the extension; move everything conceptual
(how-it-works, ai-assist, full-conversion, manual-tools pipeline) to the kit. <em>Trade-off:</em> splits
the extension's own manual mid-way — <code>ai-assist</code> and <code>manual-tools</code> are things you do <em>in the
plugin</em>, so exiling them reads oddly.</li>
<li class=""><strong>Consolidate by depth (chosen).</strong> Move only the <strong>deep architecture + algorithm</strong> to the kit as
one canonical page; keep the extension's <strong>task manual</strong> in place; shrink the extension's
<code>how-it-works</code> to a short two-engine overview that links to the kit for the full details.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/consolidate-conversion-docs-by-depth#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Consolidate <strong>by depth, not wholesale.</strong> A new canonical page —
<a href="https://unysonplus.github.io/ai-dev-kit/conversion-architecture" target="_blank" rel="noopener noreferrer" class=""><code>ai-dev-kit/conversion-architecture</code></a>
— now holds the full pipeline (capture → extract → apply → review → build), section detection,
chrome-vs-content, the role→shortcode map, the navigation and footer-widget mappers, design-token
extraction, the two kept-in-sync implementations, and exactly where AI fits. The extension's
<code>how-it-works</code> is reduced to a short two-engine overview (deterministic / capture service / optional
AI) that links out to that page. All the extension <strong>task</strong> pages stay put. Cross-links run both
directions.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/consolidate-conversion-docs-by-depth#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The right axis to split on is <strong>audience/depth, not location</strong>. The deep architecture is shared by
both paths, so it belongs in exactly one canonical place — and the kit, whose whole reason for being
is "how conversion really works and how to improve it," is the natural home. But the extension is a
standalone product; its docs must let a <code>wp-admin</code> user convert a site without ever meeting the kit.
Keeping the task manual in the extension preserves that self-sufficiency, while moving the deep
reference eliminates the duplication and the "which how-it-works is authoritative?" ambiguity. The
plugin's own AI-assist stays documented as a plugin feature, distinct from the kit's agentic build.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Documentation" term="Documentation"/>
        <category label="Conversion" term="Conversion"/>
        <category label="Extensions" term="Extensions"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[When the converter derives a site's Box Presets, do we replace the built-in defaults or keep them?]]></title>
        <id>https://docs.unysonplus.com/decisions/converted-presets-on-top-keep-defaults</id>
        <link href="https://docs.unysonplus.com/decisions/converted-presets-on-top-keep-defaults"/>
        <updated>2026-08-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Site Converter now distils a source into a full Box Preset library (every card/panel/chip/pill skin, with fill + hover). The question: should those site-derived presets REPLACE the plugin's built-in defaults (Card / Outline / Soft Shadow / Hover Lift), or sit alongside them? We keep the defaults but PREPEND the converted presets — the converted design system is primary (picked first, and what elements reference), while the generic defaults remain as a fallback for elements the source never had.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> After the Site Converter walks a source and clusters every box skin into on-brand Box
Presets, what happens to the plugin's four built-in defaults (Card / Outline / Soft Shadow / Hover Lift)?
Do the site-derived presets <strong>replace</strong> them, or do both coexist — and if both, in what order?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/converted-presets-on-top-keep-defaults#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The theme-settings importer <strong>replaces</strong> the whole <code>border_presets</code> option (it doesn't merge), so whatever
the converter emits is the entire library the converted site ships with. That forces an explicit choice —
the emitted list has to <em>deliberately</em> contain the defaults, or they're gone. Two things pull in opposite
directions:</p>
<ul>
<li class="">The <strong>converted presets are the site's design system.</strong> On a faithful clone, "Feature Card", "Glass Stat
Box", and "Problem Card" ARE the site's boxes; they're what every converted element references
(<code>box_style</code> / <code>border_preset</code> = <code>boxp-&lt;slug&gt;</code>), and what a user should reach for first when adding a new
card that matches the design.</li>
<li class="">The <strong>defaults are still useful.</strong> A user who later adds an element the <em>source never had</em> (a plain
bordered callout, a soft-shadow panel) wants a sensible generic starting point without hand-rolling one.
And removing a built-in that some element or template already references would break it.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/converted-presets-on-top-keep-defaults#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>Replace the defaults with the converted presets.</strong> Cleanest "this is your site" list — no generic noise.
<em>Trade-off:</em> destructive. Any element/template pointing at a default <code>.boxp-card</code> / <code>.boxp-outline</code> loses
its styling, and a user adding a non-source element has nothing generic to grab. Deleting built-ins that a
store REPLACES (not merges) is a one-way loss.</li>
<li class=""><strong>Keep defaults first, converted appended after</strong> (the original behaviour). Non-destructive, but the
site-matching presets sit <em>below</em> four generic ones the user has to scroll past — the converted design
system reads as secondary to boilerplate.</li>
<li class=""><strong>Keep defaults, PREPEND the converted presets</strong> (chosen). Non-destructive AND the converted design system
is primary.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/converted-presets-on-top-keep-defaults#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Keep the built-in defaults, but put the converted presets ON TOP</strong> — <code>array_merge( $derived, $defaults )</code>
(PHP) / <code>derived.concat(DEFAULTS)</code> (JS). The site-derived presets are what the user sees and selects first,
and what the converter assigns; the four generic defaults remain available below as a fallback. Same
ordering rule applies to the sibling preset families (section styles, buttons, icon badges) so the whole
Theme Settings → Components experience is "your site's stuff first, library defaults below".</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/converted-presets-on-top-keep-defaults#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The right axis is <strong>primacy, not exclusivity.</strong> The converted presets earn top billing because they're the
actual design system — but <em>removing</em> the defaults trades a real safety property (nothing that references a
built-in can break; there's always a generic starting point) for a purely cosmetic tidiness that the
ordering already delivers. Prepending gets the "this is your site" feel with none of the destructive risk,
and it's the same "your items first, library below" pattern users already know from every other picker in
the product. When in doubt between destructive-and-clean vs non-destructive-and-ordered, non-destructive
wins — you can always ignore a default, but you can't un-break a reference to a deleted one.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Extensions" term="Extensions"/>
        <category label="Option types" term="Option types"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Converting an inner page — does the user hand-uncheck options, or should the converter infer it from the URL?]]></title>
        <id>https://docs.unysonplus.com/decisions/inner-page-conversion-infer-from-url-path</id>
        <link href="https://docs.unysonplus.com/decisions/inner-page-conversion-infer-from-url-path"/>
        <updated>2026-08-21T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Site Converter hard-coded every single-URL conversion as the site's FRONT page — so converting an inner page like /services hijacked the homepage pointer and re-derived the whole child theme + chrome. The fix isn't a new 'create new page' checkbox: the converter already knows the source URL, so it should INFER page type from the path (root → homepage; any inner path → a new content-only page under its own slug), flip the chrome/child-theme defaults to match, and expose a single 'Set as homepage' toggle as the explicit override.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> A single-URL conversion of an inner page (e.g. <code>https://site/services</code>) — should the user
have to manually uncheck <em>Create child theme</em>, <em>Capture header</em>, <em>Capture footer</em> to get "just the sections
content"? Will it create a new page or override the homepage? And if it can override the homepage, don't we
need a "Create new page" checkbox?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/inner-page-conversion-infer-from-url-path#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The single-URL build hard-coded the target as the front page:</p>
<div class="language-php codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-php codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token variable" style="color:#36acaa">$screens</span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">array</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'html'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> …</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'title'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">page </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain">title</span><span class="token operator" style="color:#393A34">&gt;&gt;</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'slug'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">''</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'front'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token constant boolean" style="color:#36acaa">true</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>The importer is idempotent by slug (update-if-exists, else create), and <code>front =&gt; true</code> writes
<code>show_on_front</code> / <code>page_on_front</code>. So converting <code>/services</code>:</p>
<ul>
<li class="">created a <strong>new</strong> page (slug derived from the messy <code>&lt;title&gt;</code>, e.g. <code>services-noir</code>) — it did NOT overwrite
the Home page's <em>content</em>;</li>
<li class=""><strong>but set that new page as the site's front page</strong> — so visiting the site now showed Services. That's the
real bug: the homepage <em>pointer</em> was hijacked;</li>
<li class="">and re-captured header/footer + rebuilt the child theme — redundant, and destructive when <em>adding</em> a page to
an already-converted site (it re-derives chrome over the tuned one).</li>
</ul>
<p>The converter already receives the source URL (<code>source_url</code> in the build opts), so it has everything it needs
to tell an inner page from the homepage — it just wasn't using it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/inner-page-conversion-infer-from-url-path#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>Make the user uncheck the boxes.</strong> Rely on the human to turn off <em>Create child theme</em> / <em>Capture header</em>
/ <em>Capture footer</em> for an inner page. <em>Trade-off:</em> easy to forget (the front-page hijack still happens even
if they do, because <code>front</code> is hard-coded), and it pushes converter knowledge onto the user for something the
URL already tells us.</li>
<li class=""><strong>Add a "Create new page" checkbox.</strong> <em>Trade-off:</em> the importer already creates a new page per slug — the
thing actually missing is control over the <em>front-page</em> assignment, not page creation. A "create new page"
toggle would be redundant and wouldn't fix the hijack.</li>
<li class=""><strong>Infer from the URL path + one "Set as homepage" toggle (chosen).</strong> Derive <code>slug</code> + <code>front</code> from the
source path; flip the chrome/child-theme defaults for inner pages; expose a single explicit override.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/inner-page-conversion-infer-from-url-path#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Infer the target from the source URL path, and expose a "Set as homepage" toggle:</strong></p>
<ul>
<li class=""><strong>Root path</strong> (<code>/</code>, <code>/index.*</code>, <code>/home</code>) → the homepage: blank slug → <code>home</code>, set as the front page (today's
behavior).</li>
<li class=""><strong>Any inner path</strong> (<code>/services</code>) → a <strong>new page</strong> under the clean path-segment slug (<code>services</code>), <code>front:false</code>
— the homepage is left untouched.</li>
<li class="">The Convert panel's <strong>"Set as homepage"</strong> checkbox always wins when the user sets it (threaded as the
<code>set_as_homepage</code> build opt); it's <strong>auto-ON for a root URL and auto-OFF for an inner URL</strong>.</li>
<li class="">Typing an inner URL also <strong>auto-unchecks</strong> <em>Create child theme</em> / <em>Capture header</em> / <em>Capture footer</em> (with a
hint) so an inner page imports as content-only into the existing site — any box the user has touched is left
as they set it.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/inner-page-conversion-infer-from-url-path#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The URL path is an unambiguous, already-available signal for "is this the homepage or an inner page," so making
the human re-encode it by hand (and remember to do so) is the wrong layer — especially since the real hazard
(the front-page hijack) came from a hard-coded <code>front =&gt; true</code> the checkboxes couldn't even fix. A "create new
page" checkbox misdiagnoses the gap: creation was never the problem, <em>front-page assignment</em> was. Inferring the
defaults from the path and surfacing exactly one override ("Set as homepage") makes the common cases correct
with zero clicks — convert the homepage → it's the homepage; convert <code>/services</code> → it's a new page, your site
untouched — while keeping the user in full control for the exceptions.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Extensions" term="Extensions"/>
        <category label="Page builder" term="Page builder"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Converting a store — should the converter auto-detect WooCommerce, or gate it behind a checkbox?]]></title>
        <id>https://docs.unysonplus.com/decisions/woocommerce-conversion-detect-and-gate</id>
        <link href="https://docs.unysonplus.com/decisions/woocommerce-conversion-detect-and-gate"/>
        <updated>2026-08-21T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[When the Site Converter ingests a WooCommerce/e-commerce source, its product grids should map to real WooCommerce shortcodes (a live [wc_products] feed) instead of frozen static image cards. But emitting WooCommerce shortcodes on a site where WooCommerce is not installed produces broken output. So detection alone cannot drive emission. The decision: detect the store from the captured HTML AND gate emission behind a checkbox that is disabled unless WooCommerce is active — a belt-and-suspenders double gate, auto-ticked when both are true.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> A converted store should get <strong>live WooCommerce shortcodes</strong> for its product grids
(<code>[wc_products]</code>, add-to-cart, etc.) rather than the static <code>image_box</code> cards a generic card grid becomes.
Should the converter simply <strong>auto-detect</strong> WooCommerce from the source and map accordingly — or put a
<strong>checkbox</strong> in the Convert panel, greyed out when WooCommerce isn't installed?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/woocommerce-conversion-detect-and-gate#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The Site Converter maps a source's DOM onto native UnysonPlus constructs. A product grid on the source
(cards with a title, image, price, and an add-to-cart control) currently falls through to the generic
card-grid path and renders as static <code>image_box</code> tiles — frozen content, no cart, no live catalog.</p>
<p>Two facts pull in opposite directions:</p>
<ol>
<li class=""><strong>The source can be detected.</strong> A real WooCommerce/e-commerce page carries unambiguous signals in the
captured HTML — the <code>woocommerce</code> body/wrapper class, <code>woocommerce-Price-amount</code>, <code>add_to_cart_button</code>,
<code>data-product_id</code>, WooCommerce Blocks (<code>wc-block-*</code>), <code>Product</code> JSON-LD, and store-shaped URLs
(<code>/product/</code>, <code>/cart</code>, <code>/checkout</code>).</li>
<li class=""><strong>Emission can break the target.</strong> <code>[wc_products]</code> and friends only render on a site where WooCommerce
is <strong>active</strong>. Emit them on a plain install and the visitor sees raw, unparsed shortcodes — worse than
the static cards we replaced. Detection tells us about the <em>source</em>; it says nothing about whether the
<em>target</em> can render what we'd emit.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/woocommerce-conversion-detect-and-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>Auto-detect only (no UI).</strong> Detect WooCommerce in the source and map product grids to WooCommerce
shortcodes automatically. <em>Trade-off:</em> silently emits broken shortcodes whenever the target lacks
WooCommerce, and gives the user no way to opt out (e.g. they're rebuilding the store on a page builder
and don't want a live feed there). Detection is necessary but insufficient — it's the wrong single gate.</li>
<li class=""><strong>Checkbox only (no detection).</strong> A plain "Map to WooCommerce" toggle, always available. <em>Trade-off:</em> a
user can tick it on a non-store site and get a product feed where the source had a portfolio grid; and it
offers no guidance about whether the option is even usable on this install.</li>
<li class=""><strong>Detect <em>and</em> gate (chosen).</strong> A checkbox that is <strong><code>disabled</code> unless <code>class_exists('WooCommerce')</code></strong>
(labelled "(WooCommerce not installed)" when off), <strong>auto-ticked when the source is detected as a store</strong>,
and whose emission is gated a <em>second</em> time by the same source detection at build time. Both the target
(WooCommerce active) and the source (reads as a store) must agree, and the user stays in control.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/woocommerce-conversion-detect-and-gate#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p><strong>Detect the store, but gate emission behind a WooCommerce-active checkbox — a double gate.</strong></p>
<ul>
<li class=""><code>FW_Site_Converter_Sources::is_woocommerce_source($html)</code> scores the captured HTML (strong signals worth 2,
corroborating ones worth 1; a threshold of 2 avoids a lone "cart" word false-positiving).</li>
<li class="">The Convert panel's <strong>"Map to WooCommerce"</strong> checkbox is <code>disabled</code> unless WooCommerce is active on the
install, so it's impossible to request an unrenderable mapping.</li>
<li class="">The build reads the option (<code>map_woocommerce</code>), and the mapper only switches product grids to WooCommerce
shortcodes when <strong>the option is on, WooCommerce is active, <em>and</em> <code>is_woocommerce_source()</code> agrees</strong> — so a
mistaken tick on a non-store site is a no-op, not a broken feed.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/woocommerce-conversion-detect-and-gate#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The core insight is that <strong>source detection and target capability are two different questions</strong>, and each is
a necessary gate for a different failure mode. Detection prevents "portfolio grid became a shop"; the
WooCommerce-active gate prevents "raw <code>[wc_products]</code> on a site with no WooCommerce". Neither gate alone is
safe, so we require both and let detection <em>auto-tick</em> the option (convenience) while the WooCommerce-active
check <em>disables</em> it (safety). The result is a converter that does the smart thing by default on a real
store, degrades to the existing static-card behaviour everywhere else, and never emits output the target
can't render — with the user always able to override.</p>
<p><em>Status: Accepted. The detection + gated checkbox + option threading ship first; the product-grid →
<code>[wc_products]</code> mapping itself lands next, built and verified against a live WooCommerce source.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Extensions" term="Extensions"/>
        <category label="Architecture" term="Architecture"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why theme typography went fluid and accessibility-first]]></title>
        <id>https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography</id>
        <link href="https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography"/>
        <updated>2026-08-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The theme sized type with one desktop value per element plus a single mobile step-down at 768px, which left tablets on desktop sizes, jumped at the boundary, and skipped any non-px unit. After researching current practice (WordPress core theme.json, Utopia, Material, the CLS literature), we moved typography to fluid clamp() built on a modular scale — with the WCAG-200%-zoom constraint (rem + vw, never vw alone) designed in from the start rather than bolted on.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> How should the theme handle responsive typography long-term — keep stepping sizes at
a breakpoint, add per-device controls to every field, or move to a fluid type system? And what has to
be true for a fluid system to not fail accessibility?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Type was stored as a single desktop value per role (body, h1–h6) and made "responsive" by one generated
<code>@media (max-width: 767.98px)</code> block that scaled each size by a tiered percentage. Converting a source
site surfaced the failure modes: tablets (≥768px) rendered full desktop sizes, the boundary was a visible
jump, small phones still overflowed (one fixed value below 768px), and any size stored as <code>rem</code>/<code>em</code>
skipped the px-only scaler entirely.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography#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 stepped model, add more breakpoints.</strong> Familiar, but every breakpoint is still a jump, and
it multiplies the values to maintain.</li>
<li class=""><strong>Add Phone/Tablet/Desktop controls to every typography field.</strong> Maximum control, but triples an
already-dense tab, still only changes at breakpoints, and most users won't fill three values — so the
defaults must be good regardless.</li>
<li class=""><strong>Fluid <code>clamp()</code> on a modular scale (chosen).</strong> One authored value scales smoothly across every
viewport. Matches where the ecosystem has landed: WordPress core (<code>theme.json</code> <code>fluid: true</code>), Utopia,
Material's role/token scale.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Move typography to <strong>fluid <code>clamp()</code> built on a modular type-scale engine</strong>, with per-device controls
kept only as an optional override, and the <strong>WCAG 1.4.4 (resize to 200%) constraint designed in</strong>: every
generated clamp uses a <code>rem + vw</code> preferred value and a rem-anchored max — never <code>vw</code> alone. A tiered
mobile floor and a body minimum keep small sizes readable, and a guardrail flags over-steep curves.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>A <code>vw</code>-only clamp breaks browser zoom.</strong> Viewport units don't change when the user zooms, so a
vw-based size stays visually fixed at 200% zoom — a direct WCAG 1.4.4 failure. Anchoring both the
preferred value and the max in <code>rem</code> means zoom (and the user's font-size preference) still scale the
text. This is the single constraint that dictated the whole implementation; bolting fluid on without it
is the trap most implementations fall into.</li>
<li class=""><strong>Fluid removes the real defects.</strong> Smooth scaling covers tablets (no more desktop-on-tablet), erases
the breakpoint jump, and stops small-phone overflow — the exact problems the stepped model caused.</li>
<li class=""><strong>A scale beats per-element hand-tuning.</strong> A base size × a ratio yields a coherent set of steps that
elements reference, so the whole system stays consistent instead of drifting per heading.</li>
<li class=""><strong>It's the interoperable baseline.</strong> WordPress core already ships this model; aligning lets the block
editor and the builder agree, and keeps us on the same footing as the major themes/builders.</li>
<li class=""><strong>The overhaul is the moment to fix font CLS too.</strong> Owning how type and fonts are declared let us add
<code>font-display: swap</code> (already present) plus metric-matched fallback <code>@font-face</code> rules, so the web-font
swap doesn't shift layout.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://docs.unysonplus.com/decisions/fluid-accessibility-first-typography#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted — shipped as Phases 0–4 (fluid clamp foundation, scale engine, authored Type Scale UI, semantic
roles + <code>theme.json</code> fluid + fallback metrics). Per-device per-role overrides and the dedicated authored
control follow with the v3 option type.</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="Accessibility" term="Accessibility"/>
        <category label="Performance" term="Performance"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why converted styles keep getting overruled — and why we re-assert the source instead of building an overrule detector]]></title>
        <id>https://docs.unysonplus.com/decisions/source-reassertion-vs-overrule-detector</id>
        <link href="https://docs.unysonplus.com/decisions/source-reassertion-vs-overrule-detector"/>
        <updated>2026-08-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Converted headings rendered bold, icons green, footer text black — each time a shortcode rule or a parent-theme default outranked the source styles the converter emitted. The recurring cause is one thing: the faithful base is emitted at zero specificity (:where), so anything with real specificity wins. Rather than a separate runtime "overrule detector", we re-assert the source's computed value for a fixed set of high-overrule-risk properties at a scoped specificity that wins by construction — so the source is authoritative without per-case patching.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> The Site Converter kept producing pages where a shortcode's own CSS or a
<code>unysonplus-theme</code> default silently overruled the styles we mapped from the source — headings rendered
bold when the source was regular, service-card icons came out green instead of grey, footer address text
went black. Each was fixed one property at a time. Should we build a general "checker/detector" that stops
shortcode and parent-theme styles from overruling the source?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/source-reassertion-vs-overrule-detector#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>A conversion maps a source element onto a UnysonPlus construct (a <code>special_heading</code>, an <code>icon_box</code>, a
footer section) and emits the source's look as a <strong>faithful base</strong>. The base is written with
<code>:where(selector){…}</code> — deliberately <strong>zero specificity</strong>, so a user can override any of it later without
fighting <code>!important</code>.</p>
<p>That same zero specificity is the trap. A shortcode's own rule (<code>h2.heading-title { font-weight: var(--h2-font-weight, revert) }</code> = specificity 0,1,1) or the parent theme's tag rule (<code>h2 { … }</code> = 0,0,1)
both <strong>outrank</strong> the <code>:where</code> base (0,0,0). When the theme/shortcode default happens to equal the source,
everything looks right; when it doesn't, the source loses:</p>
<ul>
<li class=""><strong>Heading weight</strong> — the shortcode's <code>revert</code> fallback resolves to the UA default for <code>&lt;h2&gt;</code>, which is
<strong>bold</strong>; a source heading at <code>400</code> rendered bold.</li>
<li class=""><strong>Heading casing</strong> — the source's CSS-applied <code>text-transform: uppercase</code> (no <code>uppercase</code> class) was
dropped, so "OUR SERVICES" became "Our Services".</li>
<li class=""><strong>Icon colour</strong> — an unresolved <code>text-muted-foreground</code> fell through to the <code>icon_box</code> shortcode's
<strong>default green</strong> instead of the source grey.</li>
<li class=""><strong>Footer text</strong> — a stale section rule painted the address rows <strong>black</strong> on a dark footer.</li>
</ul>
<p>Read individually these look like four bugs. They are one architectural fact: <em>the base cannot win a
specificity battle it was designed to lose.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/source-reassertion-vs-overrule-detector#options-considered" class="hash-link" aria-label="Direct link to Options considered" title="Direct link to Options considered" translate="no">​</a></h2>
<ol>
<li class=""><strong>Keep patching per property.</strong> Fast each time, but it is whack-a-mole — every new shortcode or theme
default is a fresh way for the source to be overruled, and nothing tells us which properties are at
risk until a site looks wrong.</li>
<li class=""><strong>A separate runtime "overrule detector".</strong> Render the converted page, diff every element's computed
style against the source, emit corrective CSS. Accurate, but a conversion is imported <strong>server-side</strong>
(PHP) into a site that isn't live yet — there is no rendered page to diff at import time. It would need
a headless browser in the import path, which we don't have and don't want there.</li>
<li class=""><strong>Deterministic source re-assertion at build time (chosen).</strong> For a fixed set of <em>high-overrule-risk</em>
properties, always emit the source's computed value at a <strong>scoped specificity that wins</strong> — the
<code>.uHASH .part</code> (0,2,0) selector the mapper already uses for <code>heading_weight_css()</code>. The source is
authoritative <em>by construction</em>; no rendering, no diffing, no per-case chase.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/source-reassertion-vs-overrule-detector#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Re-assert, don't detect. When the converter maps an element it already holds that element's computed
style (<code>data-sc-cs</code>); it emits the overrule-prone properties — <strong>font-weight, font-family, text-transform,
letter-spacing, line-height, colour</strong> — as a scoped rule at <code>.uHASH .part</code> specificity (0,2,0), which
beats a shortcode's <code>hN.heading-title</code> (0,1,1) and the theme's bare tag rule (0,0,1). The mapper's
<code>significant_text_decls()</code> already extracts exactly this set from a computed-style string, so the
mechanism is a generalisation of an existing helper rather than a new subsystem.</p>
<p>The first place this landed: <code>heading_weight_css()</code> now recovers the weight from the heading part's own
computed style (<code>*_cs</code>) when no explicit weight/class carries it, so a source <code>&lt;h2&gt;</code> at 400 re-asserts
<code>font-weight:400 !important</code> scoped to its node and renders regular. Casing was fixed in the same spirit by
carrying the heading's <code>text-transform</code> through to a <code>--hN-text-transform</code> token the theme's <code>hN</code> rules
consume. The icon-colour case was the same root in a different guise — the admin rebuild path wasn't
seeding the semantic-colour config, so <code>text-muted-foreground</code> couldn't resolve and fell to the
shortcode default; enriching the config is the "re-assert the real source value" move for colour.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/source-reassertion-vs-overrule-detector#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Deterministic and offline.</strong> It runs in the PHP importer with no browser and no live page — the same
constraint that rules out a runtime detector.</li>
<li class=""><strong>Wins by construction, not by luck.</strong> Specificity is chosen so the source outranks the constructs it is
mapped onto; we stop depending on the theme default happening to match.</li>
<li class=""><strong>Bounded, not universal <code>!important</code> soup.</strong> Only the small, named set of properties that shortcode and
theme defaults actually clobber is re-asserted — the rest of the base stays overridable <code>:where</code>, so
users keep the clean override story the base was designed for.</li>
<li class=""><strong>One concept, many symptoms.</strong> Weight, casing, colour and footer text were all the base losing a
specificity battle; framing the fix as "re-assert the source at winning specificity" turns a growing
list of one-off patches into a single rule with a known property set to extend.</li>
</ul>
<p><em>Status: Accepted.</em> Applied to heading parts first; the property set and the <code>.uHASH .part</code> re-assertion
pattern are the template for extending it to other converted constructs as new overrule cases surface.</p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Conversion" term="Conversion"/>
        <category label="Architecture" term="Architecture"/>
        <category label="css" term="css"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why the typography overhaul is staged as "v3", then promoted to canonical]]></title>
        <id>https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote</id>
        <link href="https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote"/>
        <updated>2026-08-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A big typography overhaul risks every live site if it lands in place. We build it as a separate typography-v3 surface so the running typography control is never at risk, verify it, then promote v3 to the canonical name and retire the old one — the same move used when v2 was promoted to v1. "v3" is a staging name, not a permanent versioned control, so the namespace stays clean long-term.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> A typography overhaul this large can't safely be edited in place on the live control.
How do we build and prove it without risking every site, and without leaving a messy <code>v1 / v2 / v3</code>
option-type namespace behind?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Typography settings drive every site's rendered type. The overhaul (fluid scale engine, new Type Scale
UI, role tokens, font pipeline) changes how sizes are authored and emitted. We had just promoted the
former <code>typography-v2</code> control to be the canonical <code>typography</code> — so we already had a pattern for
"prove a new control, then make it the default."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote#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>Edit the canonical control in place.</strong> Fewest moving parts, but any regression hits all 13+ installs
immediately, and there's no clean rollback while iterating.</li>
<li class=""><strong>Build a permanent parallel <code>typography-v3</code> and keep all three.</strong> Safe to iterate, but leaves the
namespace cluttered with versioned controls forever.</li>
<li class=""><strong>Stage as <code>typography-v3</code>, then promote it to canonical (chosen).</strong> Iterate in isolation, verify, then
rename v3 → the canonical <code>typography</code> and retire the old one — exactly the v2→v1 move.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Develop the overhaul as an isolated <strong><code>typography-v3</code></strong> surface. Once verified across installs and the
converted test sites, <strong>promote it to the canonical <code>typography</code></strong> and demote the current control to a
compatibility shim. "v3" is a <strong>staging label</strong>, not a permanent versioned control.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Zero blast radius while building.</strong> The live control keeps running untouched, so iteration can't break
a production site; rollback is just "don't promote."</li>
<li class=""><strong>Non-destructive by construction.</strong> V3 emits into the same CSS custom properties (<code>--h1-font-size</code>
etc.), so when it becomes canonical, built pages don't notice the swap; saved values fold forward.</li>
<li class=""><strong>A clean long-term namespace.</strong> Because the destination is the canonical name — not a permanent
<code>v3</code> — we don't accumulate versioned controls. The number is scaffolding that disappears on promotion.</li>
<li class=""><strong>A proven pattern.</strong> This is the same staged promotion used for v2→v1, so the workflow and its
guarantees are already understood.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://docs.unysonplus.com/decisions/stage-typography-v3-then-promote#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p><strong>Superseded.</strong> The dedicated <code>typography-v3</code> <em>option type</em> was built as a composite (fonts + the scale
in one control) and then removed: a single control bundling the whole scale read as bulky and confusing,
and it duplicated what the General → Typography tab already does. The correct home for the fluid scale is
a <strong>group of ordinary options on the Typography tab</strong> (the Phase 2 "Type Scale" group), not a bespoke
option type. So there is no v3 control to promote — the fluid engine (Phases 0–1), the tab controls
(Phase 2), the font-CLS layer (Phase 3) and the role/<code>theme.json</code> work (Phase 4) all stay; only the
option-type packaging was dropped. The lesson kept: reach for a new option <em>type</em> only when a control is
genuinely reusable across many contexts — not to bundle one tab's settings.</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="Naming" term="Naming"/>
        <category label="Back-compat" term="Back-compat"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why the group container gained a native title/desc instead of a leading html option]]></title>
        <id>https://docs.unysonplus.com/decisions/group-container-native-title-desc</id>
        <link href="https://docs.unysonplus.com/decisions/group-container-native-title-desc"/>
        <updated>2026-08-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The Mobile & Tablet header tab (and the Layout tab) grew many option groups, and each one carried its section heading as a leading `html` option — a workaround that reads as noise in the option array and puts presentation inside a data slot. We settled on adding optional `title`/`desc` parameters to the FW group container itself, rendered only when set, so grouping and its label are one consistent, backward-compatible construct.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> Can the <code>group</code> container type carry a label of its own, so we stop faking
section headings with a leading <code>html</code> option?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/group-container-native-title-desc#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>Reorganising the Header <strong>Layout</strong> and <strong>Mobile &amp; Tablet</strong> tabs into <code>type =&gt; 'group'</code> boxes gave us
~19 groups. Each group needed a visible heading (and one needed a short note — "a different mobile logo
lives under Identity"). The framework's <code>group</code> container had <strong>no</strong> title concept, so every group led
with a throwaday <code>html</code> option:</p>
<div class="language-php codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-php codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token string single-quoted-string" style="color:#e3116c">'grp_toggle'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string single-quoted-string" style="color:#e3116c">'type'</span><span class="token plain">    </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'group'</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string single-quoted-string" style="color:#e3116c">'options'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token string single-quoted-string" style="color:#e3116c">'toggle_heading'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'type'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'html'</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'label'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token constant boolean" style="color:#36acaa">false</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'html'</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:#e3116c">'&lt;h4 …&gt;Hamburger / Toggle&lt;/h4&gt;'</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic">// …real options…</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">,</span><br></div></code></pre></div></div>
<p>That works, but it's wrong on three counts: the heading is a <strong>fake option</strong> occupying an id in a data
array; its markup/inline-styles are <strong>duplicated</strong> at every call site; and the label lives <strong>inside</strong>
<code>options</code> rather than as an attribute <strong>of</strong> the group, so grouping and labelling are two disconnected
ideas.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/group-container-native-title-desc#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 leading <code>html</code> option.</strong> Zero framework change, but the noise multiplies with every group
and the styling drifts (each site/tab re-invents the <code>&lt;h4 style=…&gt;</code>).</li>
<li class=""><strong>A helper closure</strong> (<code>$heading()</code> / <code>$section()</code>) per options file. Removes the copy-paste but still
emits a fake option, still per-file, still not an attribute of the group.</li>
<li class=""><strong>Native <code>title</code> (+ <code>desc</code>) on the group container</strong>, rendered only when set. One place owns the markup;
the label becomes an attribute of the thing it labels; every group across the plugin benefits.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/group-container-native-title-desc#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Add optional <code>title</code> and <code>desc</code> parameters to <code>FW_Container_Type_Group::_render()</code>. <code>title</code> renders as a
small uppercase <strong>"eyebrow" section label</strong> — deliberately a muted <code>&lt;div class="fw-backend-options-group__title"&gt;</code>,
not a content <code>&lt;h4&gt;</code>, so it marks a section without competing with the field labels beneath it; <code>desc</code>
allows limited inline HTML (<code>wp_kses_post</code>) in a muted <code>&lt;p class="fw-backend-options-group__desc"&gt;</code>. Both
render <strong>only when present</strong>, so every existing group in the plugin is untouched. The Header Layout and Mobile &amp; Tablet tabs were then
refactored to use <code>title</code>/<code>desc</code> and drop their <code>html</code> heading options (the "mobile logo" note became the
Layout group's <code>desc</code>).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/group-container-native-title-desc#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>Back-compat is free</strong> — an unset <code>title</code>/<code>desc</code> renders nothing, so the thousands of existing groups
are byte-identical in output.</li>
<li class=""><strong>One owner for the markup</strong> — the box heading style lives in the container renderer, not scattered
inline styles across option files, so it stays consistent and themeable via two real classes.</li>
<li class=""><strong>The model reads right</strong> — a group's label is now an attribute of the group, mirroring how <code>box</code>/<code>tab</code>
already carry a <code>title</code>. Grouping and labelling are one construct, which is exactly what a reader
skimming the option array expects.</li>
</ul>
<p>The options-framework docs (<code>/docs/options</code>) were updated to document the two new parameters.</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="Back-compat" term="Back-compat"/>
        <category label="Header" term="Header"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why can't a header be transparent AND shrink its logo on scroll? Splitting header behavior into two composable states.]]></title>
        <id>https://docs.unysonplus.com/decisions/header-two-state-layout-model</id>
        <link href="https://docs.unysonplus.com/decisions/header-two-state-layout-model"/>
        <updated>2026-08-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[The single header_behavior enum conflates position, top-transparency, scroll transforms, and glass into one mutually-exclusive list, so real setups (transparent + shrink, clear-at-top then glass-on-scroll) simply can't be expressed. Decision — model the header as two composable appearance states (At top / On scroll) plus a slim position axis, with a master toggle for progressive disclosure. No DB migration; a read-side shim covers legacy values.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> a user wants a header that is transparent over the hero <em>and</em> shrinks its
logo once you scroll. Today they can't — "Transparent overlay" and "Sticky + shrink" are two
different values of the same <strong>Header Behavior</strong> dropdown. Why is a header's behavior a single
choice at all, and how should we organize it so every setup is possible?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/header-two-state-layout-model#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>header_behavior</code> is one <code>select</code> with five values: <code>static</code>, <code>sticky</code>, <code>sticky-shrink</code>,
<code>hide-on-scroll</code>, <code>transparent-overlay</code>. But a header's "behavior" is really <strong>four
independent things</strong>:</p>
<ul>
<li class=""><strong>Position / motion</strong> — does it pin? does it hide on scroll-down?</li>
<li class=""><strong>Top transparency</strong> — clear over the hero, or a solid bar?</li>
<li class=""><strong>Scroll transform</strong> — shrink the logo / tighten padding when stuck?</li>
<li class=""><strong>Background chrome</strong> — the fill, plus Glass, Border, Shadow.</li>
</ul>
<p>Forcing those into one mutually-exclusive list means most combinations are unreachable:
transparent + shrink, solid + hide-on-scroll, and — the one that started this — <strong>clear at the
top, frosted glass only on scroll</strong>. Glass compounds the problem: it is currently a <em>constant</em>
background (frosted at the top <em>and</em> on scroll, by design), so it can't express "frost appears
on scroll." Every recent header gap traced back to this one enum.</p>
<p>The converter made the smell obvious. The capture already records the header in <strong>two states</strong> —
<code>header_scroll = { top, scrolled }</code> — and then we throw half of it away trying to collapse both
into a single enum value, guessing whether the result should be <code>transparent-overlay</code> or
<code>sticky</code> or <code>sticky-shrink</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/header-two-state-layout-model#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="">
<p><strong>Keep one enum, add modifier toggles</strong> (Shrink on scroll, Frost on scroll, Solid on scroll).
Least disruptive and no migration, but the enum still owns position <em>and</em> transparency, so the
toggles can still contradict the mode (e.g. "Frost on scroll" under a <code>static</code> header). It
patches symptoms, not the conflation.</p>
</li>
<li class="">
<p><strong>Literal two sub-tabs, "On load" / "On scroll", both always visible.</strong> Discoverable and
honest about the two states, but it doubles the visible surface for everyone even though most
headers never differ between states — and it still needs a separate home for position.</p>
</li>
<li class="">
<p><strong>Two composable appearance states + a slim position axis, with progressive disclosure.</strong>
Model exactly the two states the header has. <strong>Behavior</strong> shrinks to position (<code>static</code> /
<code>sticky</code> / <code>overlay</code>) plus a <code>hide on scroll</code> toggle. <strong>Appearance — At top</strong> is always shown.
<strong>Appearance — On scroll</strong> (same fields + shrink) is revealed by one master toggle,
"Change appearance on scroll"; off means the scrolled header inherits the at-top look.</p>
</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/header-two-state-layout-model#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Adopt the <strong>two-state model</strong>: a slim <strong>Behavior</strong> (position + a hide toggle), <strong>Appearance — At
top</strong>, and a toggle-gated <strong>Appearance — On scroll</strong> (Background, Glass, Border, Shadow, Shrink).
The CSS splits today's constant chrome rules into <code>:not(.is-stuck)</code> (at top) and <code>.is-stuck</code> (on
scroll) halves keyed off data-attributes, with a second <code>--header-scroll-bg</code> variable; the
<code>.is-stuck</code> observer and the hide-on-scroll JS already exist and are reused as-is.</p>
<p><strong>No database migration.</strong> The handful of live sites are re-picked by hand once the option shape
lands; a small <strong>read-side shim</strong> derives a missing <code>header_position</code> from any legacy
<code>header_behavior</code> so nothing renders broken in the meantime. The <strong>converter mapping</strong> (fill At-top
from the resting snapshot, On-scroll from <code>header_scroll</code> / <code>data-sc-scrolled</code>) and the <strong>header
presets</strong> are deliberately sequenced as follow-ups, so the first pass is options + CSS + JS + docs
only.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/header-two-state-layout-model#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<p>The four things the old enum bundled are genuinely orthogonal, so the honest model is orthogonal
controls, not a longer list of pre-baked combinations — a list can never cover the whole product
of four axes, and every gap becomes a support question. Two states is the <em>complete</em> description
of a header (there is no third), so the model is not just more flexible, it's bounded: you can't
invent a fifth thing to forget.</p>
<p>It also collapses two hard problems into one. The converter's <code>header_scroll</code> is <em>already</em> two
states; giving Theme Settings two states makes the mapping a 1:1 copy instead of the lossy
"guess a single behavior" heuristic we kept patching — the OBSIDIAN case (transparent hero,
frosted-and-shrunk on scroll) becomes an exact reproduction rather than an approximation.</p>
<p>The cost is real — it spans options, CSS, JS, the converter, and presets — but skipping the DB
migration (the riskiest piece) in favor of a read-side shim and hand-fixing a few sites removes
most of that risk, and the master "Change appearance on scroll" toggle keeps the panel as light
as today's for the common case. Modifier toggles on the old enum were rejected because they leave
the core conflation in place; the always-visible two-tab layout was rejected for taxing every user
with a split that most headers don't use.</p>
<p><em>Status: Accepted. Implementation spec: <code>Header-Two-State-Model-Spec.docx</code>. Build order — theme
options + CSS/JS + docs first; converter mapping and presets follow.</em></p>]]></content>
        <author>
            <name>Jon-Michael Lastimosa</name>
            <uri>https://github.com/jonmlas</uri>
        </author>
        <category label="Header" term="Header"/>
        <category label="Architecture" term="Architecture"/>
        <category label="Back-compat" term="Back-compat"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Why we keep the 'Enable' switch even though image-picker choices are now deselectable]]></title>
        <id>https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers</id>
        <link href="https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers"/>
        <updated>2026-08-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Once the popover image-picker gained a deselect gesture (clearing the selection means "none"), it was tempting to drop the separate "Enable X" switch above each Site-wide UX feature — deselecting the image could stand in for turning the feature off. We decided against it, because a switch and a deselect answer different questions, and folding them together loses state, hurts discoverability, and breaks the consistency of a tab whose other features have no image to deselect.]]></summary>
        <content type="html"><![CDATA[<p><strong>The question:</strong> Now that image-picker choices in the popover can be deselected (clearing the
selection means "none"), should we remove the standalone "Enable page transitions" style switches
across Site-wide UX and let "no image selected" mean "off"?</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="context">Context<a href="https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>The Site-wide UX tab (Page Transitions, Cursor, Preloader, Smooth Scroll, Dark Mode, Scroll
Progress, …) pairs an <strong>Enable X</strong> switch with the feature's controls. For the image-picker features
— e.g. Page Transitions with its searchable Fade / Content Fade-Up / Zoom tiles — the picker recently
gained a <strong>deselect</strong> gesture: clicking the selected tile clears it, storing "none". That raised a
fair question: if "none" is now a reachable state, isn't the separate enable switch redundant? Deselect
the transition → no transition → effectively off.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="options-considered">Options considered<a href="https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers#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>Remove the enable switch; treat "no image selected" as off.</strong> Fewer controls, one less thing to
reason about, and "none" becomes a first-class member of the choice set. But it overloads the picker
with two jobs — <em>which variant</em> and <em>whether the feature runs at all</em> — and only works for features
that actually have an image to deselect.</li>
<li class=""><strong>Keep the enable switch as the master on/off; deselect stays a "clear my choice" escape hatch.</strong>
One extra control per feature, but enablement and variant-choice stay orthogonal, and every feature
in the tab toggles the same way.</li>
<li class=""><strong>Keep the switch, and for features where "none" is genuinely first-class, add an explicit "None"
tile</strong> rather than leaning on the subtle deselect gesture. Best of both where it applies, without
changing the enable model.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="decision">Decision<a href="https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers#decision" class="hash-link" aria-label="Direct link to Decision" title="Direct link to Decision" translate="no">​</a></h2>
<p>Keep the enable switches. Deselect is treated as what it's best at — a "none / clear" option <em>within</em>
the choice — not as the feature's on/off. Where "none" is a natural first-class member of a picker's
set, we prefer adding an explicit <strong>None</strong> tile over repurposing the deselect gesture, but the master
switch stays either way.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why">Why<a href="https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers#why" class="hash-link" aria-label="Direct link to Why" title="Direct link to Why" translate="no">​</a></h2>
<ul>
<li class=""><strong>They answer different questions.</strong> The switch is <em>"is this feature active at all?"</em>; the picker is
<em>"which variant?"</em> An image-picker with nothing selected means "enabled, but no transition chosen" —
an in-between state, not a clean off. The switch says off unambiguously.</li>
<li class=""><strong>Deselect discards the user's choice.</strong> With a switch you can disable Page Transitions while keeping
"Zoom" selected, then re-enable later with Zoom intact. If off means "no image selected", disabling
wipes the configured variant and re-enabling forces a re-pick. Keeping enablement orthogonal to the
choice is the more forgiving model.</li>
<li class=""><strong>Consistency across the tab.</strong> Most Site-wide UX features have an enable switch but <em>no</em> deselectable
image — Smooth Scroll (sliders), Dark Mode (a mode select), Scroll Progress (a color). Removing the
switch only where there's an image picker splits the tab into two mental models: some features toggle
via a switch, others via a "clear the tile" gesture. Uniform switches are easier to learn.</li>
<li class=""><strong>Discoverability.</strong> "Enable page transitions: YES/NO" is obvious at a glance; "clear the image to
disable" is a hidden affordance most users won't find. Deselect is a good escape hatch, not a good
primary control.</li>
<li class=""><strong>Default direction.</strong> The switch lets a feature ship <strong>off by default</strong> while still offering a
sensible preselected tile (Fade) once enabled. Without the switch you either default the feature <em>on</em>
(a tile is preselected) or present an empty, uninviting picker.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="status">Status<a href="https://docs.unysonplus.com/decisions/keep-enable-switches-vs-deselectable-pickers#status" class="hash-link" aria-label="Direct link to Status" title="Direct link to Status" translate="no">​</a></h2>
<p>Accepted — no code change; this ratifies the existing pattern and rules out the "deselect replaces the
switch" simplification.</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="Animation" term="Animation"/>
    </entry>
</feed>