<h1><i class="bi bi-play-btn-fill"></i> Custom Button</h1>
<p class="lead">A <strong>Custom Button</strong> is more than a link — it runs a short, three-stage pipeline every
time it is clicked: <strong>Client&nbsp;Before</strong> (JavaScript in the browser) →
<strong>Server</strong> (PHP on the server) → <strong>Client&nbsp;After</strong> (JavaScript in the browser).
It rides on the app's built-in, login-guarded JavaScript&nbsp;↔&nbsp;PHP bridge, so a single click can validate
input, run trusted database work, and react to the outcome — with no page reload and no plumbing to wire up.</p>

<div class="help-callout help-callout-info">
    <i class="bi bi-info-circle"></i>
    <div><strong>Where to find it.</strong> In the Designer, open the <em>Actions</em> group in the components
        palette and drag <strong>Custom&nbsp;Button</strong> onto any page. Its three code boxes appear in the
        Properties panel, right below the label, icon and variant.</div>
</div>

<h2>Contents</h2>
<ul>
    <li><a href="#model">The three-stage model</a></li>
    <li><a href="#contract">The data contract — <code>params</code> and <code>result</code></a></li>
    <li><a href="#before">① Client Before (JavaScript)</a></li>
    <li><a href="#server">② Server (PHP)</a></li>
    <li><a href="#after">③ Client After (JavaScript)</a></li>
    <li><a href="#ppjs">Nicer dialogs &amp; requests with <code>pp.js</code></a></li>
    <li><a href="#controls">Reach any field with <code>Runner</code></a></li>
    <li><a href="#recipes">Recipes worth stealing</a></li>
    <li><a href="#reference">Reference — what's in scope, stage by stage</a></li>
    <li><a href="#authoring">Author in the Designer <em>or</em> the Events module</a></li>
    <li><a href="#security">Security model</a></li>
</ul>

<!-- =========================================== -->
<h2 id="model">The three-stage model</h2>
<p>Every stage is <strong>optional</strong>. Fill only the ones you need and the rest are skipped:</p>
<ul>
    <li><strong>Client&nbsp;Before only</strong> — a pure front-end button (copy to clipboard, toggle a panel).</li>
    <li><strong>+ Server</strong> — when you need the database or anything trusted.</li>
    <li><strong>+ Client&nbsp;After</strong> — to respond once the server answers.</li>
</ul>
<pre><code>  ①  Client Before  ──(params)──▶  ②  Server  ──(result)──▶  ③  Client After
      browser · JS                     PHP                        browser · JS
   prepare &amp; guard          read $params, touch the DB,        show a message,
   return false to cancel        set $result                   refresh, redirect…</code></pre>

<!-- =========================================== -->
<h2 id="contract">The data contract</h2>
<p>Two objects travel the pipeline — that is the entire model.</p>
<h3><code>params</code> — you fill it (browser → server)</h3>
<p>In Client&nbsp;Before you put anything you like on the <code>params</code> object. It arrives on the server as
PHP's <code>$params</code> array — same keys, same values.</p>
<pre><code>// Client Before (JavaScript)
params.id   = btn.dataset.id;          // read a data-attribute on the button
params.note = document.querySelector('#note').value;</code></pre>
<h3><code>result</code> — the server fills it (server → browser)</h3>
<p>On the server you build up the <code>$result</code> array. It comes back to Client&nbsp;After as the
<code>result</code> object, ready to read.</p>
<pre><code>// Server (PHP)
$result['saved'] = true;
$result['total'] = dbValue('SELECT COUNT(*) FROM orders');</code></pre>

<div class="help-callout help-callout-warn">
    <i class="bi bi-arrow-counterclockwise"></i>
    <div><strong>Cancel up front.</strong> If Client&nbsp;Before <code>return false</code>, the whole action stops —
        no server call and no Client&nbsp;After. Perfect for “Are you sure?” and validation gates.</div>
</div>

<!-- =========================================== -->
<h2 id="before">① Client Before — JavaScript</h2>
<p>Plain JavaScript that runs the instant the button is clicked, before anything leaves the page.</p>
<table class="help-table">
    <thead><tr><th>Available</th><th>What it is</th></tr></thead>
    <tbody>
        <tr><td><code>params</code></td><td>Object to fill; sent to the server as <code>$params</code>.</td></tr>
        <tr><td><code>btn</code> / <code>this</code></td><td>The clicked button element — read data-attributes, change its label, disable it.</td></tr>
        <tr><td><code>return false</code></td><td>Cancels the whole action; nothing after this runs.</td></tr>
    </tbody>
