Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Syntax-check the theme's browser assets
# Every shipped browser asset, not just the theme's: a view registers its behavior from
# modules/<slug>/assets/js and public/js too (TIGER-384), and a syntax error in one of those
# kills the screen's JS silently behind a 200. Vendored third-party bundles are excluded —
# they are not ours to fix, and several are minified ES the checker trips over.
- name: Syntax-check the shipped browser assets
run: |
for f in themes/puma/assets/js/*.js; do node --check "$f"; done
find themes modules public core library -name '*.js' \
-not -path '*/vendor/*' -not -path '*/node_modules/*' -print0 \
| xargs -0 -n1 node --check
- name: Tiger.t() contract
run: node tests/js/tiger.i18n.test.js
- name: TigerButton busy state
Expand Down
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,45 @@ The rule of thumb: **a control should always say what it's doing.** A save butto
there, or a message that blinks into existence and never leaves, is the tell of a cheap UI. These
helpers are the house style — every admin + auth screen already uses them; match that.

## Views: a `.phtml` owns MARKUP, never behavior or styling

**No `<script>` and no `<style>` tag in a view — not an inline blob, and not a `<script src>`
either.** Behavior is a JS asset file, styling is the skin's job, and the theme **layout** is the
one place a tag is emitted. A view *registers* what it needs and the layout emits it:

```php
$this->pageScript('/_modules/shop/js/shop.cart.js', ['defer' => true]); // → the pageScripts slot
$this->pageScript($this->themeAssets . '/vendor/sortable/Sortable.js'); // a vendored lib, same way
$this->pageStyle('/_modules/shop/css/shop.css'); // → the pageHead slot
```

Both cache-bust through `asset()`, pass a remote URL through untouched, dedupe by URL, and keep
registration order — so a library still loads ahead of the script that uses it. `style="…"` on an
element is fine; a `<style>` block is not.

Three things a script used to reach for a PHP tag to get, and where they come from instead:

| Needs | Use |
|---|---|
| a localized string | `$this->i18n(['saved' => 'shop.cart.saved'])` in the view → `Tiger.t('saved')` in the JS |
| a server value (an id, a URL, a config flag, a JSON payload) | a `data-*` attribute on a container the JS reads |
| a list of rows | `/api` — never a payload baked into the page (WEBSERVICES.md §5) |

Why it is a hard rule and not a preference: a view full of behavior cannot be reskinned, cannot be
cached, cannot be CSP'd, and cannot be read — and an inline blob is invisible to every JS tool in
the repo. Keeping the i18n in the carrier has a second payoff: the page ships only the strings its
own JS uses, as translated *values* under generic aliases, so viewing source never exposes the key
taxonomy.

Three structural exemptions, none a matter of taste: an **email template** (mail clients drop
`<link>`, so inline CSS is the correct answer); a view that **is the document** — a full-screen
surface whose controller disabled the layout, like the CMS builder, which is a layout wearing a
view's filename and has no layout to register into; and a **JSON-LD** `<script
type="application/ld+json">`, which carries data rather than behavior and must be inline by spec.

`php tests/ui/bin/paradigm-scan.php` reports every violation across the tree, with those two
exemptions built in.

## Services (`/api`) — the canonical flow

Services extend `Tiger_Service_Service`; the message's `method` names the action, which
Expand Down
3 changes: 2 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> before assuming something isn't built. `@api` = stable to build on; `@internal` = may change.
> Grouped by **capability** (across layers), not by directory.

**246 classes** across **33 capabilities** · **27 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).
**247 classes** across **33 capabilities** · **27 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).

## Capabilities (`library/Tiger`)

Expand Down Expand Up @@ -281,6 +281,7 @@
- **Tiger_View_Helper_LocaleView** `@api` — render the LOCALE variant of a view when one ships, else the default. · `library/Tiger/View/Helper/LocaleView.php`
- **Tiger_View_Helper_MediaField** `@api` — a form field that picks media via TigerMediaPicker. · `library/Tiger/View/Helper/MediaField.php`
- **Tiger_View_Helper_Menu** `@api` — render a custom menu in a view: `<?= $this->menu('primary') ?>`. · `library/Tiger/View/Helper/Menu.php`
- **Tiger_View_Helper_PageAsset_Abstract** `@internal` — the shared slot mechanics behind `pageScript()`/`pageStyle()`. · `library/Tiger/View/Helper/PageAsset/Abstract.php`
- **Tiger_View_Helper_PageField** `@api` — read a custom field value (Tiger_Fields) from a page on the front end. · `library/Tiger/View/Helper/PageField.php`
- **Tiger_View_Helper_PageScript** `@api` — register a page-specific JS file from a view WITHOUT a `<script>` tag. · `library/Tiger/View/Helper/PageScript.php`
- **Tiger_View_Helper_PageStyle** `@api` — register a page-specific stylesheet from a view WITHOUT a `<style>` tag. · `library/Tiger/View/Helper/PageStyle.php`
Expand Down
192 changes: 3 additions & 189 deletions core/views/scripts/admin/index.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -84,199 +84,13 @@ $dashLead = $this->dashLead ?? 'core.dashboard.lead';
</div>
</div>

<style>
.dash-grid { position: relative; margin: 0 -.5rem; opacity: 0; }
.dash-grid.dash-ready { opacity: 1; transition: opacity .4s ease; }
.dash-item { position: absolute; width: 100%; padding: .5rem; box-sizing: border-box; z-index: 1; }
@media (min-width: 768px) { .dash-item { width: 50%; } .dash-item[data-w="3"], .dash-item[data-w="4"] { width: 100%; } }
@media (min-width: 1200px) { .dash-item { width: 33.333%; } .dash-item[data-w="2"] { width: 66.666%; } .dash-item[data-w="3"], .dash-item[data-w="4"] { width: 100%; } }
@media (min-width: 1600px) { .dash-item { width: 25%; } .dash-item[data-w="2"] { width: 50%; } .dash-item[data-w="3"] { width: 75%; } .dash-item[data-w="4"] { width: 100%; } }
.dash-item .card { margin: 0; }
.dash-handle { cursor: move; user-select: none; }
.dash-collapse { cursor: pointer; }
.muuri-item-dragging { z-index: 3; }
.muuri-item-dragging .dash-handle { cursor: grabbing; }
.muuri-item-releasing { z-index: 2; }
.muuri-item-hidden { z-index: 0; }
@media (prefers-reduced-motion: reduce) { .dash-item { transition: none !important; } .dash-grid.dash-ready { transition: none; } }
</style>
<?php $this->pageStyle('/_tiger/css/tiger.admin-index.css'); ?>

<?php
$this->i18n([
'widgetLoadError' => 'core.js.widget_load_error',
]);
?>
<script src="<?= $this->asset($this->themeAssets . '/vendor/muuri/muuri.min.js') ?>"></script>
<script>
(function () {
var el = document.getElementById('dash-grid');
if (!el) { return; }

// The grid starts at opacity 0 (CSS), so the browser never paints the pre-layout stack of
// absolutely-positioned tiles (the FOUC). We fade it in only once Muuri has arranged them.
// reveal() is idempotent; a 1s safety net guarantees the widgets show even if Muuri fails
// to load or init throws before the first layoutEnd.
var revealed = false;
function reveal() { if (revealed) { return; } revealed = true; el.classList.add('dash-ready'); }
setTimeout(reveal, 1000);
if (!window.Muuri) { reveal(); return; }

var grid = new Muuri(el, {
items: '.dash-item:not(.d-none)', // hidden widgets stay in the DOM but out of the grid
dragEnabled: true,
dragHandle: '.dash-handle', // the whole card header is the handle
layout: { fillGaps: false, rounding: true },
// Don't start a drag when the click is on the collapse toggle inside the header.
dragStartPredicate: function (item, e) {
if (e.target && e.target.closest && e.target.closest('.dash-collapse')) { return false; }
return Muuri.ItemDrag.defaultStartPredicate(item, e);
}
});

function debounce(fn, ms) { var t; return function () { clearTimeout(t); t = setTimeout(fn, ms); }; }
window.addEventListener('resize', debounce(function () { grid.refreshItems().layout(); }, 150));

// Seed each body's open/closed state for TigerDOM (server renders collapsed ones display:none).
el.querySelectorAll('.dash-body').forEach(function (body) { body.__tgOpen = (body.style.display !== 'none'); });

function persist() {
var order = grid.getItems().map(function (it) { return it.getElement().getAttribute('data-id'); });
var collapsed = {};
el.querySelectorAll('.dash-item').forEach(function (it) {
var body = it.querySelector('.dash-body');
if (body && body.__tgOpen === false) { collapsed[it.getAttribute('data-id')] = true; }
});
var fd = new URLSearchParams();
fd.set('module', 'system'); fd.set('service', 'dashboard'); fd.set('method', 'saveLayout');
fd.set('layout', JSON.stringify({ order: order, collapsed: collapsed }));
fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }).catch(function () {});
}

grid.on('dragReleaseEnd', persist);

// Collapse / expand a widget — animated via TigerDOM (the house reveal primitive), repacking the
// Muuri grid each frame so the masonry reflows smoothly as the card resizes.
el.addEventListener('click', function (e) {
var btn = e.target.closest('.dash-collapse');
if (!btn) { return; }
var item = btn.closest('.dash-item');
var body = item.querySelector('.dash-body');
var icon = btn.querySelector('i');
var mItems = grid.getItems(item);
var willOpen = !body.__tgOpen;
icon.className = 'fa-solid fa-chevron-' + (willOpen ? 'up' : 'down');

if (!window.TigerDOM) { // graceful fallback (no animation)
body.style.display = willOpen ? '' : 'none'; body.__tgOpen = willOpen;
if (mItems.length) { grid.refreshItems(mItems).layout(); }
persist(); return;
}
var animating = true;
(function tick() { if (!animating) { return; } if (mItems.length) { grid.refreshItems(mItems).layout(); } requestAnimationFrame(tick); })();
TigerDOM.toggle(body).then(function () {
animating = false;
if (mItems.length) { grid.refreshItems(mItems).layout(); }
persist();
});
});

// --- Customize: per-user widget visibility (WP "Screen Options" style) ------------------------
var custBtn = document.getElementById('dash-customize');
var modalEl = document.getElementById('dash-customize-modal');
var modal = null; // created lazily on first click — Bootstrap's JS loads in the layout
// footer, AFTER this inline script, so window.bootstrap isn't defined yet.

function itemById(id) {
var found = null;
el.querySelectorAll('.dash-item').forEach(function (it) { if (it.getAttribute('data-id') === id) { found = it; } });
return found;
}
function itemTitle(it) {
var s = it.querySelector('.dash-handle .text-truncate');
return s ? s.textContent : it.getAttribute('data-id');
}
function hiddenIds() {
var ids = [];
el.querySelectorAll('.dash-item.d-none').forEach(function (it) { ids.push(it.getAttribute('data-id')); });
return ids;
}
function persistPrefs() {
var fd = new URLSearchParams();
fd.set('module', 'system'); fd.set('service', 'dashboard'); fd.set('method', 'saveWidgetPrefs');
fd.set('hidden', JSON.stringify(hiddenIds()));
fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }).catch(function () {});
}

// Build the on/off list from EVERY widget in the DOM (visible + the hidden d-none shells).
function buildPrefs() {
var list = document.getElementById('dash-widget-prefs');
if (!list) { return; }
list.innerHTML = '';
el.querySelectorAll('.dash-item').forEach(function (it) {
var on = !it.classList.contains('d-none');
var row = document.createElement('label');
row.className = 'list-group-item d-flex align-items-center gap-3';
row.innerHTML = '<span class="form-check form-switch m-0"><input class="form-check-input dash-pref" type="checkbox" role="switch"' + (on ? ' checked' : '') + '></span>' +
'<span class="flex-grow-1 text-truncate"></span>';
row.querySelector('.dash-pref').setAttribute('data-id', it.getAttribute('data-id'));
row.querySelector('.text-truncate').textContent = itemTitle(it);
list.appendChild(row);
});
}

function showWidget(it) {
it.classList.remove('d-none');
var body = it.querySelector('.dash-body');
var addToGrid = function () {
if (!grid.getItems(it).length) { grid.add(it, { layout: true }); }
body.__tgOpen = (body.style.display !== 'none');
grid.refreshItems(grid.getItems(it)).layout();
};
if (body && body.getAttribute('data-lazy') === '1') { // never rendered on load → fetch it now
body.innerHTML = '<div class="text-center text-body-secondary py-3"><span class="spinner-border spinner-border-sm"></span></div>';
var fd = new URLSearchParams();
fd.set('module', 'system'); fd.set('service', 'dashboard'); fd.set('method', 'widgetBody');
fd.set('id', it.getAttribute('data-id'));
fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
.then(function (r) { return r.json(); })
.then(function (res) {
body.innerHTML = (res && res.result === 1 && res.data && res.data.html) ? res.data.html : '<p class="text-body-secondary small mb-0">' + Tiger.t('widgetLoadError') + '</p>';
body.removeAttribute('data-lazy');
addToGrid();
})
.catch(function () { body.innerHTML = '<p class="text-body-secondary small mb-0">' + Tiger.t('widgetLoadError') + '</p>'; body.removeAttribute('data-lazy'); addToGrid(); });
} else {
addToGrid();
}
}
function hideWidget(it) {
var mItems = grid.getItems(it);
if (mItems.length) { grid.remove(mItems, { removeElements: false }); }
it.classList.add('d-none');
grid.layout();
}

if (custBtn && modalEl) {
custBtn.addEventListener('click', function () {
if (!modal && window.bootstrap) { modal = new bootstrap.Modal(modalEl); }
buildPrefs();
if (modal) { modal.show(); }
});
}
if (modalEl) {
modalEl.addEventListener('change', function (e) {
var cb = e.target.closest('.dash-pref');
if (!cb) { return; }
var it = itemById(cb.getAttribute('data-id'));
if (!it) { return; }
if (cb.checked) { showWidget(it); } else { hideWidget(it); }
persistPrefs();
});
}

// Fade the grid in once the first layout settles (see reveal() above) — kills the FOUC.
grid.on('layoutEnd', function onFirstLayout() { grid.off('layoutEnd', onFirstLayout); reveal(); });
grid.layout();
})();
</script>
<?php $this->pageScript($this->themeAssets . '/vendor/muuri/muuri.min.js'); ?>
<?php $this->pageScript('/_tiger/js/tiger.admin-index.js', ['defer' => true]); ?>
<?php endif; ?>
42 changes: 1 addition & 41 deletions core/views/scripts/auth/forgot.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -36,44 +36,4 @@ $this->i18n([
'recaptcha' => 'core.js.recaptcha',
]);
?>
<script>
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('forgot-form');
var fb = document.getElementById('forgot-feedback');
var btn = document.getElementById('forgot-submit');

function rcReset() { if (window.grecaptcha) { try { grecaptcha.reset(); } catch (e) {} } }

form.addEventListener('submit', function (e) {
e.preventDefault();
fb.innerHTML = '';
var email = document.getElementById('forgot-email').value;

TigerButton.run(btn, function () {
return fetch('/auth/forgot', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: new URLSearchParams(new FormData(form))
})
.then(function (r) { return r.json().catch(function () { return {}; }); });
})
.then(function (res) {
// A reCAPTCHA block is a real error (not account enumeration) — surface it.
if (res && res.result === 0 && res.recaptcha) {
rcReset();
TigerDOM.notify(fb, Tiger.t('recaptcha'), { type: 'error' });
return;
}
// Hide the form and confirm via notify. Escape the (user-supplied) email —
// notify takes HTML, so build the text safely rather than concatenating raw.
var msg = document.createElement('span');
msg.textContent = 'If an account exists for ' + email + ', a reset link is on its way. Check your inbox.';
form.style.display = 'none';
TigerDOM.notify(fb, msg.innerHTML, { type: 'success' });
})
.catch(function () {
rcReset();
});
});
});
</script>
<?php $this->pageScript('/_tiger/js/tiger.auth-forgot.js', ['defer' => true]); ?>
Loading
Loading