app.js 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. // Shared page plumbing. showToast: slide-in notifications (used in
  2. // place of alert). initQuoteComments: registers a container matcher
  3. // for the text-highlight -> comment popover - selecting text inside
  4. // a matched container shows a floating button, then a popover that
  5. // posts the comment with the quoted text via the action the page
  6. // supplies.
  7. // Shared UI engines (parameterised per page):
  8. //
  9. // showToast - every page's toast corner.
  10. //
  11. // initQuoteComments - the text-selection quote popover + dialog
  12. // posting a comment with the quoted text; pages pass a `matches`
  13. // node predicate and the action accessor.
  14. //
  15. // initPlanReviews (plan 16 R3, arch 15 - the ~180-line pattern
  16. // that lived duplicated between the task and goal pages, now
  17. // goal-only after the task-page plan review died with the poll
  18. // era): the plan/completion REVIEW remark queue. "Add remark"
  19. // appends to a per-block queue rendered at the bottom of the
  20. // review body, persists across statum re-renders (the maps live
  21. // outside the render cycle), rides the rejection NUMBERED (each
  22. // remark with its selection quote prefixed like a comment), and
  23. // clears on accept. Remark-less rejection is allowed when the
  24. // feedback textarea carries the substance (plan 16 S2). The
  25. // rejection body rides `feedback`; the SERVER wraps the per-kind
  26. // stable prefix (R4 - templates carry no verdict constants). The
  27. // page supplies `resolveBlock(id)` - how a [data-plan-review]
  28. // container's block row resolves from page state.
  29. window.initPlanReviews = function (config) {
  30. if (!window.statum || !statum.afterEvaluate) return;
  31. var resolveBlock = (config && config.resolveBlock)
  32. || function () { return null; };
  33. var remarksByBlock = {};
  34. var rejectOpenByBlock = {};
  35. var remarkPopover = null;
  36. statum.afterEvaluate(syncPlanRemarks);
  37. document.addEventListener('click', function (event) {
  38. if (!event.target.closest) return;
  39. var accept = event.target.closest('[data-plan-accept]');
  40. if (accept) {
  41. // Accept ignores/clears the queued remarks.
  42. var accepted = accept.closest('[data-plan-review]');
  43. if (accepted) {
  44. var acceptId = accepted.getAttribute('data-block-id');
  45. remarksByBlock[acceptId] = [];
  46. rejectOpenByBlock[acceptId] = false;
  47. }
  48. return;
  49. }
  50. var toggle = event.target.closest('[data-plan-toggle]');
  51. if (toggle) {
  52. hideRemarkPopover();
  53. var review = toggle.closest('[data-plan-review]');
  54. var id = review && review.getAttribute('data-block-id');
  55. if (id) rejectOpenByBlock[id] = !rejectOpenByBlock[id];
  56. syncPlanRemarks();
  57. return;
  58. }
  59. var submit = event.target.closest('[data-plan-submit]');
  60. if (submit) {
  61. submitPlanRemarks(submit.closest('[data-plan-review]'));
  62. return;
  63. }
  64. var remove = event.target.closest('[data-plan-remove]');
  65. if (remove) {
  66. var entry = remove.closest('[data-plan-remark-id]');
  67. var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id');
  68. var list = remarksByBlock[blockId] || [];
  69. var index = parseInt(entry.getAttribute('data-plan-remark-id'), 10);
  70. if (!isNaN(index) && list[index]) list.splice(index, 1);
  71. remarksByBlock[blockId] = list;
  72. syncPlanRemarks();
  73. }
  74. });
  75. document.addEventListener('mouseup', function (event) {
  76. if (!event.target.closest) return;
  77. if (remarkPopover && remarkPopover.contains(event.target)) return;
  78. var body = event.target.closest('[data-plan-body]');
  79. if (!body) {
  80. hideRemarkPopover();
  81. return;
  82. }
  83. var review = body.closest('[data-plan-review]');
  84. if (!review || review.getAttribute('data-plan-open') !== '1') return;
  85. var selection = window.getSelection();
  86. var text = selection ? String(selection.toString()).trim() : '';
  87. if (text.length === 0) {
  88. hideRemarkPopover();
  89. return;
  90. }
  91. showRemarkPopover(event.pageX, event.pageY, text, review);
  92. });
  93. document.addEventListener('mousedown', function (event) {
  94. if (remarkPopover && event.target.closest
  95. && event.target.closest('.plan-popover')) return;
  96. hideRemarkPopover();
  97. });
  98. function showRemarkPopover(x, y, quote, review) {
  99. hideRemarkPopover();
  100. remarkPopover = document.createElement('div');
  101. remarkPopover.className = 'plan-popover';
  102. remarkPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px';
  103. remarkPopover.style.top = y + 'px';
  104. var quoted = document.createElement('blockquote');
  105. quoted.textContent = quote;
  106. remarkPopover.appendChild(quoted);
  107. var body = document.createElement('textarea');
  108. body.placeholder = 'Your remark on the selected plan text';
  109. remarkPopover.appendChild(body);
  110. var actions = document.createElement('div');
  111. actions.className = 'actions';
  112. var add = document.createElement('button');
  113. add.type = 'button';
  114. add.className = 'btn-primary btn-sm';
  115. add.textContent = 'Add remark';
  116. add.addEventListener('click', function () {
  117. if (body.value.trim().length === 0) return;
  118. var id = review.getAttribute('data-block-id');
  119. var list = remarksByBlock[id] || [];
  120. list.push({ quote: quote, body: body.value.trim() });
  121. remarksByBlock[id] = list;
  122. hideRemarkPopover();
  123. syncPlanRemarks();
  124. });
  125. var cancel = document.createElement('button');
  126. cancel.type = 'button';
  127. cancel.textContent = 'Cancel';
  128. cancel.addEventListener('click', hideRemarkPopover);
  129. actions.appendChild(add);
  130. actions.appendChild(cancel);
  131. remarkPopover.appendChild(actions);
  132. document.body.appendChild(remarkPopover);
  133. body.focus();
  134. }
  135. function hideRemarkPopover() {
  136. if (remarkPopover) {
  137. remarkPopover.remove();
  138. remarkPopover = null;
  139. }
  140. }
  141. function submitPlanRemarks(review) {
  142. if (!review) return;
  143. var id = review.getAttribute('data-block-id');
  144. var list = remarksByBlock[id] || [];
  145. var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
  146. var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : '';
  147. // Remark-less rejection is allowed when the feedback
  148. // textarea carries the review's substance - one of the
  149. // two must be non-empty (plan 16 S2).
  150. if (list.length === 0 && extraText.length === 0) {
  151. if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error');
  152. return;
  153. }
  154. var block = resolveBlock(id);
  155. if (!block || !block.answer_action) return;
  156. // The rejection body keeps the historical shape: every
  157. // queued remark NUMBERED in queue order, each with its
  158. // selection quote prefixed like a comment, then the
  159. // free-text feedback.
  160. var feedback = list.map(function (item, index) {
  161. return (item.quote
  162. ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n'
  163. : '') + (index + 1) + '. ' + item.body;
  164. }).join('\n\n');
  165. if (extraText.length > 0) {
  166. feedback += (feedback.length > 0 ? '\n\n' : '') + extraText;
  167. }
  168. remarksByBlock[id] = [];
  169. rejectOpenByBlock[id] = false;
  170. statum.act(block.answer_action, { feedback: feedback }).catch(function () { });
  171. }
  172. function syncPlanRemarks() {
  173. document.querySelectorAll('[data-plan-review]').forEach(function (review) {
  174. var id = review.getAttribute('data-block-id');
  175. var open = rejectOpenByBlock[id] ? '1' : '0';
  176. review.setAttribute('data-plan-open', open);
  177. var feedback = review.querySelector('[data-plan-feedback]');
  178. if (feedback) {
  179. if (rejectOpenByBlock[id]) feedback.removeAttribute('hidden');
  180. else feedback.setAttribute('hidden', '');
  181. }
  182. var list = review.querySelector('[data-plan-remarks]');
  183. if (!list) return;
  184. while (list.firstChild) list.removeChild(list.firstChild);
  185. (remarksByBlock[id] || []).forEach(function (item, index) {
  186. var entry = document.createElement('li');
  187. entry.setAttribute('data-plan-remark-id', String(index));
  188. if (item.quote) {
  189. var quoted = document.createElement('blockquote');
  190. quoted.textContent = item.quote;
  191. entry.appendChild(quoted);
  192. }
  193. var body = document.createElement('div');
  194. body.textContent = item.body;
  195. entry.appendChild(body);
  196. var remove = document.createElement('button');
  197. remove.type = 'button';
  198. remove.className = 'btn-ghost btn-sm';
  199. remove.setAttribute('data-plan-remove', '');
  200. remove.textContent = 'Remove';
  201. entry.appendChild(remove);
  202. list.appendChild(entry);
  203. });
  204. });
  205. }
  206. };
  207. //
  208. // initLongFormEditors (plan QoL task 12): in-place editors for the
  209. // long-form card fields (task mandate, goal brief). The rendered
  210. // markdown view and the editor <form> BOTH live in the page markup;
  211. // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
  212. // plan-remarks pattern) toggles which side shows. Why JS-applied
  213. // values and not stm-attribute.value: statum's attribute binding
  214. // sets the CONTENT attribute, which the browser ignores on
  215. // <textarea> (its value lives in the text node) - so the textarea
  216. // is re-prefilled from the card's data-value payload after every
  217. // render cycle, while the page state carries the source of truth.
  218. // The card contract:
  219. // [data-long-form] the card root; data-field = slot key
  220. // whose object carries the payload
  221. // [data-lf-view] the rendered markdown half
  222. // [data-lf-edit] the editor half (form)
  223. // textarea/input[data-lf-src] a state path ("task.description")
  224. // whose current value prefills the
  225. // control on every render
  226. // [data-lf-toggle] the Edit / Cancel button
  227. // Buttons INSIDE the form (Save) submit through the form's
  228. // stm-action as usual; toggling is purely presentational, so no
  229. // state round-trip and no flicker.
  230. //
  231. window.initLongFormEditors = function () {
  232. if (!window.statum || !statum.afterEvaluate) return;
  233. // Resolve a "type.field" state path: statum.read returns the
  234. // SLOT's public object, so the first segment is the slot and
  235. // the rest walks into it.
  236. function readPath(path) {
  237. if (!path) return '';
  238. var parts = path.split('.');
  239. var value = statum.read(parts[0]);
  240. for (var i = 1; i < parts.length && value != null; i++) {
  241. value = value[parts[i]];
  242. }
  243. return value === undefined || value === null ? '' : String(value);
  244. }
  245. function syncCard(card) {
  246. var open = card.getAttribute('data-lf-open') === '1';
  247. var view = card.querySelector('[data-lf-view]');
  248. var editor = card.querySelector('[data-lf-edit]');
  249. if (view) {
  250. if (open) view.setAttribute('hidden', '');
  251. else view.removeAttribute('hidden');
  252. }
  253. if (editor) {
  254. if (open) editor.removeAttribute('hidden');
  255. else editor.setAttribute('hidden', '');
  256. }
  257. }
  258. // Prefill bookkeeping: applied ONCE per open - a statum
  259. // re-render (pulse refresh, action response) rebinds
  260. // attributes but never textarea VALUES, so re-applying on
  261. // every cycle would clobber the user's in-progress typing.
  262. // Closing the card clears the flag; the next open re-prefills
  263. // from the (possibly updated) state.
  264. var prefilled = {};
  265. function applyPrefills() {
  266. var cards = document.querySelectorAll('[data-long-form]');
  267. Array.prototype.forEach.call(cards, function (card) {
  268. syncCard(card);
  269. var key = card.getAttribute('data-field') || '';
  270. var open = card.getAttribute('data-lf-open') === '1';
  271. if (open && !prefilled[key]) {
  272. var controls = card.querySelectorAll('[data-lf-src]');
  273. Array.prototype.forEach.call(controls, function (control) {
  274. var value = readPath(control.getAttribute('data-lf-src'));
  275. if (document.activeElement !== control) {
  276. control.value = value;
  277. }
  278. });
  279. prefilled[key] = true;
  280. }
  281. if (!open) delete prefilled[key];
  282. });
  283. }
  284. document.addEventListener('click', function (event) {
  285. if (!event.target.closest) return;
  286. var button = event.target.closest('[data-lf-toggle]');
  287. if (!button) return;
  288. var card = button.closest('[data-long-form]');
  289. if (!card) return;
  290. event.preventDefault();
  291. var open = card.getAttribute('data-lf-open') === '1';
  292. if (open) {
  293. card.setAttribute('data-lf-open', '0');
  294. delete prefilled[card.getAttribute('data-field') || ''];
  295. } else {
  296. card.setAttribute('data-lf-open', '1');
  297. }
  298. syncCard(card);
  299. // Prefill immediately (the render cycle applies the state
  300. // bindings; the textarea's value needs this direct pass).
  301. applyPrefills();
  302. if (!open) {
  303. var area = card.querySelector('textarea');
  304. if (area && area.focus) area.focus();
  305. }
  306. });
  307. statum.afterEvaluate(applyPrefills);
  308. };
  309. //
  310. // Anchor sub-nav engine (goal 41 T5 shipped the chassis on the
  311. // task page, T6 the closest-to-fold selection rule on the goal
  312. // page; T11 unifies them into this ONE engine both pages run).
  313. //
  314. // Markup contract (docs/spry-conversion.md):
  315. // <nav class="subnav" data-subnav aria-label="Sections">
  316. // <a href="#section-id">…</a>…
  317. // </nav>
  318. // <section id="section-id" data-subnav-section="section-id">…
  319. //
  320. // The engine owns: (a) smooth in-page jumps that stop below the
  321. // sticky chrome (topbar + strip, measured at click time), (b)
  322. // highlighting whichever region the reader is actually in — the
  323. // section whose top sits CLOSEST above the strip's bottom edge
  324. // (ties to the later DOM node); the single-column "last heading
  325. // that crossed the line" reading T5's IntersectionObserver
  326. // implemented is the same outcome, and the closest-to-fold rule
  327. // stays correct in the two-column Act|Consult grid where an
  328. // observer can light a consult card while the reader looks at an
  329. // act one, (c) the strip's .is-stuck class once the page scrolls
  330. // it under the topbar, and (d) publishing the measured topbar
  331. // height as --topbar-bottom (the sticky log toolbar consumes the
  332. // same property). Listeners bind ONCE on document/window — statum
  333. // re-renders replace the DOM but never static markup's listeners,
  334. // and the bind flag keeps a second init call from stacking them;
  335. // statum.afterEvaluate re-runs the sync pass so the strip
  336. // re-derives its highlight and stuck state after every render.
  337. //
  338. // Nav dropdown toggles (goal 41 review round 3): the menu TOPS are
  339. // buttons, not links - tapping a link navigated away before a touch
  340. // user could pick a submenu item. Click toggles .is-open on the
  341. // .has-submenu li (spry's hover/focus-within rules still serve
  342. // pointer users); a click outside, or Esc, closes. The engine binds
  343. // once at document level (delegation), so statum re-renders never
  344. // strand a menu.
  345. (function initNavToggles() {
  346. function closeAll(except) {
  347. var open = document.querySelectorAll('.has-submenu.is-open');
  348. for (var i = 0; i < open.length; i++) {
  349. if (open[i] !== except) {
  350. open[i].classList.remove('is-open');
  351. var toggle = open[i].querySelector('.nav-toggle');
  352. if (toggle) toggle.setAttribute('aria-expanded', 'false');
  353. }
  354. }
  355. }
  356. document.addEventListener('click', function (event) {
  357. var toggle = event.target.closest ? event.target.closest('.nav-toggle') : null;
  358. if (toggle) {
  359. var item = toggle.closest('.has-submenu');
  360. var isOpen = item.classList.contains('is-open');
  361. closeAll(item);
  362. item.classList.toggle('is-open', !isOpen);
  363. toggle.setAttribute('aria-expanded', String(!isOpen));
  364. return;
  365. }
  366. // A click on a submenu item navigates; anything else closes.
  367. if (!event.target.closest || !event.target.closest('.has-submenu')) closeAll(null);
  368. });
  369. document.addEventListener('keydown', function (event) {
  370. if (event.key === 'Escape') closeAll(null);
  371. });
  372. })();
  373. window.initSubnav = function () {
  374. if (!window.statum || !statum.afterEvaluate) return;
  375. var FLAG = 'data-subnav-engine';
  376. var root = document.documentElement;
  377. if (root.getAttribute(FLAG) === 'bound') return; // already live
  378. root.setAttribute(FLAG, 'bound');
  379. function strips() {
  380. return Array.prototype.slice.call(
  381. document.querySelectorAll('[data-subnav]'));
  382. }
  383. function sections() {
  384. return Array.prototype.slice.call(
  385. document.querySelectorAll('[data-subnav-section]'));
  386. }
  387. // The strip parks under the sticky topbar; publish the
  388. // measured topbar height as a custom property so app.css's
  389. // `top:` tracks any wrap/viewport.
  390. function syncTopbarOffset() {
  391. var bar = document.querySelector('.topbar');
  392. // The topbar is static (review round 3 - it scrolled away with
  393. // the page), so nothing sits above a sticky strip except a
  394. // SCROLLED-PAST topbar: publish 0 and let app.css park the
  395. // strips at the viewport top. jumpOffset() still measures the
  396. // real bar for in-page anchor jumps (a click near the top of a
  397. // fresh page can land while the bar is still visible).
  398. var offset = (bar && getComputedStyle(bar).position === 'sticky')
  399. ? bar.offsetHeight : 0;
  400. root.style.setProperty('--topbar-bottom', offset + 'px');
  401. }
  402. // How much sticky chrome sits above a scrolled-to heading:
  403. // the topbar plus the strip itself (so the heading the
  404. // reader jumped to is not swallowed by either).
  405. function jumpOffset(strip) {
  406. var height = 0;
  407. var bar = document.querySelector('.topbar');
  408. if (bar) height += bar.offsetHeight;
  409. if (strip) height += strip.offsetHeight + 8;
  410. return height;
  411. }
  412. function highlight(strip, id) {
  413. var links = strip.querySelectorAll('a[href^="#"]');
  414. for (var i = 0; i < links.length; i++) {
  415. var active = links[i].getAttribute('href') === '#' + id;
  416. links[i].classList.toggle('active', active);
  417. if (active) {
  418. links[i].setAttribute('aria-current', 'true');
  419. } else {
  420. links[i].removeAttribute('aria-current');
  421. }
  422. }
  423. }
  424. // The section the reader is in: the one whose top sits
  425. // closest above the strip's bottom edge (just below it).
  426. // Sections still below that line have not been entered;
  427. // before the first one crosses, nothing is current.
  428. function currentSection(strip) {
  429. var mark = strip.getBoundingClientRect().bottom + 24;
  430. var current = null;
  431. var best = Infinity;
  432. var all = sections();
  433. for (var i = 0; i < all.length; i++) {
  434. var distance = all[i].getBoundingClientRect().top - mark;
  435. if (distance > 0) continue; // still below the fold line
  436. if (-distance <= best) {
  437. best = -distance;
  438. current = all[i];
  439. }
  440. }
  441. return current;
  442. }
  443. function sync() {
  444. strips().forEach(function (strip) {
  445. syncTopbarOffset();
  446. // The strip sticks once the page scrolls it under
  447. // the topbar: the class carries the opaque surface
  448. // and edge the un-stuck strip must not have.
  449. var bar = document.querySelector('.topbar');
  450. var barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
  451. strip.classList.toggle('is-stuck',
  452. strip.getBoundingClientRect().top <= barBottom);
  453. var region = currentSection(strip);
  454. highlight(strip,
  455. region ? region.getAttribute('data-subnav-section') : null);
  456. });
  457. }
  458. document.addEventListener('click', function (event) {
  459. if (!event.target.closest) return;
  460. var link = event.target.closest('[data-subnav] a[href^="#"]');
  461. if (!link) return;
  462. var region = document.getElementById(
  463. link.getAttribute('href').slice(1));
  464. if (!region) return;
  465. event.preventDefault();
  466. var strip = link.closest('[data-subnav]');
  467. window.scrollTo({
  468. top: region.getBoundingClientRect().top
  469. + window.pageYOffset - jumpOffset(strip),
  470. behavior: 'smooth'
  471. });
  472. if (history.replaceState) {
  473. history.replaceState(null, '',
  474. link.getAttribute('href'));
  475. }
  476. });
  477. window.addEventListener('scroll', sync, { passive: true });
  478. window.addEventListener('resize', sync, { passive: true });
  479. statum.afterEvaluate(sync);
  480. sync();
  481. };
  482. if (window.statum) {
  483. (function () {
  484. 'use strict';
  485. window.showToast = function (message, kind) {
  486. var stack = document.querySelector('.toast-stack');
  487. if (!stack) {
  488. stack = document.createElement('div');
  489. stack.className = 'toast-stack';
  490. document.body.appendChild(stack);
  491. }
  492. var toast = document.createElement('div');
  493. toast.className = 'toast ' + (kind || '');
  494. toast.textContent = message;
  495. stack.appendChild(toast);
  496. window.setTimeout(function () {
  497. toast.classList.add('leaving');
  498. window.setTimeout(function () { toast.remove(); }, 300);
  499. }, 5000);
  500. };
  501. statum.onNotifyHandler = function (kind, message) {
  502. showToast(message || kind, kind);
  503. };
  504. //
  505. // Web Push bell engine (goal 41 task 3; extracted from
  506. // the dashboard's inline script so the /profile settings
  507. // card and the dashboard's compact rail strip run the
  508. // SAME per-browser verdict logic): the page state's
  509. // endpoint FINGERPRINTS are matched against this
  510. // browser's own pushManager subscription (hashed the
  511. // same way), so each browser shows its own truth —
  512. // subscribed here ("Turn off") vs subscribed elsewhere
  513. // only ("Enable" + a count). The client-side check is
  514. // authoritative; the server flag is a convenience for
  515. // other consumers.
  516. //
  517. window.initPushBell = function (config) {
  518. var stateType = (config && config.stateType) || 'dashboard';
  519. function state() {
  520. return (window.statum && statum.read(stateType)) || {};
  521. }
  522. function fail(error) {
  523. showToast('Notifications: '
  524. + (error && error.message ? error.message : error), 'error');
  525. }
  526. function vapidKeyBytes(base64Url) {
  527. var padding = '='.repeat((4 - base64Url.length % 4) % 4);
  528. var base64 = (base64Url + padding).replace(/-/g, '+')
  529. .replace(/_/g, '/');
  530. var raw = atob(base64);
  531. var bytes = new Uint8Array(raw.length);
  532. for (var index = 0; index < raw.length; index++) {
  533. bytes[index] = raw.charCodeAt(index);
  534. }
  535. return bytes;
  536. }
  537. function enablePush() {
  538. var page = state();
  539. if (!page.push_vapid_key) {
  540. fail('not available on this server');
  541. return;
  542. }
  543. if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
  544. fail('this browser has no Web Push support');
  545. return;
  546. }
  547. Notification.requestPermission().then(function (permission) {
  548. if (permission !== 'granted') {
  549. fail('permission denied in the browser');
  550. return;
  551. }
  552. // The service worker lives at the origin root
  553. // (GET /sw.js, Service-Worker-Allowed: /), so
  554. // its scope covers every page.
  555. return navigator.serviceWorker.register('/sw.js', { scope: '/' })
  556. .then(function () { return navigator.serviceWorker.ready; })
  557. .then(function (registration) {
  558. return registration.pushManager
  559. .getSubscription().then(function (existing) {
  560. if (existing) return existing;
  561. return registration.pushManager.subscribe({
  562. userVisibleOnly: true,
  563. applicationServerKey:
  564. vapidKeyBytes(page.push_vapid_key)
  565. });
  566. });
  567. })
  568. .then(function (subscription) {
  569. var keys = subscription.toJSON().keys || {};
  570. return statum.act(page.push_subscribe, {
  571. endpoint: subscription.endpoint,
  572. p256dh: keys.p256dh,
  573. auth: keys.auth
  574. });
  575. })
  576. .catch(function (error) { fail(error); });
  577. });
  578. }
  579. function disablePush() {
  580. var page = state();
  581. navigator.serviceWorker.getRegistration('/')
  582. .then(function (registration) {
  583. if (!registration) return null;
  584. return registration.pushManager.getSubscription();
  585. }).then(function (subscription) {
  586. var done = subscription
  587. ? subscription.unsubscribe() : Promise.resolve(true);
  588. return done.then(function () {
  589. if (subscription && page.push_unsubscribe) {
  590. return statum.act(page.push_unsubscribe, {
  591. endpoint: subscription.endpoint
  592. });
  593. }
  594. });
  595. }).catch(function (error) { fail(error); });
  596. }
  597. function sendTest() {
  598. var page = state();
  599. if (!page.push_test) {
  600. fail('not available on this server');
  601. return;
  602. }
  603. statum.act(page.push_test, {})
  604. .catch(function (error) { fail(error); });
  605. }
  606. var browserSubscribed = null; // null = still asking the browser
  607. var hereNow = false; // the displayed verdict (a click acts on it)
  608. function sha256Hex16(text) {
  609. // Mirrors PushService.endpoint_fingerprint: the
  610. // first 8 bytes of the SHA-256, hex.
  611. // crypto.subtle needs a secure context, exactly
  612. // like pushManager itself.
  613. var data = new TextEncoder().encode(text);
  614. return crypto.subtle.digest('SHA-256', data)
  615. .then(function (digest) {
  616. var bytes = new Uint8Array(digest).slice(0, 8);
  617. var hex = '';
  618. for (var index = 0; index < bytes.length; index++) {
  619. hex += ('0' + bytes[index].toString(16)).slice(-2);
  620. }
  621. return hex;
  622. });
  623. }
  624. function myFingerprint() {
  625. if (!('serviceWorker' in navigator)
  626. || !('PushManager' in window)) {
  627. return Promise.resolve(null);
  628. }
  629. return navigator.serviceWorker.getRegistration('/')
  630. .then(function (registration) {
  631. if (!registration) return null;
  632. return registration.pushManager
  633. .getSubscription().then(function (sub) {
  634. return sub ? sha256Hex16(sub.endpoint) : null;
  635. });
  636. }).catch(function () { return null; });
  637. }
  638. function otherBrowserCount() {
  639. // The state's fingerprints minus this browser's own.
  640. var mine = browserSubscribed;
  641. var list = state().push_endpoints || [];
  642. if (mine === null || mine === undefined) return list.length;
  643. var others = 0;
  644. for (var index = 0; index < list.length; index++) {
  645. if (list[index] !== mine) others++;
  646. }
  647. return others;
  648. }
  649. function refreshBell() {
  650. return myFingerprint().then(function (mine) {
  651. browserSubscribed = mine;
  652. var list = state().push_endpoints || [];
  653. // The page's .push-bell-card is the live one
  654. // (the dashboard's rail strip or /profile's
  655. // settings card — one per page).
  656. var card = document.querySelector('.push-bell-card');
  657. if (!card) return;
  658. var here = mine !== null && list.indexOf(mine) !== -1;
  659. var others = otherBrowserCount();
  660. var onBtn = card.querySelector('[data-bell-on]');
  661. var offBtn = card.querySelector('[data-bell-off]');
  662. var help = card.querySelector('[data-bell-help]');
  663. var testBtn = card.querySelector('[data-push-test]');
  664. hereNow = here;
  665. if (onBtn) onBtn.hidden = here;
  666. if (offBtn) offBtn.hidden = !here;
  667. if (testBtn) testBtn.hidden = !here;
  668. if (help) {
  669. if (here) {
  670. help.textContent = others > 0
  671. ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
  672. + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
  673. : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
  674. } else if (list.length > 0) {
  675. help.textContent = '\u2014 you are subscribed on '
  676. + list.length
  677. + ' other browser(s); enable here too to be notified in this browser';
  678. } else {
  679. help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
  680. }
  681. }
  682. });
  683. }
  684. // The page state refreshes on every pulse; the
  685. // bell's per-browser verdict must be re-derived each
  686. // time (the browser's own subscription may have
  687. // changed under us).
  688. statum.addStateListener(stateType, refreshBell);
  689. refreshBell();
  690. document.addEventListener('click', function (event) {
  691. if (!event.target.closest) return;
  692. var card = event.target.closest('.push-bell-card');
  693. if (!card) return;
  694. var bell = event.target.closest('[data-push-bell]');
  695. var test = event.target.closest('[data-push-test]');
  696. if (bell) {
  697. event.preventDefault();
  698. // Follow the DISPLAYED verdict: a browser
  699. // holding a subscription the server no longer
  700. // knows (a pruned row) shows Enable — and
  701. // enabling re-registers the existing
  702. // subscription, healing it.
  703. if (hereNow) disablePush();
  704. else enablePush();
  705. } else if (test) {
  706. event.preventDefault();
  707. sendTest();
  708. }
  709. });
  710. };
  711. var quoteHandlers = [];
  712. var quoteButton = null;
  713. var quotePopover = null;
  714. var quotedText = '';
  715. var activeHandler = null;
  716. window.initQuoteComments = function (handler) {
  717. quoteHandlers.push(handler);
  718. };
  719. document.addEventListener('mouseup', function (event) {
  720. if (quoteButton && quoteButton.contains(event.target)) return;
  721. var selection = window.getSelection();
  722. var text = selection ? String(selection.toString()).trim() : '';
  723. activeHandler = matchHandler(event.target);
  724. if (text.length === 0 || !activeHandler) {
  725. hideQuoteUi();
  726. return;
  727. }
  728. quotedText = text;
  729. showQuoteButton(event.pageX, event.pageY);
  730. });
  731. document.addEventListener('mousedown', function (event) {
  732. if (quoteButton && quoteButton.contains(event.target)) return;
  733. if (quotePopover && quotePopover.contains(event.target)) return;
  734. hideQuoteUi();
  735. });
  736. function matchHandler(node) {
  737. for (var i = 0; i < quoteHandlers.length; i++) {
  738. if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
  739. }
  740. return null;
  741. }
  742. function showQuoteButton(x, y) {
  743. hideQuoteUi();
  744. quoteButton = document.createElement('button');
  745. quoteButton.type = 'button';
  746. quoteButton.className = 'quote-button';
  747. quoteButton.textContent = 'Comment on this';
  748. quoteButton.style.left = Math.max(x - 60, 8) + 'px';
  749. quoteButton.style.top = (y - 36) + 'px';
  750. quoteButton.addEventListener('click', function (event) {
  751. event.preventDefault();
  752. openQuoteDialog();
  753. });
  754. document.body.appendChild(quoteButton);
  755. }
  756. function openQuoteDialog() {
  757. if (quoteButton) { quoteButton.remove(); quoteButton = null; }
  758. var dialog = document.getElementById('quote-comment-dialog');
  759. if (!dialog) return;
  760. var quote = dialog.querySelector('#quote-text');
  761. var field = dialog.querySelector('input[name="quote"]');
  762. if (quote) quote.textContent = quotedText;
  763. if (field) field.value = quotedText;
  764. var returnField = dialog.querySelector('input[name="return_to"]');
  765. if (returnField) returnField.value = location.pathname + location.search;
  766. if (typeof dialog.showModal === 'function') {
  767. dialog.showModal();
  768. } else {
  769. dialog.setAttribute('open', '');
  770. }
  771. }
  772. function hideQuoteUi() {
  773. if (quoteButton) { quoteButton.remove(); quoteButton = null; }
  774. if (quotePopover) { quotePopover.remove(); quotePopover = null; }
  775. activeHandler = null;
  776. }
  777. })();
  778. }
  779. //
  780. // Theme hook (goal 41 task 2): the DEVICE-scope "ui" slot carries
  781. // the account's saved appearance ({theme, mode}). The seven theme
  782. // stylesheets main.html links stay inert until <html> carries
  783. // data-theme, and data-mode picks light/dark/auto - so applying a
  784. // theme is purely attribute work on documentElement. Runs on statum
  785. // init and after every render cycle, so a saved choice paints the
  786. // first page and any later change applies immediately.
  787. //
  788. window.applyUiSlot = function () {
  789. if (!window.statum || !statum.read) return;
  790. var ui = statum.read('ui');
  791. var root = document.documentElement;
  792. if (ui && ui.theme) {
  793. root.setAttribute('data-theme', ui.theme);
  794. } else {
  795. root.removeAttribute('data-theme');
  796. }
  797. if (ui && ui.mode && ui.mode !== 'auto') {
  798. root.setAttribute('data-mode', ui.mode);
  799. } else {
  800. root.setAttribute('data-mode', 'auto');
  801. }
  802. };
  803. if (window.statum) {
  804. statum.afterEvaluate(window.applyUiSlot);
  805. window.applyUiSlot();
  806. }
  807. //
  808. // App dialog helper (goal 41 T4). The create forms on the
  809. // tasks/goals/projects/stacks lists live in native <dialog>
  810. // modals styled with Spry's dialog classes; this helper owns
  811. // opening them and closing them when their stm-action form
  812. // gets an answer.
  813. //
  814. // Verified against js/statum.js (asserted in the T4 bundle):
  815. // a re-render (pulse or set directive) never rebuilds static
  816. // markup — only stm-for loop instances are cloned — so an
  817. // open dialog KEEPS its open state and typed values across
  818. // re-renders. Nothing in statum closes or resets a form
  819. // after an action: the server's `notify` directive is the
  820. // success/failure signal (kind success vs error), and
  821. // Clanker's create actions ALWAYS notify on both paths. So
  822. // the helper wraps statum.onNotifyHandler and closes the top
  823. // open dialog on ANY notify while one is open: a success
  824. // closes the modal; an error closes it too (the toast
  825. // carries the message — a failed create with a stuck-open
  826. // modal is worse than a closed one whose field values are
  827. // still there when it is reopened). A `navigate` response
  828. // tears the whole page down, dialog included.
  829. //
  830. window.openAppDialog = function (id) {
  831. var dialog = document.getElementById(id);
  832. if (!dialog) return;
  833. var previousNotify = statum.onNotifyHandler;
  834. statum.onNotifyHandler = function (kind, message) {
  835. // Restore the app toast handler, then close.
  836. statum.onNotifyHandler = previousNotify === null
  837. ? null : previousNotify;
  838. if (dialog.open) dialog.close();
  839. if (statum.onNotifyHandler) {
  840. statum.onNotifyHandler(kind, message);
  841. }
  842. };
  843. if (typeof dialog.showModal === 'function') {
  844. dialog.showModal();
  845. } else {
  846. dialog.setAttribute('open', '');
  847. }
  848. };
  849. window.closeAppDialog = function (id) {
  850. var dialog = document.getElementById(id);
  851. if (dialog && dialog.open) dialog.close();
  852. };