</table>
<pre><code>// Gather input and guard before doing anything
params.id = btn.closest('tr')?.dataset.id;   // e.g. a button inside a grid row
if (!params.id) {
    alert('No record selected.');
    return false;                            // stop — no server call
}</code></pre>

<!-- =========================================== -->
<h2 id="server">② Server — PHP</h2>
<p>PHP that runs on the server — only when this box has code — reached over the built-in bridge. The visitor must
be signed in, and the request is CSRF-checked automatically. Read <code>$params</code>, do the work, set
<code>$result</code>.</p>
<p>The <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/DB">database helper façade</a>
is in scope, plus every core helper the app ships:</p>
<table class="help-table">
    <thead><tr><th>Helper</th><th>Returns</th><th>Use for</th></tr></thead>
    <tbody>
        <tr><td><code>dbValue($sql, $params)</code></td><td>one cell</td><td>a count, a single field</td></tr>
        <tr><td><code>dbRow($sql, $params)</code></td><td>one row (assoc array)</td><td>a single record</td></tr>
        <tr><td><code>dbAll($sql, $params)</code></td><td>every row</td><td>a list</td></tr>
        <tr><td><code>dbExecute($sql, $params)</code></td><td>affected rows</td><td>insert / update / delete</td></tr>
    </tbody>
</table>
<pre><code>// Read a value in safely, do a write, and report back
$id = (int) ($params['id'] ?? 0);

dbExecute('UPDATE orders SET status = ?, note = ? WHERE id = ?',
          ['approved', $params['note'] ?? '', $id]);

$result['ok']    = true;
$result['total'] = dbValue('SELECT COUNT(*) FROM orders WHERE status = ?', ['approved']);</code></pre>
<div class="help-callout help-callout-info">
    <i class="bi bi-shield-check"></i>
    <div>Always pass values as the <strong>second array argument</strong> — they are bound as parameters, never
        glued into the SQL — so user input can never break your query. See the
        <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/DB">Database Helpers</a> guide.</div>
</div>

<!-- =========================================== -->
<h2 id="after">③ Client After — JavaScript</h2>
<p>JavaScript again, once the server answers — with the outcome in hand.</p>
<table class="help-table">
    <thead><tr><th>Available</th><th>What it is</th></tr></thead>
    <tbody>
        <tr><td><code>result</code></td><td>The server's <code>$result</code>, decoded from JSON.</td></tr>
        <tr><td><code>params</code></td><td>Still available for context.</td></tr>
        <tr><td><code>btn</code> / <code>this</code></td><td>The button element.</td></tr>
    </tbody>
</table>
<pre><code>// React to what came back
if (result.ok) {
    location.reload();                       // refresh the list to show the new status
} else {
    alert(result.error || 'Something went wrong.');
}</code></pre>

<!-- =========================================== -->
<h2 id="ppjs">Nicer dialogs &amp; requests with <code>pp.js</code></h2>
<p>Generated apps ship the <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/PPjs"><code>pp.js</code>
client API</a>, so your Client&nbsp;Before / Client&nbsp;After code can use polished dialogs and helpers instead of
raw <code>alert()</code>/<code>confirm()</code>. Every method degrades gracefully to a native dialog when the rich
UI library isn't present, so it is always safe to call.</p>
<pre><code>// Client Before — a real confirm dialog (pp.dialog returns a Promise)
if (!await pp.dialog.confirm('Approve this order?', 'This cannot be undone.')) {
    return false;
}
params.id = btn.dataset.id;</code></pre>
<pre><code>// Client After — a toast instead of an alert
pp.dialog.toast('success', 'Approved — ' + result.total + ' orders now active.');</code></pre>
<p>Handy pieces of <code>pp.js</code> inside button code:</p>
<ul>
    <li><code>pp.dialog.toast(icon, title)</code> — corner toast (<code>success</code>/<code>error</code>/<code>warning</code>/<code>info</code>).</li>
    <li><code>await pp.dialog.confirm(title, text)</code> — returns <code>true</code>/<code>false</code>.</li>
    <li><code>await pp.dialog.prompt(title, placeholder)</code> — returns the typed text.</li>
    <li><code>pp.util.formatDate(value)</code>, <code>pp.util.escapeHtml(str)</code> — formatting helpers.</li>
    <li><code>pp.ajax.get(url)</code> / <code>pp.ajax.post(url, data)</code> — if you ever need a second call of your own.</li>
