@floless/app 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +262 -23
- package/dist/skills/floless-app-report-issue/SKILL.md +127 -0
- package/dist/web/app.css +221 -0
- package/dist/web/app.js +1 -0
- package/dist/web/aware.js +542 -62
- package/dist/web/index.html +59 -0
- package/package.json +1 -1
package/dist/web/aware.js
CHANGED
|
@@ -1585,11 +1585,194 @@
|
|
|
1585
1585
|
_handleMenuAction(action);
|
|
1586
1586
|
};
|
|
1587
1587
|
|
|
1588
|
+
// ── Release notes: shared state + popover ─────────────────────────────────────
|
|
1589
|
+
// The channel-correct public site base (e.g. https://floless.io), captured from
|
|
1590
|
+
// /api/health in the health poll. The changelog deep-link is omitted entirely when
|
|
1591
|
+
// this is empty — never hardcode floless.io (it would be wrong on stage/dev).
|
|
1592
|
+
let webBase = '';
|
|
1593
|
+
// The what's-new panel reveals at most once per session (guard against the 5s
|
|
1594
|
+
// health poll re-triggering it after the user expands/dismisses).
|
|
1595
|
+
let whatsNewShown = false;
|
|
1596
|
+
|
|
1597
|
+
const $notesPopover = document.getElementById('notes-popover');
|
|
1598
|
+
|
|
1599
|
+
// ONE renderer for typed change bullets — used by BOTH the popover body and the
|
|
1600
|
+
// what's-new panel detail. Groups bullets by change type (first-seen order); each
|
|
1601
|
+
// group is a typeset label + a bullet list. Only `.added` borrows --accent (CSS).
|
|
1602
|
+
function renderTypedChanges(container, changes) {
|
|
1603
|
+
if (!container) return;
|
|
1604
|
+
const groups = [];
|
|
1605
|
+
const byType = new Map();
|
|
1606
|
+
for (const c of (Array.isArray(changes) ? changes : [])) {
|
|
1607
|
+
const type = String((c && c.type) || '').trim() || 'changed';
|
|
1608
|
+
const desc = String((c && c.description) || '').trim();
|
|
1609
|
+
if (!desc) continue;
|
|
1610
|
+
if (!byType.has(type)) { byType.set(type, []); groups.push(type); }
|
|
1611
|
+
byType.get(type).push(desc);
|
|
1612
|
+
}
|
|
1613
|
+
if (!groups.length) { container.innerHTML = '<p class="relnotes-unavailable">No itemised changes</p>'; return; }
|
|
1614
|
+
container.innerHTML = groups.map((type) => {
|
|
1615
|
+
const items = byType.get(type).map((d) => `<li class="relnotes-item">${escapeHtml(d)}</li>`).join('');
|
|
1616
|
+
return `<div class="relnotes-group">`
|
|
1617
|
+
+ `<div class="relnotes-type ${escapeAttr(type)}">${escapeHtml(type)}</div>`
|
|
1618
|
+
+ `<ul class="relnotes-list">${items}</ul></div>`;
|
|
1619
|
+
}).join('');
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Open the shared release-notes popover for an update pill (or the AWARE version
|
|
1623
|
+
// label). The action row — Update now + the changelog deep-link — renders FIRST,
|
|
1624
|
+
// so "Update now" is live before (and regardless of whether) the notes arrive.
|
|
1625
|
+
// `onUpdate` is optional: the post-AWARE-upgrade label reopen passes none (read-only).
|
|
1626
|
+
let _notesEscHandler = null, _notesOutsideHandler = null, _notesOriginEl = null;
|
|
1627
|
+
function closeNotesPopover() {
|
|
1628
|
+
if (!$notesPopover || $notesPopover.hidden) return;
|
|
1629
|
+
$notesPopover.hidden = true;
|
|
1630
|
+
$notesPopover.innerHTML = '';
|
|
1631
|
+
if (_notesEscHandler) { document.removeEventListener('keydown', _notesEscHandler, true); _notesEscHandler = null; }
|
|
1632
|
+
if (_notesOutsideHandler) { document.removeEventListener('mousedown', _notesOutsideHandler, true); _notesOutsideHandler = null; }
|
|
1633
|
+
const origin = _notesOriginEl; _notesOriginEl = null;
|
|
1634
|
+
if (origin) {
|
|
1635
|
+
origin.setAttribute('aria-expanded', 'false');
|
|
1636
|
+
origin.removeAttribute('aria-controls');
|
|
1637
|
+
try { origin.focus(); } catch { /* origin may have hidden (pill → relaunch) */ }
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
function openNotesPopover({ component, version, anchorEl, onUpdate }) {
|
|
1641
|
+
if (!$notesPopover || !version) return;
|
|
1642
|
+
// Clean up a prior open instance's document listeners (reopening from a different
|
|
1643
|
+
// pill) without the focus-restore — the new anchor takes over below.
|
|
1644
|
+
if (_notesEscHandler) { document.removeEventListener('keydown', _notesEscHandler, true); _notesEscHandler = null; }
|
|
1645
|
+
if (_notesOutsideHandler) { document.removeEventListener('mousedown', _notesOutsideHandler, true); _notesOutsideHandler = null; }
|
|
1646
|
+
if (_notesOriginEl && _notesOriginEl !== anchorEl) { _notesOriginEl.setAttribute('aria-expanded', 'false'); _notesOriginEl.removeAttribute('aria-controls'); }
|
|
1647
|
+
const compLabel = component === 'aware' ? 'AWARE' : 'floless.app';
|
|
1648
|
+
$notesPopover.setAttribute('aria-label', compLabel + ' v' + version + ' release notes');
|
|
1649
|
+
|
|
1650
|
+
// Build the "full notes" link, component-aware:
|
|
1651
|
+
// - app → the floless.io changelog (floless.app-only by design). Omit when webBase is empty.
|
|
1652
|
+
// - aware → the PUBLIC AWARE GitHub release. A stable URL built straight from `version`
|
|
1653
|
+
// (no webBase, no dependency on the notes fetch) — AWARE is NOT on floless.io.
|
|
1654
|
+
const changelogHtml = component === 'aware'
|
|
1655
|
+
? `<a class="relnotes-changelog" target="_blank" rel="noopener" href="${escapeAttr('https://github.com/aware-aeco/aware/releases/tag/v' + version)}">Full release notes ↗</a>`
|
|
1656
|
+
: (webBase
|
|
1657
|
+
? `<a class="relnotes-changelog" target="_blank" rel="noopener" href="${escapeAttr(webBase + '/changelog#v' + version)}">Full changelog ↗</a>`
|
|
1658
|
+
: '');
|
|
1659
|
+
const updateHtml = onUpdate ? `<button type="button" class="relnotes-update">Update now</button>` : '';
|
|
1660
|
+
|
|
1661
|
+
// Render the shell: header (skeleton title), body (skeleton bars), action row (LIVE).
|
|
1662
|
+
$notesPopover.innerHTML =
|
|
1663
|
+
`<div class="relnotes-header"><div class="relnotes-title">${compLabel} v${escapeHtml(version)}</div></div>`
|
|
1664
|
+
+ `<div class="relnotes-body" aria-busy="true">`
|
|
1665
|
+
+ `<div class="relnotes-skel"><div class="relnotes-skel-line"></div><div class="relnotes-skel-line"></div><div class="relnotes-skel-line"></div></div>`
|
|
1666
|
+
+ `</div>`
|
|
1667
|
+
+ `<div class="relnotes-actions">${updateHtml}${changelogHtml}</div>`;
|
|
1668
|
+
$notesPopover.hidden = false;
|
|
1669
|
+
|
|
1670
|
+
// Wire Update now — it never waits on the notes fetch.
|
|
1671
|
+
const $update = $notesPopover.querySelector('.relnotes-update');
|
|
1672
|
+
if ($update && onUpdate) {
|
|
1673
|
+
$update.onclick = () => { closeNotesPopover(); onUpdate(version); };
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Position relative to the anchor: hang above it (footer is at the bottom), and
|
|
1677
|
+
// clamp the right edge so a wide pill near the left can't push it off-screen.
|
|
1678
|
+
if (anchorEl) {
|
|
1679
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
1680
|
+
$notesPopover.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
|
1681
|
+
$notesPopover.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// a11y: mark the originating control expanded; move focus into the dialog.
|
|
1685
|
+
_notesOriginEl = anchorEl || null;
|
|
1686
|
+
if (anchorEl) { anchorEl.setAttribute('aria-expanded', 'true'); anchorEl.setAttribute('aria-controls', 'notes-popover'); }
|
|
1687
|
+
try { $notesPopover.focus(); } catch { /* container is tabindex=-1 */ }
|
|
1688
|
+
|
|
1689
|
+
// Tab cycles only the (up to 2) action controls; Escape closes + restores focus.
|
|
1690
|
+
const focusables = () => Array.from($notesPopover.querySelectorAll('.relnotes-update, .relnotes-changelog'));
|
|
1691
|
+
_notesEscHandler = (e) => {
|
|
1692
|
+
if (e.key === 'Escape') { e.preventDefault(); closeNotesPopover(); return; }
|
|
1693
|
+
if (e.key === 'Tab') {
|
|
1694
|
+
const f = focusables();
|
|
1695
|
+
if (!f.length) return;
|
|
1696
|
+
const first = f[0], last = f[f.length - 1];
|
|
1697
|
+
const active = document.activeElement;
|
|
1698
|
+
if (e.shiftKey && (active === first || active === $notesPopover)) { e.preventDefault(); last.focus(); }
|
|
1699
|
+
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
document.addEventListener('keydown', _notesEscHandler, true);
|
|
1703
|
+
// Click outside closes — but ignore the opening click on the anchor itself.
|
|
1704
|
+
_notesOutsideHandler = (e) => {
|
|
1705
|
+
if ($notesPopover.contains(e.target) || (anchorEl && anchorEl.contains(e.target))) return;
|
|
1706
|
+
closeNotesPopover();
|
|
1707
|
+
};
|
|
1708
|
+
document.addEventListener('mousedown', _notesOutsideHandler, true);
|
|
1709
|
+
|
|
1710
|
+
// Fetch the notes and fill the body (or the unavailable state). The action row
|
|
1711
|
+
// is untouched — Update now and the changelog link stay live throughout.
|
|
1712
|
+
(async () => {
|
|
1713
|
+
const $body = $notesPopover.querySelector('.relnotes-body');
|
|
1714
|
+
let notes = null;
|
|
1715
|
+
try {
|
|
1716
|
+
const r = await fetch('/api/release-notes?component=' + encodeURIComponent(component) + '&version=' + encodeURIComponent(version));
|
|
1717
|
+
notes = await r.json();
|
|
1718
|
+
} catch { notes = null; }
|
|
1719
|
+
// The popover may have been closed/reopened while the fetch was in flight.
|
|
1720
|
+
if ($notesPopover.hidden || !$body || !$body.isConnected) return;
|
|
1721
|
+
$body.removeAttribute('aria-busy');
|
|
1722
|
+
if (notes && notes.ok) {
|
|
1723
|
+
const title = $notesPopover.querySelector('.relnotes-title');
|
|
1724
|
+
if (title && notes.title) title.textContent = notes.title;
|
|
1725
|
+
const summaryHtml = notes.summary ? `<div class="relnotes-summary">${escapeHtml(notes.summary)}</div>` : '';
|
|
1726
|
+
const header = $notesPopover.querySelector('.relnotes-header');
|
|
1727
|
+
if (header && summaryHtml && !header.querySelector('.relnotes-summary')) header.insertAdjacentHTML('beforeend', summaryHtml);
|
|
1728
|
+
$body.innerHTML = '<div class="relnotes-changes"></div>';
|
|
1729
|
+
renderTypedChanges($body.querySelector('.relnotes-changes'), notes.changes);
|
|
1730
|
+
} else {
|
|
1731
|
+
$body.innerHTML = '<p class="relnotes-unavailable">Release notes unavailable</p>';
|
|
1732
|
+
}
|
|
1733
|
+
})();
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1588
1736
|
// ── App self-update tag ───────────────────────────────────────────────────────
|
|
1589
1737
|
// Surface updater.ts: poll GET /api/update (on load + every 6h); when a newer
|
|
1590
|
-
// installed build exists, show a footer
|
|
1591
|
-
//
|
|
1738
|
+
// installed build exists, show a footer pill → click opens the release-notes
|
|
1739
|
+
// popover → Update now → POST /api/update/apply, which downloads + relaunches into
|
|
1740
|
+
// the new version. Hidden in dev/npm (supported:false).
|
|
1592
1741
|
const $appUpdate = document.getElementById('app-update');
|
|
1742
|
+
// The apply body, extracted so the popover's "Update now" can drive it with the
|
|
1743
|
+
// polled target version (no textContent scrape). Preserves the npm-vs-desktop
|
|
1744
|
+
// branch verbatim: npm copies the command; desktop confirms → relaunch.
|
|
1745
|
+
async function applyAppUpdate(version) {
|
|
1746
|
+
if (!$appUpdate) return;
|
|
1747
|
+
// npm channel can't self-apply (no Update.exe) — copy the command for the user's terminal.
|
|
1748
|
+
if ($appUpdate.dataset.channel === 'npm') {
|
|
1749
|
+
const cmd = $appUpdate.dataset.command || 'npm i -g @floless/app@latest';
|
|
1750
|
+
// Best-effort copy — fire-and-forget so the toast never blocks on clipboard permission.
|
|
1751
|
+
if (navigator.clipboard) navigator.clipboard.writeText(cmd).catch(() => {});
|
|
1752
|
+
showToast('Update in your terminal — ' + cmd, 'info');
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
// desktop (Velopack): one-click download + relaunch
|
|
1756
|
+
const v = version || '';
|
|
1757
|
+
if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
|
|
1758
|
+
// Record the pending version so the new build's first paint can reveal the
|
|
1759
|
+
// what's-new panel (survives the relaunch via localStorage; cleared once shown).
|
|
1760
|
+
try { localStorage.setItem('floless.whatsNew.pending', v); } catch { /* private mode */ }
|
|
1761
|
+
$appUpdate.disabled = true;
|
|
1762
|
+
$appUpdate.textContent = '↑ Updating…';
|
|
1763
|
+
try {
|
|
1764
|
+
const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
|
|
1765
|
+
const d = await r.json().catch(() => ({}));
|
|
1766
|
+
if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
|
|
1767
|
+
// Success: the server is exiting + Update.exe relaunches the new build. The health
|
|
1768
|
+
// poll flips to offline (R1 overlay), then the new version reconnects on its own.
|
|
1769
|
+
} catch (e) {
|
|
1770
|
+
$appUpdate.disabled = false;
|
|
1771
|
+
try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
|
|
1772
|
+
showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
|
|
1773
|
+
refreshUpdate();
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1593
1776
|
async function refreshUpdate() {
|
|
1594
1777
|
if (!$appUpdate) return;
|
|
1595
1778
|
try {
|
|
@@ -1600,57 +1783,99 @@
|
|
|
1600
1783
|
$appUpdate.textContent = '↑ Update to v' + d.targetVersion;
|
|
1601
1784
|
$appUpdate.dataset.channel = d.channel || 'desktop';
|
|
1602
1785
|
$appUpdate.dataset.command = (npm && d.command) ? d.command : '';
|
|
1603
|
-
$appUpdate.dataset.
|
|
1604
|
-
|
|
1605
|
-
|
|
1786
|
+
$appUpdate.dataset.target = d.targetVersion;
|
|
1787
|
+
$appUpdate.dataset.tip = "What's new in v" + d.targetVersion;
|
|
1788
|
+
$appUpdate.setAttribute('aria-haspopup', 'dialog');
|
|
1606
1789
|
$appUpdate.hidden = false;
|
|
1607
1790
|
} else {
|
|
1608
|
-
|
|
1791
|
+
// Dev-override (ships inert): an E2E can force the pill visible deterministically
|
|
1792
|
+
// by setting localStorage['floless.dev.forceUpdate'] = {"component":"app","version":"x.y.z"}.
|
|
1793
|
+
// Read on every poll; absent by default → no behaviour change.
|
|
1794
|
+
if (!applyDevForcedUpdate('app', $appUpdate)) $appUpdate.hidden = true; // up-to-date / dev / registry-or-feed error → no tag
|
|
1609
1795
|
}
|
|
1610
|
-
} catch {
|
|
1796
|
+
} catch {
|
|
1797
|
+
if (!applyDevForcedUpdate('app', $appUpdate)) $appUpdate.hidden = true;
|
|
1798
|
+
}
|
|
1611
1799
|
}
|
|
1612
1800
|
if ($appUpdate) {
|
|
1613
|
-
$appUpdate.onclick =
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
const cmd = $appUpdate.dataset.command || 'npm i -g @floless/app@latest';
|
|
1617
|
-
// Best-effort copy — fire-and-forget so the toast never blocks on clipboard permission.
|
|
1618
|
-
if (navigator.clipboard) navigator.clipboard.writeText(cmd).catch(() => {});
|
|
1619
|
-
showToast('Update in your terminal — ' + cmd, 'info');
|
|
1620
|
-
return;
|
|
1621
|
-
}
|
|
1622
|
-
// desktop (Velopack): one-click download + relaunch
|
|
1623
|
-
const v = $appUpdate.textContent.replace(/^[^0-9]*/, ''); // "↑ Update to v0.5.2" → "0.5.2"
|
|
1624
|
-
if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
|
|
1625
|
-
$appUpdate.disabled = true;
|
|
1626
|
-
$appUpdate.textContent = '↑ Updating…';
|
|
1627
|
-
try {
|
|
1628
|
-
const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
|
|
1629
|
-
const d = await r.json().catch(() => ({}));
|
|
1630
|
-
if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
|
|
1631
|
-
// Success: the server is exiting + Update.exe relaunches the new build. The health
|
|
1632
|
-
// poll flips to offline (R1 overlay), then the new version reconnects on its own.
|
|
1633
|
-
} catch (e) {
|
|
1634
|
-
$appUpdate.disabled = false;
|
|
1635
|
-
showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
|
|
1636
|
-
refreshUpdate();
|
|
1637
|
-
}
|
|
1801
|
+
$appUpdate.onclick = () => {
|
|
1802
|
+
const version = $appUpdate.dataset.target || '';
|
|
1803
|
+
openNotesPopover({ component: 'app', version, anchorEl: $appUpdate, onUpdate: applyAppUpdate });
|
|
1638
1804
|
};
|
|
1639
1805
|
refreshUpdate();
|
|
1640
1806
|
setInterval(refreshUpdate, 6 * 60 * 60 * 1000); // re-check every 6h so a long-running window notices
|
|
1641
1807
|
}
|
|
1642
1808
|
|
|
1809
|
+
// Dev-override helper — the ONLY forced-visible path. Reveals a pill with a fixed
|
|
1810
|
+
// version when localStorage['floless.dev.forceUpdate'] matches the component, so an
|
|
1811
|
+
// E2E can exercise the popover without a real pending update. Returns true if forced.
|
|
1812
|
+
function applyDevForcedUpdate(component, pillEl) {
|
|
1813
|
+
if (!pillEl) return false;
|
|
1814
|
+
let forced = null;
|
|
1815
|
+
try { forced = JSON.parse(localStorage.getItem('floless.dev.forceUpdate') || 'null'); } catch { forced = null; }
|
|
1816
|
+
if (!forced || forced.component !== component || !forced.version) return false;
|
|
1817
|
+
const v = String(forced.version);
|
|
1818
|
+
pillEl.dataset.target = v;
|
|
1819
|
+
pillEl.dataset.tip = "What's new in v" + v;
|
|
1820
|
+
pillEl.setAttribute('aria-haspopup', 'dialog');
|
|
1821
|
+
if (component === 'aware') { pillEl.textContent = '↑ Upgrade AWARE to v' + v; }
|
|
1822
|
+
else { pillEl.textContent = '↑ Update to v' + v; pillEl.dataset.channel = pillEl.dataset.channel || 'desktop'; }
|
|
1823
|
+
pillEl.hidden = false;
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1643
1827
|
// ── AWARE runtime upgrade tag ─────────────────────────────────────────────────
|
|
1644
1828
|
// Surface aware-update.ts: poll GET /api/aware/update (on load + every 6h); when a
|
|
1645
|
-
// newer @aware-aeco/cli exists, show a footer pill →
|
|
1646
|
-
// reinstalls AWARE in place (no relaunch —
|
|
1647
|
-
// version live. Mirrors the app self-update
|
|
1648
|
-
// (the live version re-stamp IS the
|
|
1829
|
+
// newer @aware-aeco/cli exists, show a footer pill → click opens the release-notes
|
|
1830
|
+
// popover → Update now → POST apply, which reinstalls AWARE in place (no relaunch —
|
|
1831
|
+
// the app stays open) and re-stamps the version live. Mirrors the app self-update
|
|
1832
|
+
// pill; differs in that success is silent (the live version re-stamp IS the
|
|
1833
|
+
// confirmation) and there is no relaunch.
|
|
1649
1834
|
const $awareUpdate = document.getElementById('aware-update');
|
|
1650
1835
|
// After a successful upgrade, briefly trust the upgraded version: a /api/health request that
|
|
1651
1836
|
// was in flight before the reinstall finished could resolve with the OLD version and momentarily
|
|
1652
1837
|
// revert the footer. The live re-stamp (health poll) honors this short settle window.
|
|
1653
1838
|
let awareUpgradeFloor = null, awareUpgradeFloorUntil = 0;
|
|
1839
|
+
// Extracted apply body — driven by the popover's "Update now" with the polled
|
|
1840
|
+
// target version. Preserves the awareUpgradeFloor settle window + the live
|
|
1841
|
+
// #aware-version re-stamp verbatim; additionally makes the version label a notes
|
|
1842
|
+
// affordance and shows a brief "installed" confirmation before the pill hides.
|
|
1843
|
+
async function applyAwareUpdate(version) {
|
|
1844
|
+
if (!$awareUpdate) return;
|
|
1845
|
+
const v = version || '';
|
|
1846
|
+
if (!window.confirm('Upgrade AWARE runtime to v' + v + '? This reinstalls the npm package in place — the app stays open and the version restamps automatically.')) return;
|
|
1847
|
+
$awareUpdate.disabled = true;
|
|
1848
|
+
$awareUpdate.textContent = '↑ Upgrading…';
|
|
1849
|
+
try {
|
|
1850
|
+
// Cap the wait so the pill never sticks disabled if the global npm install wedges.
|
|
1851
|
+
const r = await fetch('/api/aware/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(120000) });
|
|
1852
|
+
const d = await r.json().catch(() => ({}));
|
|
1853
|
+
if (!r.ok || !d.ok) throw new Error(d.error || 'aware upgrade failed');
|
|
1854
|
+
// Success is silent: the pill disappears and the AWARE version re-stamps live.
|
|
1855
|
+
$awareUpdate.disabled = false;
|
|
1856
|
+
const wv = document.getElementById('aware-version');
|
|
1857
|
+
if (wv && d.version) {
|
|
1858
|
+
wv.textContent = 'AWARE ' + d.version;
|
|
1859
|
+
awareUpgradeFloor = d.version; awareUpgradeFloorUntil = Date.now() + 10000;
|
|
1860
|
+
// The version label becomes a notes affordance — click to reopen what changed.
|
|
1861
|
+
wv.classList.add('relnotes-reopen');
|
|
1862
|
+
wv.setAttribute('role', 'button');
|
|
1863
|
+
wv.setAttribute('tabindex', '0');
|
|
1864
|
+
wv.dataset.tip = "What's new in v" + d.version + ' — click for notes';
|
|
1865
|
+
wv.dataset.notesVersion = d.version;
|
|
1866
|
+
}
|
|
1867
|
+
// Brief confirmation (~2s) before the pill hides.
|
|
1868
|
+
$awareUpdate.textContent = 'AWARE v' + (d.version || v) + ' installed';
|
|
1869
|
+
setTimeout(() => { $awareUpdate.hidden = true; refreshAwareUpdate(); }, 2000);
|
|
1870
|
+
} catch (e) {
|
|
1871
|
+
$awareUpdate.disabled = false;
|
|
1872
|
+
const msg = (e && e.name === 'TimeoutError')
|
|
1873
|
+
? 'AWARE upgrade is taking a while — it may still be installing; the version updates when it finishes'
|
|
1874
|
+
: 'AWARE upgrade failed — ' + String((e && e.message) || e).slice(0, 80);
|
|
1875
|
+
showToast(msg, 'warn');
|
|
1876
|
+
refreshAwareUpdate(); // restore the pill text for retry
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1654
1879
|
async function refreshAwareUpdate() {
|
|
1655
1880
|
if (!$awareUpdate) return;
|
|
1656
1881
|
try {
|
|
@@ -1659,42 +1884,109 @@
|
|
|
1659
1884
|
if (r.ok && d.updateAvailable && d.targetVersion) {
|
|
1660
1885
|
$awareUpdate.textContent = '↑ Upgrade AWARE to v' + d.targetVersion;
|
|
1661
1886
|
$awareUpdate.dataset.target = d.targetVersion;
|
|
1662
|
-
$awareUpdate.dataset.tip = '
|
|
1887
|
+
$awareUpdate.dataset.tip = "What's new in v" + d.targetVersion;
|
|
1888
|
+
$awareUpdate.setAttribute('aria-haspopup', 'dialog');
|
|
1663
1889
|
$awareUpdate.hidden = false;
|
|
1664
1890
|
} else {
|
|
1665
|
-
|
|
1891
|
+
// Dev-override (ships inert) — see applyDevForcedUpdate. Read on every poll.
|
|
1892
|
+
if (!applyDevForcedUpdate('aware', $awareUpdate)) $awareUpdate.hidden = true; // up-to-date / absent / registry error → no pill
|
|
1666
1893
|
}
|
|
1667
|
-
} catch {
|
|
1894
|
+
} catch {
|
|
1895
|
+
if (!applyDevForcedUpdate('aware', $awareUpdate)) $awareUpdate.hidden = true;
|
|
1896
|
+
}
|
|
1668
1897
|
}
|
|
1669
1898
|
if ($awareUpdate) {
|
|
1670
|
-
$awareUpdate.onclick =
|
|
1671
|
-
const
|
|
1672
|
-
|
|
1673
|
-
$awareUpdate.disabled = true;
|
|
1674
|
-
$awareUpdate.textContent = '↑ Upgrading…';
|
|
1675
|
-
try {
|
|
1676
|
-
// Cap the wait so the pill never sticks disabled if the global npm install wedges.
|
|
1677
|
-
const r = await fetch('/api/aware/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(120000) });
|
|
1678
|
-
const d = await r.json().catch(() => ({}));
|
|
1679
|
-
if (!r.ok || !d.ok) throw new Error(d.error || 'aware upgrade failed');
|
|
1680
|
-
// Success is silent: the pill disappears and the AWARE version re-stamps live.
|
|
1681
|
-
$awareUpdate.hidden = true;
|
|
1682
|
-
$awareUpdate.disabled = false;
|
|
1683
|
-
const wv = document.getElementById('aware-version');
|
|
1684
|
-
if (wv && d.version) { wv.textContent = 'AWARE ' + d.version; awareUpgradeFloor = d.version; awareUpgradeFloorUntil = Date.now() + 10000; }
|
|
1685
|
-
} catch (e) {
|
|
1686
|
-
$awareUpdate.disabled = false;
|
|
1687
|
-
const msg = (e && e.name === 'TimeoutError')
|
|
1688
|
-
? 'AWARE upgrade is taking a while — it may still be installing; the version updates when it finishes'
|
|
1689
|
-
: 'AWARE upgrade failed — ' + String((e && e.message) || e).slice(0, 80);
|
|
1690
|
-
showToast(msg, 'warn');
|
|
1691
|
-
refreshAwareUpdate(); // restore the pill text for retry
|
|
1692
|
-
}
|
|
1899
|
+
$awareUpdate.onclick = () => {
|
|
1900
|
+
const version = $awareUpdate.dataset.target || '';
|
|
1901
|
+
openNotesPopover({ component: 'aware', version, anchorEl: $awareUpdate, onUpdate: applyAwareUpdate });
|
|
1693
1902
|
};
|
|
1694
1903
|
refreshAwareUpdate();
|
|
1695
1904
|
setInterval(refreshAwareUpdate, 6 * 60 * 60 * 1000); // re-check every 6h
|
|
1696
1905
|
}
|
|
1697
1906
|
|
|
1907
|
+
// The AWARE version label, once it's a notes affordance (post in-place upgrade),
|
|
1908
|
+
// reopens the popover on click or Enter/Space.
|
|
1909
|
+
{
|
|
1910
|
+
const $awareVersion = document.getElementById('aware-version');
|
|
1911
|
+
if ($awareVersion) {
|
|
1912
|
+
const reopen = () => {
|
|
1913
|
+
const v = $awareVersion.dataset.notesVersion;
|
|
1914
|
+
if (v) openNotesPopover({ component: 'aware', version: v, anchorEl: $awareVersion });
|
|
1915
|
+
};
|
|
1916
|
+
$awareVersion.addEventListener('click', () => { if ($awareVersion.classList.contains('relnotes-reopen')) reopen(); });
|
|
1917
|
+
$awareVersion.addEventListener('keydown', (e) => {
|
|
1918
|
+
if (!$awareVersion.classList.contains('relnotes-reopen')) return;
|
|
1919
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); reopen(); }
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// ── What's-new panel (floless.app post-update, relaunch-surviving) ────────────
|
|
1925
|
+
// Called from the health poll AFTER #app-version is stamped (so the version
|
|
1926
|
+
// compare has a real value). Reveals once: if a pending self-update version was
|
|
1927
|
+
// recorded before the relaunch AND the now-running build matches it AND the user
|
|
1928
|
+
// hasn't already dismissed that version, fetch its notes and show the panel.
|
|
1929
|
+
function maybeShowWhatsNew() {
|
|
1930
|
+
if (whatsNewShown) return;
|
|
1931
|
+
let pending, seen;
|
|
1932
|
+
try { pending = localStorage.getItem('floless.whatsNew.pending'); seen = localStorage.getItem('floless.whatsNew.seen'); } catch { return; }
|
|
1933
|
+
if (!pending) return;
|
|
1934
|
+
const appVer = (document.getElementById('app-version')?.textContent || '').replace(/[^0-9.]/g, '');
|
|
1935
|
+
if (appVer !== pending || seen === pending) { try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ } return; }
|
|
1936
|
+
|
|
1937
|
+
const panel = document.getElementById('whats-new-panel');
|
|
1938
|
+
if (!panel) return;
|
|
1939
|
+
whatsNewShown = true; // guard: reveal at most once per session
|
|
1940
|
+
|
|
1941
|
+
const $summaryText = panel.querySelector('.whats-new-summary-text');
|
|
1942
|
+
const $toggle = panel.querySelector('.whats-new-toggle');
|
|
1943
|
+
const $detail = panel.querySelector('.whats-new-detail');
|
|
1944
|
+
const $detailInner = panel.querySelector('.whats-new-detail-inner');
|
|
1945
|
+
const $link = panel.querySelector('.whats-new-link');
|
|
1946
|
+
const $dismiss = panel.querySelector('.whats-new-dismiss');
|
|
1947
|
+
|
|
1948
|
+
// Changelog deep-link — channel-correct base only; omit (hide) when unknown.
|
|
1949
|
+
if ($link) {
|
|
1950
|
+
if (webBase) { $link.href = webBase + '/changelog#v' + pending; $link.hidden = false; }
|
|
1951
|
+
else { $link.removeAttribute('href'); $link.hidden = true; }
|
|
1952
|
+
}
|
|
1953
|
+
if ($summaryText) $summaryText.textContent = 'floless.app v' + pending + ' is now running.';
|
|
1954
|
+
|
|
1955
|
+
// Toggle expands/collapses the detail (grid-rows transition); label + aria flip.
|
|
1956
|
+
if ($toggle && $detail) {
|
|
1957
|
+
$toggle.onclick = () => {
|
|
1958
|
+
const open = $detail.classList.toggle('open');
|
|
1959
|
+
$toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
1960
|
+
$toggle.textContent = open ? 'Hide' : "What's new";
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
// Dismiss — no confirm; persist the seen version so it never re-reveals.
|
|
1964
|
+
if ($dismiss) {
|
|
1965
|
+
$dismiss.onclick = () => {
|
|
1966
|
+
try { localStorage.setItem('floless.whatsNew.seen', pending); localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
|
|
1967
|
+
panel.hidden = true;
|
|
1968
|
+
document.getElementById('app')?.classList.remove('has-whats-new');
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// Reveal the panel (adds the 4th grid row), then fill the detail with shared bullets.
|
|
1973
|
+
document.getElementById('app')?.classList.add('has-whats-new');
|
|
1974
|
+
panel.hidden = false;
|
|
1975
|
+
(async () => {
|
|
1976
|
+
let notes = null;
|
|
1977
|
+
try {
|
|
1978
|
+
const r = await fetch('/api/release-notes?component=app&version=' + encodeURIComponent(pending));
|
|
1979
|
+
notes = await r.json();
|
|
1980
|
+
} catch { notes = null; }
|
|
1981
|
+
if (notes && notes.ok) {
|
|
1982
|
+
if ($summaryText && notes.summary) $summaryText.textContent = notes.summary;
|
|
1983
|
+
renderTypedChanges($detailInner, notes.changes);
|
|
1984
|
+
} else if ($detailInner) {
|
|
1985
|
+
$detailInner.innerHTML = '<p class="relnotes-unavailable">Release notes unavailable</p>';
|
|
1986
|
+
}
|
|
1987
|
+
})();
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1698
1990
|
// ONE Run (the approved single-Run model). "▶ Run workflow" does a REAL run against
|
|
1699
1991
|
// the live host, using the app inputs. If the app has a report node, it drives
|
|
1700
1992
|
// the in-app HTML Viewer (renders + caches the returned HTML); otherwise it
|
|
@@ -2245,8 +2537,14 @@
|
|
|
2245
2537
|
// version CAN change (an out-of-band `npm i -g`, or the in-app upgrade), so
|
|
2246
2538
|
// re-stamp it whenever /api/health reports a different value — never show a
|
|
2247
2539
|
// stale runtime version in the footer.
|
|
2540
|
+
// Capture the channel-correct public site base for the changelog deep-links
|
|
2541
|
+
// (popover + what's-new). Empty until /api/health reports it → links omitted.
|
|
2542
|
+
if (h && h.webBase) webBase = h.webBase;
|
|
2248
2543
|
const av = document.getElementById('app-version');
|
|
2249
2544
|
if (av && h && h.appVersion && !shownVersion) { av.textContent = 'v' + h.appVersion; shownVersion = true; }
|
|
2545
|
+
// After the build version is stamped, reveal the relaunch-surviving what's-new
|
|
2546
|
+
// panel iff this is the build we just self-updated into (guarded to once).
|
|
2547
|
+
maybeShowWhatsNew();
|
|
2250
2548
|
const wv = document.getElementById('aware-version');
|
|
2251
2549
|
if (wv && h && h.awareVersion) {
|
|
2252
2550
|
const next = 'AWARE ' + h.awareVersion;
|
|
@@ -3266,6 +3564,188 @@
|
|
|
3266
3564
|
else if ($routinesModal.classList.contains('show')) hideModal($routinesModal);
|
|
3267
3565
|
});
|
|
3268
3566
|
|
|
3567
|
+
// ── Report an issue ──────────────────────────────────────────────────────────
|
|
3568
|
+
// The hamburger "Report an issue" item files into the PRIVATE product log via
|
|
3569
|
+
// POST /api/report-issue (the floless.io relay; report-relay.ts). The user gets a
|
|
3570
|
+
// confirmation ref, not a public link (private repo). The /floless-app-report-issue
|
|
3571
|
+
// skill is the richer terminal path (it reads run traces + the .flo to file a
|
|
3572
|
+
// diagnosed report). Modal elements are static in index.html, present when this runs.
|
|
3573
|
+
const $riModal = document.getElementById('report-issue-modal');
|
|
3574
|
+
const $riForm = document.getElementById('ri-form');
|
|
3575
|
+
const $riState = document.getElementById('ri-state');
|
|
3576
|
+
const $riTitle = document.getElementById('ri-title');
|
|
3577
|
+
const $riDesc = document.getElementById('ri-desc');
|
|
3578
|
+
const $riContext = document.getElementById('ri-context');
|
|
3579
|
+
const $riSend = document.getElementById('ri-send');
|
|
3580
|
+
const $riCancel = document.getElementById('ri-cancel');
|
|
3581
|
+
let riCategory = 'bug';
|
|
3582
|
+
let riSubmitting = false;
|
|
3583
|
+
let riAppVersion = ''; // captured from /api/health on open so the report actually carries
|
|
3584
|
+
let riAwareVersion = ''; // the versions the modal promises ("sent automatically: …")
|
|
3585
|
+
|
|
3586
|
+
const riSyncSend = () => { $riSend.disabled = !($riTitle.value.trim() && $riDesc.value.trim()); };
|
|
3587
|
+
const riClose = () => { if (!riSubmitting) hideModal($riModal); };
|
|
3588
|
+
const riSetCategory = (cat) => {
|
|
3589
|
+
riCategory = cat;
|
|
3590
|
+
$riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => {
|
|
3591
|
+
const on = b.dataset.cat === cat;
|
|
3592
|
+
b.classList.toggle('active', on);
|
|
3593
|
+
b.setAttribute('aria-pressed', String(on));
|
|
3594
|
+
});
|
|
3595
|
+
};
|
|
3596
|
+
// Build the post-submit states with safe DOM methods (textContent, not innerHTML).
|
|
3597
|
+
const riButton = (id, label, primary, onclick) => {
|
|
3598
|
+
const b = document.createElement('button');
|
|
3599
|
+
b.id = id;
|
|
3600
|
+
if (primary) b.className = 'primary';
|
|
3601
|
+
b.textContent = label;
|
|
3602
|
+
b.onclick = onclick;
|
|
3603
|
+
return b;
|
|
3604
|
+
};
|
|
3605
|
+
// A Lucide-style circle-check (stroke SVG, matching the menu icons) for the success state —
|
|
3606
|
+
// it actually means "received/done", vs an ad-hoc glyph. Inherits var(--ok) via currentColor.
|
|
3607
|
+
const riCheckIcon = () => {
|
|
3608
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
3609
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
3610
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
3611
|
+
svg.setAttribute('width', '34');
|
|
3612
|
+
svg.setAttribute('height', '34');
|
|
3613
|
+
svg.setAttribute('fill', 'none');
|
|
3614
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
3615
|
+
svg.setAttribute('stroke-width', '1.75');
|
|
3616
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
3617
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
3618
|
+
svg.style.display = 'block';
|
|
3619
|
+
svg.style.margin = '0 auto';
|
|
3620
|
+
const circle = document.createElementNS(NS, 'circle');
|
|
3621
|
+
circle.setAttribute('cx', '12');
|
|
3622
|
+
circle.setAttribute('cy', '12');
|
|
3623
|
+
circle.setAttribute('r', '10');
|
|
3624
|
+
const check = document.createElementNS(NS, 'path');
|
|
3625
|
+
check.setAttribute('d', 'm9 12 2 2 4-4');
|
|
3626
|
+
svg.append(circle, check);
|
|
3627
|
+
return svg;
|
|
3628
|
+
};
|
|
3629
|
+
const riShowState = (...nodes) => {
|
|
3630
|
+
while ($riState.firstChild) $riState.removeChild($riState.firstChild);
|
|
3631
|
+
for (const n of nodes) $riState.appendChild(n);
|
|
3632
|
+
$riState.hidden = false;
|
|
3633
|
+
$riForm.hidden = true;
|
|
3634
|
+
};
|
|
3635
|
+
|
|
3636
|
+
openReportIssue = function openReportIssueReal() {
|
|
3637
|
+
riSubmitting = false;
|
|
3638
|
+
$riForm.hidden = false;
|
|
3639
|
+
$riState.hidden = true;
|
|
3640
|
+
while ($riState.firstChild) $riState.removeChild($riState.firstChild);
|
|
3641
|
+
$riTitle.value = '';
|
|
3642
|
+
$riDesc.value = '';
|
|
3643
|
+
riSetCategory('bug');
|
|
3644
|
+
riAppVersion = '';
|
|
3645
|
+
riAwareVersion = '';
|
|
3646
|
+
$riSend.disabled = true;
|
|
3647
|
+
$riSend.textContent = 'Send report';
|
|
3648
|
+
// Auto-attached diagnostics line — versions + the open workflow, read fresh from health.
|
|
3649
|
+
$riContext.textContent = 'Also sent automatically: app + AWARE version, and the open workflow.';
|
|
3650
|
+
api('/api/health').then((h) => {
|
|
3651
|
+
riAppVersion = h.appVersion || '';
|
|
3652
|
+
riAwareVersion = h.awareVersion || '';
|
|
3653
|
+
const app = currentId && apps.get(currentId);
|
|
3654
|
+
const wf = app ? ` · workflow "${app.displayName || currentId}"` : '';
|
|
3655
|
+
$riContext.textContent = `Also sent automatically: FloLess v${h.appVersion || '?'}, AWARE v${h.awareVersion || '?'}${wf}`;
|
|
3656
|
+
}).catch(() => { /* keep the generic line on a health blip */ });
|
|
3657
|
+
showModal($riModal);
|
|
3658
|
+
setTimeout(() => $riTitle.focus(), 0);
|
|
3659
|
+
};
|
|
3660
|
+
|
|
3661
|
+
function riRenderSuccess(ref) {
|
|
3662
|
+
const wrap = document.createElement('div');
|
|
3663
|
+
wrap.className = 'graft-success';
|
|
3664
|
+
const icon = document.createElement('div');
|
|
3665
|
+
icon.className = 'gs-icon';
|
|
3666
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
3667
|
+
icon.appendChild(riCheckIcon());
|
|
3668
|
+
const msg = document.createElement('div');
|
|
3669
|
+
msg.className = 'gs-msg';
|
|
3670
|
+
msg.textContent = ref
|
|
3671
|
+
? `Received — logged as ${ref} · We'll follow up privately.`
|
|
3672
|
+
: "Received · We'll follow up privately.";
|
|
3673
|
+
wrap.append(icon, msg);
|
|
3674
|
+
const actions = document.createElement('div');
|
|
3675
|
+
actions.className = 'modal-actions';
|
|
3676
|
+
const done = riButton('ri-done', 'Done', true, () => hideModal($riModal));
|
|
3677
|
+
actions.append(done);
|
|
3678
|
+
riShowState(wrap, actions);
|
|
3679
|
+
done.focus();
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
function riRenderSignIn() {
|
|
3683
|
+
const sub = document.createElement('div');
|
|
3684
|
+
sub.className = 'modal-sub';
|
|
3685
|
+
sub.textContent = 'Sign in to send a report — your session has expired.';
|
|
3686
|
+
const actions = document.createElement('div');
|
|
3687
|
+
actions.className = 'modal-actions';
|
|
3688
|
+
const close = riButton('ri-signin-close', 'Close', false, () => hideModal($riModal));
|
|
3689
|
+
const signin = riButton('ri-signin-btn', 'Sign in', true, async () => {
|
|
3690
|
+
try { await api('/api/license/start', { method: 'POST' }); } catch { /* surfaced via license polling */ }
|
|
3691
|
+
hideModal($riModal);
|
|
3692
|
+
});
|
|
3693
|
+
actions.append(close, signin);
|
|
3694
|
+
riShowState(sub, actions);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
async function riSubmit() {
|
|
3698
|
+
if (riSubmitting) return;
|
|
3699
|
+
const title = $riTitle.value.trim();
|
|
3700
|
+
const body = $riDesc.value.trim();
|
|
3701
|
+
if (!title || !body) return;
|
|
3702
|
+
riSubmitting = true;
|
|
3703
|
+
$riSend.disabled = true;
|
|
3704
|
+
$riSend.textContent = 'Sending…';
|
|
3705
|
+
// Versions are filled by the health fetch on open; if a fast submit beat it, fetch them
|
|
3706
|
+
// now so the report carries the versions the modal promised ("sent automatically: …").
|
|
3707
|
+
if (!riAppVersion || !riAwareVersion) {
|
|
3708
|
+
try {
|
|
3709
|
+
const h = await api('/api/health');
|
|
3710
|
+
riAppVersion = riAppVersion || h.appVersion || '';
|
|
3711
|
+
riAwareVersion = riAwareVersion || h.awareVersion || '';
|
|
3712
|
+
} catch { /* best-effort — send with whatever we have */ }
|
|
3713
|
+
}
|
|
3714
|
+
const app = currentId && apps.get(currentId);
|
|
3715
|
+
const context = {
|
|
3716
|
+
source: 'web',
|
|
3717
|
+
appId: currentId || null,
|
|
3718
|
+
workflow: app ? (app.displayName || currentId) : null,
|
|
3719
|
+
appVersion: riAppVersion || null,
|
|
3720
|
+
awareVersion: riAwareVersion || null,
|
|
3721
|
+
};
|
|
3722
|
+
try {
|
|
3723
|
+
const r = await api('/api/report-issue', { method: 'POST', body: JSON.stringify({ category: riCategory, title, body, context }) });
|
|
3724
|
+
riSubmitting = false; // response in hand — Done / Escape / backdrop may close
|
|
3725
|
+
riRenderSuccess(r.ref || '');
|
|
3726
|
+
} catch (e) {
|
|
3727
|
+
riSubmitting = false;
|
|
3728
|
+
const err = (e && e.body && e.body.error) || '';
|
|
3729
|
+
if (err === 'signed_out') {
|
|
3730
|
+
riRenderSignIn(); // the action isn't possible until signed in — hide the form, don't grey it out
|
|
3731
|
+
} else if (err === 'rate_limited') {
|
|
3732
|
+
showToast('Too many reports just now — please wait a moment and try again', 'warn');
|
|
3733
|
+
$riSend.disabled = false; $riSend.textContent = 'Send report';
|
|
3734
|
+
} else {
|
|
3735
|
+
showToast("Couldn't send — check your connection and try again", 'err');
|
|
3736
|
+
$riSend.disabled = false; $riSend.textContent = 'Send report';
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
$riTitle.addEventListener('input', riSyncSend);
|
|
3742
|
+
$riDesc.addEventListener('input', riSyncSend);
|
|
3743
|
+
$riSend.onclick = () => riSubmit();
|
|
3744
|
+
$riCancel.onclick = () => riClose();
|
|
3745
|
+
$riModal.onclick = (e) => { if (e.target === $riModal) riClose(); };
|
|
3746
|
+
$riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => { b.onclick = () => riSetCategory(b.dataset.cat); });
|
|
3747
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $riModal.classList.contains('show')) riClose(); });
|
|
3748
|
+
|
|
3269
3749
|
// ── init (after app.js bootstrap) ────────────────────────────────────────────
|
|
3270
3750
|
// app.js already stored the ORIGINAL functions as .onclick/.oninput before we
|
|
3271
3751
|
// reassigned the globals; rebind via arrows so they resolve our versions at
|