@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/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 tagconfirm POST /api/update/apply,
1591
- // which downloads + relaunches into the new version. Hidden in dev/npm (supported:false).
1738
+ // installed build exists, show a footer pillclick 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.tip = npm
1604
- ? 'A newer floless.app (v' + d.targetVersion + ') is on npm — click to copy the update command'
1605
- : 'A newer floless.app is available — click to download and relaunch into v' + d.targetVersion;
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
- $appUpdate.hidden = true; // up-to-date / dev / registry-or-feed error no tag
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 { $appUpdate.hidden = true; }
1796
+ } catch {
1797
+ if (!applyDevForcedUpdate('app', $appUpdate)) $appUpdate.hidden = true;
1798
+ }
1611
1799
  }
1612
1800
  if ($appUpdate) {
1613
- $appUpdate.onclick = async () => {
1614
- // npm channel can't self-apply (no Update.exe) copy the command for the user's terminal.
1615
- if ($appUpdate.dataset.channel === 'npm') {
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 → confirm POST apply, which
1646
- // reinstalls AWARE in place (no relaunch — the app stays open) and re-stamps the
1647
- // version live. Mirrors the app self-update pill; differs in that success is silent
1648
- // (the live version re-stamp IS the confirmation) and there is no relaunch.
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 = 'A newer AWARE runtime (v' + d.targetVersion + ') is available — click to install it in place (no relaunch)';
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
- $awareUpdate.hidden = true; // up-to-date / absent / registry error no pill
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 { $awareUpdate.hidden = true; }
1894
+ } catch {
1895
+ if (!applyDevForcedUpdate('aware', $awareUpdate)) $awareUpdate.hidden = true;
1896
+ }
1668
1897
  }
1669
1898
  if ($awareUpdate) {
1670
- $awareUpdate.onclick = async () => {
1671
- const v = $awareUpdate.dataset.target || '';
1672
- 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;
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