</ul>
<div class="help-callout help-callout-info">
    <i class="bi bi-lightbulb"></i>
    <div>Because <code>pp.dialog.confirm</code> is asynchronous, use <code>await</code> in Client&nbsp;Before and
        <code>return false</code> after it to cancel — exactly as shown above.</div>
</div>

<!-- =========================================== -->
<h2 id="controls">Reach any field with <code>Runner</code></h2>
<p>Inside Client&nbsp;Before / Client&nbsp;After you rarely want to hand-write DOM look-ups. The
<strong><code>Runner</code></strong> API (part of <code>pp.js</code>) gives you every field on the page by its
database name — read it, write it, hide it, validate it — in one clean, chainable call.</p>
<pre><code>var c = Runner.getControl('first_name');   // grab a control by field name

c.getValue();                 // read the current value (text, select, checkbox…)
c.setValue('Ada');            // write it (fires change, so plugins react)
c.getText();                  // the visible text (e.g. a select's label)
c.hide();  c.show();          // hide / show the whole field (label included)
c.disable();  c.enable();
c.focus();  c.clear();
c.required(true);
c.setMessage('This is required');   // inline red message under the field
c.clearMessage();
c.on('change', function () { /* … */ });</code></pre>
<p>Page-level shortcuts — no need to touch each field one by one:</p>
<pre><code>Runner.getControlValue('email');            // one value, no intermediate variable
Runner.setControlValue('status', 'active');

var data = Runner.getData();                // { first_name:'Ada', email:'…', … } — every field
Runner.setData({ status: 'active', flagged: true });

Runner.fields();       // ['first_name','last_name','email', …]
Runner.getPageId();    // the table this page edits
Runner.hideField('internal_note');</code></pre>
<div class="help-callout help-callout-info">
    <i class="bi bi-magic"></i>
    <div>Every method returns the control, so you can chain:
        <code>Runner.getControl('email').setValue('').focus().setMessage('Enter a valid email');</code>
        And <code>Runner.getControl('unknown')</code> never throws — it just returns a control whose
        <code>.exists()</code> is <code>false</code>.</div>
</div>
<p><strong>Two signatures, one behaviour.</strong> <code>Runner.getControl('field')</code> works on the current
page; the PHPRunner-style <code>Runner.getControl(pageId, 'field')</code> is also accepted when you want to scope
to a specific page container. Both are documented in the
<a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/PPjs">PP.js guide</a>.</p>

<!-- =========================================== -->
<h2 id="recipes">Recipes worth stealing</h2>

<h3>0 · Send a field's value to the server, write the answer back <span class="help-badge-dev" style="background:#e7e2fb;color:#5b4bc0">before · server · after</span></h3>
<p>The everyday shape: read a control, send it, and drop the result into another control — no DOM code.</p>
<pre><code>// ① Client Before — read the control, guard, and pass it on
params.email = Runner.getControlValue('email');
if (!params.email) { Runner.getControl('email').focus().setMessage('Enter an email first'); return false; }

// ② Server — look it up
$result['name'] = dbValue('SELECT full_name FROM contacts WHERE email = ?', [$params['email']]) ?: '';

// ③ Client After — write the answer into another field
Runner.setControlValue('full_name', result.name);
Runner.getControl('email').clearMessage();</code></pre>

<h3>1 · Confirm, then count <span class="help-badge-dev" style="background:#e7e2fb;color:#5b4bc0">before · server · after</span></h3>
<p>Ask before doing anything, run an aggregate on the server, report the number back.</p>
<pre><code>// ① Client Before
if (!await pp.dialog.confirm('Recount orders now?')) return false;

// ② Server
$result['total'] = (int) dbValue('SELECT COUNT(*) FROM orders');

// ③ Client After
pp.dialog.toast('info', 'Orders: ' + result.total);</code></pre>

<h3>2 · Approve the current record <span class="help-badge-dev" style="background:#e7e2fb;color:#5b4bc0">before · server · after</span></h3>
<p>Grab an id from the row, write to the database, then refresh.</p>
<pre><code>// ① Client Before
params.id = btn.dataset.id;
if (!params.id) return false;

// ② Server
dbExecute('UPDATE tickets SET status = ? WHERE id = ?', ['approved', $params['id']]);
$result['ok'] = true;

// ③ Client After
if (result.ok) location.reload();</code></pre>

<h3>3 · Copy a value — no server needed <span class="help-badge-dev" style="background:#fbf1dd;color:#b5791a">before only</span></h3>
<p>Some buttons never leave the browser. Leave Server and Client&nbsp;After empty.</p>
<pre><code>// ① Client Before
navigator.clipboard.writeText(document.querySelector('#invoiceNo').textContent);
pp.dialog.toast('success', 'Invoice number copied');
return false;   // nothing more to do — stop here</code></pre>

<h3>4 · Look something up, then decide <span class="help-badge-dev" style="background:#e2f4ee;color:#0f7a5f">server · after</span></h3>
<p>No prep needed — go straight to the server, and branch on what it returns.</p>
<pre><code>// ② Server
$row = dbRow('SELECT stock, name FROM parts WHERE id = ?', [$params['id'] ?? 0]);
$result['low']  = ((int) ($row['stock'] ?? 0)) &lt; 5;
$result['name'] = $row['name'] ?? '?';

// ③ Client After
pp.dialog.alert(
    result.low ? result.name + ' is running low.' : 'Stock is healthy.',
    '', result.low ? 'warning' : 'success');</code></pre>

<h3>5 · Send a notification / email <span class="help-badge-dev" style="background:#e2f4ee;color:#0f7a5f">server · after</span></h3>
<p>The server stage can reach any core helper — raise an in-app notification, queue an email, and so on.</p>
<pre><code>// ② Server — uses the app's built-in helpers (see the Notifications / Email guides)
$id = (int) ($params['id'] ?? 0);
$order = dbRow('SELECT customer_email, ref FROM orders WHERE id = ?', [$id]);
pp_send_email($order['customer_email'], 'Your order shipped', 'Ref ' . $order['ref'] . ' is on its way.');
$result['sent'] = true;

// ③ Client After
if (result.sent) pp.dialog.toast('success', 'Customer notified.');</code></pre>

<h3>6 · Bulk action on the selected grid rows <span class="help-badge-dev" style="background:#e7e2fb;color:#5b4bc0">before · server · after</span></h3>
<p>Drop a Custom&nbsp;Button on a <strong>list</strong> page and act on whatever rows are ticked — Runner reads the
selection for you.</p>
<pre><code>// ① Client Before — collect the ticked rows
params.ids = Runner.getSelectedKeys();
if (!params.ids.length) { pp.dialog.toast('warning', 'Select at least one row first.'); return false; }

// ② Server — update them all (bind an IN-list safely)
$ids = array_map('intval', (array) ($params['ids'] ?? []));
if ($ids) {
    $in = implode(',', array_fill(0, count($ids), '?'));
    dbExecute("UPDATE orders SET status = 'shipped' WHERE id IN ($in)", $ids);
}
$result['count'] = count($ids);

// ③ Client After
pp.dialog.toast('success', result.count + ' order(s) marked shipped.');
Runner.reloadGrid();</code></pre>

<h3>6b · Collect several inputs in one dialog, then act <span class="help-badge-dev" style="background:#e7e2fb;color:#5b4bc0">before · server · after</span></h3>
<p>When a button needs more than a yes/no, open a <strong>multi-field dialog</strong> with
<code>pp.dialog.form()</code> in <em>Client&nbsp;Before</em>, hand the answers to the server through
<code>params</code>, and report back in <em>Client&nbsp;After</em>. This is the modern replacement for a chain of
prompts — text, dropdowns, dates and checkboxes in a single window.</p>
<pre><code>// ① Client Before — ask, then pass the answers to the server
var form = await pp.dialog.form({
    title: 'Email these results',
    header: 'Where should we send the export?',
    fields: [
        { name: 'email',   label: 'Send to',   type: 'email', value: Runner.getControlValue('customer_email') },
        { name: 'subject', label: 'Subject',   value: 'Your report' },
        { name: 'format',  label: 'Format',    options: ['PDF', 'CSV', 'Excel'] },
        { name: 'note',    label: 'Add a note', type: 'textarea' },
        { name: 'copyme',  label: 'Send me a copy', type: 'checkbox', value: true },
    ],
    ok: 'Send',
});
if (!form) return false;          // user cancelled → skip the server + after stages
params.mail = form;               // the whole { email, subject, format, note, copyme } object

// ② Server — everything the user typed is in $params['mail']
$m = (array) ($params['mail'] ?? []);
$to = filter_var($m['email'] ?? '', FILTER_VALIDATE_EMAIL);
if (!$to) { $result['error'] = 'A valid email is required.'; return; }
pp_send_email($to, (string) ($m['subject'] ?? 'Report'),
    'Format: ' . ($m['format'] ?? 'PDF') . "\n\n" . ($m['note'] ?? ''));
$result['sentTo'] = $to;

// ③ Client After
if (result.error) { pp.dialog.toast('error', result.error); return; }
pp.dialog.toast('success', 'Sent to ' + result.sentTo + '.');</code></pre>
<p class="help-note"><strong>Field types:</strong> <code>text</code> (default), <code>textarea</code>,
<code>number</code>, <code>email</code>, <code>password</code>, <code>date</code>, <code>checkbox</code>, and
<code>select</code> (pass <code>options: ['A','B']</code> or <code>[{value,label}]</code>). The dialog resolves to
<code>{ name: value }</code> on OK, or <code>null</code> on Cancel — always guard with <code>if (!form) return false;</code>.</p>

<h3>6c · Let the <em>server</em> drive the page — <code>pp()</code> <span class="help-badge-dev" style="background:#e2f4ee;color:#0f7a5f">server only</span></h3>
<p>The Server stage has <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/pp_server_api"><code>pp()</code></a>,
the PHP twin of Runner. It can read the current record, do the work, and <strong>command the browser</strong> — set a
field, toast, reload the grid — so you often need <em>no</em> Client After code at all.</p>
<pre><code>// ② Server — approve the record this button sits on, and reflect it live
if (!pp()->can('edit', 'orders')) { pp()->toast('error', 'Not allowed.'); return; }
$o = pp()->record();                                  // the current record — no id wiring needed
pp()->update('orders', $o['id'], ['status' => 'approved', 'approved_by' => pp()->username()]);
pp()->setControl('status', 'approved')                // ← drives the field in the browser
    ->toast('success', 'Order approved')
    ->reloadGrid();
// (no Client After needed — the commands above are applied by pp.js automatically)</code></pre>
<p>On a <strong>list</strong> page the same button can act on every ticked row — the selection travels to the server
for you:</p>
<pre><code>// ② Server — bulk action on the rows the user ticked
pp()->eachSelected(function ($row) {
    pp()->update('orders', $row['id'], ['status' => 'shipped']);
});
pp()->toast('success', count(pp()->selectedRecords()) . ' shipped')->reloadGrid();</code></pre>

<h3>7 · Master/Detail — total the child rows onto the master <span class="help-badge-dev" style="background:#fbf1dd;color:#b5791a">before only</span></h3>
<p>On an order's edit page, read the line-items detail grid and write the sum back into the master's Total field —
no server call needed.</p>
<pre><code>// ① Client Before (client-only button)
var items = Runner.detail('order_items').getAllRecords();
var total = items.reduce(function (s, it) { return s + (+it.qty) * (+it.price); }, 0);
Runner.setControlValue('total', total);
pp.dialog.toast('success', items.length + ' line(s) · total ' + total);
return false;</code></pre>

<h3>8 · Master/Detail — recompute &amp; save every child line <span class="help-badge-dev" style="background:#fbf1dd;color:#b5791a">before only</span></h3>
<p>Read the detail grid, write a computed value back into each child row, and persist it — two-way, no server code.</p>
<pre><code>// ① Client Before (client-only)
var d = Runner.detail('order_items');
for (var it of d.getAllRecords()) {
    await d.setCellValue(it.id, 'line_total', (+it.qty) * (+it.price));   // saves each row
}
pp.dialog.toast('success', 'Line totals recalculated.');
d.reload();
return false;</code></pre>

<h3>9 · Validate a form before submit <span class="help-badge-dev" style="background:#fbf1dd;color:#b5791a">before only</span></h3>
<pre><code>// ① Client Before
var email = document.querySelector('#email').value.trim();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    pp.dialog.toast('error', 'Please enter a valid email first.');
    return false;
}
document.querySelector('#myForm').submit();
return false;   // we submitted the form ourselves</code></pre>

<!-- =========================================== -->
<h2 id="reference">Reference — what's in scope, stage by stage</h2>
<table class="help-table">
    <thead><tr><th>Stage</th><th>Name</th><th>Type</th><th>What it is</th></tr></thead>
    <tbody>
        <tr><td>① Before</td><td><code>params</code></td><td>object</td><td>Yours to fill; sent to the server as <code>$params</code>.</td></tr>
        <tr><td>① Before</td><td><code>btn</code> / <code>this</code></td><td>Element</td><td>The clicked button.</td></tr>
        <tr><td>① Before</td><td><code>return false</code></td><td>—</td><td>Cancels the whole action.</td></tr>
        <tr><td>② Server</td><td><code>$params</code></td><td>array</td><td>Exactly what Client Before put on <code>params</code>.</td></tr>
        <tr><td>② Server</td><td><code>$result</code></td><td>array</td><td>Build it up; returned to Client After as <code>result</code>.</td></tr>
        <tr><td>② Server</td><td><code>dbValue/dbRow/dbAll/dbExecute</code></td><td>fn</td><td>The database façade — parameters bound safely.</td></tr>
        <tr><td>② Server</td><td>core helpers</td><td>fn</td><td>Email, notifications, PDF, and the rest of the app's API.</td></tr>
        <tr><td>③ After</td><td><code>result</code></td><td>object</td><td>The server's <code>$result</code>, decoded from JSON.</td></tr>
        <tr><td>③ After</td><td><code>params</code>, <code>btn</code></td><td>object · Element</td><td>Still on hand for context.</td></tr>
        <tr><td>①③ Client</td><td><code>Runner.getControl(field)</code></td><td>API</td><td>Reach any field on the page by name — value, visibility, events, messages.</td></tr>
        <tr><td>①③ Client</td><td><code>pp.dialog</code>, <code>pp.ajax</code>, <code>pp.util</code></td><td>API</td><td>The shipped <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/PPjs">pp.js</a> client library.</td></tr>
    </tbody>
</table>

<!-- =========================================== -->
<h2 id="authoring">Author in the Designer <em>or</em> the Events module</h2>
<p>Two front doors, one source of truth. Type the code wherever you think best — the app records it as a single
event either way and keeps both views in step.</p>
<ul>
    <li><strong>Designer · in place.</strong> Select a Custom&nbsp;Button and its three boxes sit right in the
        Properties panel, beside the label and icon. Best when you're shaping the page and its logic together.</li>
    <li><strong>Events module · alongside the rest.</strong> Every button's handlers are recorded as events, so they
        live beside your login, table and email events. Best when you think in terms of logic first.</li>
</ul>
<div class="help-callout help-callout-info">
    <i class="bi bi-arrow-left-right"></i>
    <div>Edit in one place and the other updates on the next save — the handler is stored once and mirrored, so
        there is never a “which copy is right?”. <strong>Rebuild the app</strong> to ship your changes.</div>
</div>

<!-- =========================================== -->
<h2 id="security">Security model</h2>
<ul>
    <li><strong>Login required.</strong> The Server stage runs behind the same guard as every built-in endpoint —
        the visitor must be signed in.</li>
    <li><strong>CSRF-checked.</strong> Each call carries a token that is verified before a line of your PHP runs.</li>
    <li><strong>Compiled, not evaluated.</strong> Your server code is baked into the app at build time, never
        evaluated from the request — so nothing a visitor types can ever become code. It is author-trusted, exactly
        like PHP snippets and Events code.</li>
    <li><strong>Parameterised queries.</strong> Pass values through the second array argument of the DB helpers and
        user input can never alter your SQL.</li>
</ul>

<div class="help-callout help-callout-info">
    <i class="bi bi-signpost-2"></i>
    <div><strong>See also:</strong>
        <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/PPjs">PP.js — Frontend API</a> ·
        <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/DB">Database Helpers</a> ·
        <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/Helpers">Helpers</a> ·
        <a href="<?= htmlspecialchars(rtrim((string)($baseUrl ?? ''), '/')) ?>/help/Notification">Notifications</a></div>
</div>
