| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911 |
- // Shared page plumbing. showToast: slide-in notifications (used in
- // place of alert). initQuoteComments: registers a container matcher
- // for the text-highlight -> comment popover - selecting text inside
- // a matched container shows a floating button, then a popover that
- // posts the comment with the quoted text via the action the page
- // supplies.
- // Shared UI engines (parameterised per page):
- //
- // showToast - every page's toast corner.
- //
- // initQuoteComments - the text-selection quote popover + dialog
- // posting a comment with the quoted text; pages pass a `matches`
- // node predicate and the action accessor.
- //
- // initPlanReviews (plan 16 R3, arch 15 - the ~180-line pattern
- // that lived duplicated between the task and goal pages, now
- // goal-only after the task-page plan review died with the poll
- // era): the plan/completion REVIEW remark queue. "Add remark"
- // appends to a per-block queue rendered at the bottom of the
- // review body, persists across statum re-renders (the maps live
- // outside the render cycle), rides the rejection NUMBERED (each
- // remark with its selection quote prefixed like a comment), and
- // clears on accept. Remark-less rejection is allowed when the
- // feedback textarea carries the substance (plan 16 S2). The
- // rejection body rides `feedback`; the SERVER wraps the per-kind
- // stable prefix (R4 - templates carry no verdict constants). The
- // page supplies `resolveBlock(id)` - how a [data-plan-review]
- // container's block row resolves from page state.
- window.initPlanReviews = function (config) {
- if (!window.statum || !statum.afterEvaluate) return;
- var resolveBlock = (config && config.resolveBlock)
- || function () { return null; };
- var remarksByBlock = {};
- var rejectOpenByBlock = {};
- var remarkPopover = null;
- statum.afterEvaluate(syncPlanRemarks);
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var accept = event.target.closest('[data-plan-accept]');
- if (accept) {
- // Accept ignores/clears the queued remarks.
- var accepted = accept.closest('[data-plan-review]');
- if (accepted) {
- var acceptId = accepted.getAttribute('data-block-id');
- remarksByBlock[acceptId] = [];
- rejectOpenByBlock[acceptId] = false;
- }
- return;
- }
- var toggle = event.target.closest('[data-plan-toggle]');
- if (toggle) {
- hideRemarkPopover();
- var review = toggle.closest('[data-plan-review]');
- var id = review && review.getAttribute('data-block-id');
- if (id) rejectOpenByBlock[id] = !rejectOpenByBlock[id];
- syncPlanRemarks();
- return;
- }
- var submit = event.target.closest('[data-plan-submit]');
- if (submit) {
- submitPlanRemarks(submit.closest('[data-plan-review]'));
- return;
- }
- var remove = event.target.closest('[data-plan-remove]');
- if (remove) {
- var entry = remove.closest('[data-plan-remark-id]');
- var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id');
- var list = remarksByBlock[blockId] || [];
- var index = parseInt(entry.getAttribute('data-plan-remark-id'), 10);
- if (!isNaN(index) && list[index]) list.splice(index, 1);
- remarksByBlock[blockId] = list;
- syncPlanRemarks();
- }
- });
- document.addEventListener('mouseup', function (event) {
- if (!event.target.closest) return;
- if (remarkPopover && remarkPopover.contains(event.target)) return;
- var body = event.target.closest('[data-plan-body]');
- if (!body) {
- hideRemarkPopover();
- return;
- }
- var review = body.closest('[data-plan-review]');
- if (!review || review.getAttribute('data-plan-open') !== '1') return;
- var selection = window.getSelection();
- var text = selection ? String(selection.toString()).trim() : '';
- if (text.length === 0) {
- hideRemarkPopover();
- return;
- }
- showRemarkPopover(event.pageX, event.pageY, text, review);
- });
- document.addEventListener('mousedown', function (event) {
- if (remarkPopover && event.target.closest
- && event.target.closest('.plan-popover')) return;
- hideRemarkPopover();
- });
- function showRemarkPopover(x, y, quote, review) {
- hideRemarkPopover();
- remarkPopover = document.createElement('div');
- remarkPopover.className = 'plan-popover';
- remarkPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px';
- remarkPopover.style.top = y + 'px';
- var quoted = document.createElement('blockquote');
- quoted.textContent = quote;
- remarkPopover.appendChild(quoted);
- var body = document.createElement('textarea');
- body.placeholder = 'Your remark on the selected plan text';
- remarkPopover.appendChild(body);
- var actions = document.createElement('div');
- actions.className = 'actions';
- var add = document.createElement('button');
- add.type = 'button';
- add.className = 'btn-primary btn-sm';
- add.textContent = 'Add remark';
- add.addEventListener('click', function () {
- if (body.value.trim().length === 0) return;
- var id = review.getAttribute('data-block-id');
- var list = remarksByBlock[id] || [];
- list.push({ quote: quote, body: body.value.trim() });
- remarksByBlock[id] = list;
- hideRemarkPopover();
- syncPlanRemarks();
- });
- var cancel = document.createElement('button');
- cancel.type = 'button';
- cancel.textContent = 'Cancel';
- cancel.addEventListener('click', hideRemarkPopover);
- actions.appendChild(add);
- actions.appendChild(cancel);
- remarkPopover.appendChild(actions);
- document.body.appendChild(remarkPopover);
- body.focus();
- }
- function hideRemarkPopover() {
- if (remarkPopover) {
- remarkPopover.remove();
- remarkPopover = null;
- }
- }
- function submitPlanRemarks(review) {
- if (!review) return;
- var id = review.getAttribute('data-block-id');
- var list = remarksByBlock[id] || [];
- var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
- var extraText = extra && extra.value.trim().length > 0 ? extra.value.trim() : '';
- // Remark-less rejection is allowed when the feedback
- // textarea carries the review's substance - one of the
- // two must be non-empty (plan 16 S2).
- if (list.length === 0 && extraText.length === 0) {
- if (window.showToast) window.showToast('Highlight some text and add a remark, or write feedback first', 'error');
- return;
- }
- var block = resolveBlock(id);
- if (!block || !block.answer_action) return;
- // The rejection body keeps the historical shape: every
- // queued remark NUMBERED in queue order, each with its
- // selection quote prefixed like a comment, then the
- // free-text feedback.
- var feedback = list.map(function (item, index) {
- return (item.quote
- ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n'
- : '') + (index + 1) + '. ' + item.body;
- }).join('\n\n');
- if (extraText.length > 0) {
- feedback += (feedback.length > 0 ? '\n\n' : '') + extraText;
- }
- remarksByBlock[id] = [];
- rejectOpenByBlock[id] = false;
- statum.act(block.answer_action, { feedback: feedback }).catch(function () { });
- }
- function syncPlanRemarks() {
- document.querySelectorAll('[data-plan-review]').forEach(function (review) {
- var id = review.getAttribute('data-block-id');
- var open = rejectOpenByBlock[id] ? '1' : '0';
- review.setAttribute('data-plan-open', open);
- var feedback = review.querySelector('[data-plan-feedback]');
- if (feedback) {
- if (rejectOpenByBlock[id]) feedback.removeAttribute('hidden');
- else feedback.setAttribute('hidden', '');
- }
- var list = review.querySelector('[data-plan-remarks]');
- if (!list) return;
- while (list.firstChild) list.removeChild(list.firstChild);
- (remarksByBlock[id] || []).forEach(function (item, index) {
- var entry = document.createElement('li');
- entry.setAttribute('data-plan-remark-id', String(index));
- if (item.quote) {
- var quoted = document.createElement('blockquote');
- quoted.textContent = item.quote;
- entry.appendChild(quoted);
- }
- var body = document.createElement('div');
- body.textContent = item.body;
- entry.appendChild(body);
- var remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'btn-ghost btn-sm';
- remove.setAttribute('data-plan-remove', '');
- remove.textContent = 'Remove';
- entry.appendChild(remove);
- list.appendChild(entry);
- });
- });
- }
- };
- //
- // initLongFormEditors (plan QoL task 12): in-place editors for the
- // long-form card fields (task mandate, goal brief). The rendered
- // markdown view and the editor <form> BOTH live in the page markup;
- // a per-card `data-lf-open` flag (kept OUTSIDE statum state, the
- // plan-remarks pattern) toggles which side shows. Why JS-applied
- // values and not stm-attribute.value: statum's attribute binding
- // sets the CONTENT attribute, which the browser ignores on
- // <textarea> (its value lives in the text node) - so the textarea
- // is re-prefilled from the card's data-value payload after every
- // render cycle, while the page state carries the source of truth.
- // The card contract:
- // [data-long-form] the card root; data-field = slot key
- // whose object carries the payload
- // [data-lf-view] the rendered markdown half
- // [data-lf-edit] the editor half (form)
- // textarea/input[data-lf-src] a state path ("task.description")
- // whose current value prefills the
- // control on every render
- // [data-lf-toggle] the Edit / Cancel button
- // Buttons INSIDE the form (Save) submit through the form's
- // stm-action as usual; toggling is purely presentational, so no
- // state round-trip and no flicker.
- //
- window.initLongFormEditors = function () {
- if (!window.statum || !statum.afterEvaluate) return;
- // Resolve a "type.field" state path: statum.read returns the
- // SLOT's public object, so the first segment is the slot and
- // the rest walks into it.
- function readPath(path) {
- if (!path) return '';
- var parts = path.split('.');
- var value = statum.read(parts[0]);
- for (var i = 1; i < parts.length && value != null; i++) {
- value = value[parts[i]];
- }
- return value === undefined || value === null ? '' : String(value);
- }
- function syncCard(card) {
- var open = card.getAttribute('data-lf-open') === '1';
- var view = card.querySelector('[data-lf-view]');
- var editor = card.querySelector('[data-lf-edit]');
- if (view) {
- if (open) view.setAttribute('hidden', '');
- else view.removeAttribute('hidden');
- }
- if (editor) {
- if (open) editor.removeAttribute('hidden');
- else editor.setAttribute('hidden', '');
- }
- }
- // Prefill bookkeeping: applied ONCE per open - a statum
- // re-render (pulse refresh, action response) rebinds
- // attributes but never textarea VALUES, so re-applying on
- // every cycle would clobber the user's in-progress typing.
- // Closing the card clears the flag; the next open re-prefills
- // from the (possibly updated) state.
- var prefilled = {};
- function applyPrefills() {
- var cards = document.querySelectorAll('[data-long-form]');
- Array.prototype.forEach.call(cards, function (card) {
- syncCard(card);
- var key = card.getAttribute('data-field') || '';
- var open = card.getAttribute('data-lf-open') === '1';
- if (open && !prefilled[key]) {
- var controls = card.querySelectorAll('[data-lf-src]');
- Array.prototype.forEach.call(controls, function (control) {
- var value = readPath(control.getAttribute('data-lf-src'));
- if (document.activeElement !== control) {
- control.value = value;
- }
- });
- prefilled[key] = true;
- }
- if (!open) delete prefilled[key];
- });
- }
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var button = event.target.closest('[data-lf-toggle]');
- if (!button) return;
- var card = button.closest('[data-long-form]');
- if (!card) return;
- event.preventDefault();
- var open = card.getAttribute('data-lf-open') === '1';
- if (open) {
- card.setAttribute('data-lf-open', '0');
- delete prefilled[card.getAttribute('data-field') || ''];
- } else {
- card.setAttribute('data-lf-open', '1');
- }
- syncCard(card);
- // Prefill immediately (the render cycle applies the state
- // bindings; the textarea's value needs this direct pass).
- applyPrefills();
- if (!open) {
- var area = card.querySelector('textarea');
- if (area && area.focus) area.focus();
- }
- });
- statum.afterEvaluate(applyPrefills);
- };
- //
- // Anchor sub-nav engine (goal 41 T5 shipped the chassis on the
- // task page, T6 the closest-to-fold selection rule on the goal
- // page; T11 unifies them into this ONE engine both pages run).
- //
- // Markup contract (docs/spry-conversion.md):
- // <nav class="subnav" data-subnav aria-label="Sections">
- // <a href="#section-id">…</a>…
- // </nav>
- // <section id="section-id" data-subnav-section="section-id">…
- //
- // The engine owns: (a) smooth in-page jumps that stop below the
- // sticky chrome (topbar + strip, measured at click time), (b)
- // highlighting whichever region the reader is actually in — the
- // section whose top sits CLOSEST above the strip's bottom edge
- // (ties to the later DOM node); the single-column "last heading
- // that crossed the line" reading T5's IntersectionObserver
- // implemented is the same outcome, and the closest-to-fold rule
- // stays correct in the two-column Act|Consult grid where an
- // observer can light a consult card while the reader looks at an
- // act one, (c) the strip's .is-stuck class once the page scrolls
- // it under the topbar, and (d) publishing the measured topbar
- // height as --topbar-bottom (the sticky log toolbar consumes the
- // same property). Listeners bind ONCE on document/window — statum
- // re-renders replace the DOM but never static markup's listeners,
- // and the bind flag keeps a second init call from stacking them;
- // statum.afterEvaluate re-runs the sync pass so the strip
- // re-derives its highlight and stuck state after every render.
- //
- // Nav dropdown toggles (goal 41 review round 3): the menu TOPS are
- // buttons, not links - tapping a link navigated away before a touch
- // user could pick a submenu item. Click toggles .is-open on the
- // .has-submenu li (spry's hover/focus-within rules still serve
- // pointer users); a click outside, or Esc, closes. The engine binds
- // once at document level (delegation), so statum re-renders never
- // strand a menu.
- (function initNavToggles() {
- function closeAll(except) {
- var open = document.querySelectorAll('.has-submenu.is-open');
- for (var i = 0; i < open.length; i++) {
- if (open[i] !== except) {
- open[i].classList.remove('is-open');
- var toggle = open[i].querySelector('.nav-toggle');
- if (toggle) toggle.setAttribute('aria-expanded', 'false');
- }
- }
- }
- document.addEventListener('click', function (event) {
- var toggle = event.target.closest ? event.target.closest('.nav-toggle') : null;
- if (toggle) {
- var item = toggle.closest('.has-submenu');
- var isOpen = item.classList.contains('is-open');
- closeAll(item);
- item.classList.toggle('is-open', !isOpen);
- toggle.setAttribute('aria-expanded', String(!isOpen));
- return;
- }
- // A click on a submenu item navigates; anything else closes.
- if (!event.target.closest || !event.target.closest('.has-submenu')) closeAll(null);
- });
- document.addEventListener('keydown', function (event) {
- if (event.key === 'Escape') closeAll(null);
- });
- })();
- window.initSubnav = function () {
- if (!window.statum || !statum.afterEvaluate) return;
- var FLAG = 'data-subnav-engine';
- var root = document.documentElement;
- if (root.getAttribute(FLAG) === 'bound') return; // already live
- root.setAttribute(FLAG, 'bound');
- function strips() {
- return Array.prototype.slice.call(
- document.querySelectorAll('[data-subnav]'));
- }
- function sections() {
- return Array.prototype.slice.call(
- document.querySelectorAll('[data-subnav-section]'));
- }
- // The strip parks under the sticky topbar; publish the
- // measured topbar height as a custom property so app.css's
- // `top:` tracks any wrap/viewport.
- function syncTopbarOffset() {
- var bar = document.querySelector('.topbar');
- // The topbar is static (review round 3 - it scrolled away with
- // the page), so nothing sits above a sticky strip except a
- // SCROLLED-PAST topbar: publish 0 and let app.css park the
- // strips at the viewport top. jumpOffset() still measures the
- // real bar for in-page anchor jumps (a click near the top of a
- // fresh page can land while the bar is still visible).
- var offset = (bar && getComputedStyle(bar).position === 'sticky')
- ? bar.offsetHeight : 0;
- root.style.setProperty('--topbar-bottom', offset + 'px');
- }
- // How much sticky chrome sits above a scrolled-to heading:
- // the topbar plus the strip itself (so the heading the
- // reader jumped to is not swallowed by either).
- function jumpOffset(strip) {
- var height = 0;
- var bar = document.querySelector('.topbar');
- if (bar) height += bar.offsetHeight;
- if (strip) height += strip.offsetHeight + 8;
- return height;
- }
- function highlight(strip, id) {
- var links = strip.querySelectorAll('a[href^="#"]');
- for (var i = 0; i < links.length; i++) {
- var active = links[i].getAttribute('href') === '#' + id;
- links[i].classList.toggle('active', active);
- if (active) {
- links[i].setAttribute('aria-current', 'true');
- } else {
- links[i].removeAttribute('aria-current');
- }
- }
- }
- // The section the reader is in: the one whose top sits
- // closest above the strip's bottom edge (just below it).
- // Sections still below that line have not been entered;
- // before the first one crosses, nothing is current.
- function currentSection(strip) {
- var mark = strip.getBoundingClientRect().bottom + 24;
- var current = null;
- var best = Infinity;
- var all = sections();
- for (var i = 0; i < all.length; i++) {
- var distance = all[i].getBoundingClientRect().top - mark;
- if (distance > 0) continue; // still below the fold line
- if (-distance <= best) {
- best = -distance;
- current = all[i];
- }
- }
- return current;
- }
- function sync() {
- strips().forEach(function (strip) {
- syncTopbarOffset();
- // The strip sticks once the page scrolls it under
- // the topbar: the class carries the opaque surface
- // and edge the un-stuck strip must not have.
- var bar = document.querySelector('.topbar');
- var barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
- strip.classList.toggle('is-stuck',
- strip.getBoundingClientRect().top <= barBottom);
- var region = currentSection(strip);
- highlight(strip,
- region ? region.getAttribute('data-subnav-section') : null);
- });
- }
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var link = event.target.closest('[data-subnav] a[href^="#"]');
- if (!link) return;
- var region = document.getElementById(
- link.getAttribute('href').slice(1));
- if (!region) return;
- event.preventDefault();
- var strip = link.closest('[data-subnav]');
- window.scrollTo({
- top: region.getBoundingClientRect().top
- + window.pageYOffset - jumpOffset(strip),
- behavior: 'smooth'
- });
- if (history.replaceState) {
- history.replaceState(null, '',
- link.getAttribute('href'));
- }
- });
- window.addEventListener('scroll', sync, { passive: true });
- window.addEventListener('resize', sync, { passive: true });
- statum.afterEvaluate(sync);
- sync();
- };
- if (window.statum) {
- (function () {
- 'use strict';
- window.showToast = function (message, kind) {
- var stack = document.querySelector('.toast-stack');
- if (!stack) {
- stack = document.createElement('div');
- stack.className = 'toast-stack';
- document.body.appendChild(stack);
- }
- var toast = document.createElement('div');
- toast.className = 'toast ' + (kind || '');
- toast.textContent = message;
- stack.appendChild(toast);
- window.setTimeout(function () {
- toast.classList.add('leaving');
- window.setTimeout(function () { toast.remove(); }, 300);
- }, 5000);
- };
- statum.onNotifyHandler = function (kind, message) {
- showToast(message || kind, kind);
- };
- //
- // Web Push bell engine (goal 41 task 3; extracted from
- // the dashboard's inline script so the /profile settings
- // card and the dashboard's compact rail strip run the
- // SAME per-browser verdict logic): the page state's
- // endpoint FINGERPRINTS are matched against this
- // browser's own pushManager subscription (hashed the
- // same way), so each browser shows its own truth —
- // subscribed here ("Turn off") vs subscribed elsewhere
- // only ("Enable" + a count). The client-side check is
- // authoritative; the server flag is a convenience for
- // other consumers.
- //
- window.initPushBell = function (config) {
- var stateType = (config && config.stateType) || 'dashboard';
- function state() {
- return (window.statum && statum.read(stateType)) || {};
- }
- function fail(error) {
- showToast('Notifications: '
- + (error && error.message ? error.message : error), 'error');
- }
- function vapidKeyBytes(base64Url) {
- var padding = '='.repeat((4 - base64Url.length % 4) % 4);
- var base64 = (base64Url + padding).replace(/-/g, '+')
- .replace(/_/g, '/');
- var raw = atob(base64);
- var bytes = new Uint8Array(raw.length);
- for (var index = 0; index < raw.length; index++) {
- bytes[index] = raw.charCodeAt(index);
- }
- return bytes;
- }
- function enablePush() {
- var page = state();
- if (!page.push_vapid_key) {
- fail('not available on this server');
- return;
- }
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
- fail('this browser has no Web Push support');
- return;
- }
- Notification.requestPermission().then(function (permission) {
- if (permission !== 'granted') {
- fail('permission denied in the browser');
- return;
- }
- // The service worker lives at the origin root
- // (GET /sw.js, Service-Worker-Allowed: /), so
- // its scope covers every page.
- return navigator.serviceWorker.register('/sw.js', { scope: '/' })
- .then(function () { return navigator.serviceWorker.ready; })
- .then(function (registration) {
- return registration.pushManager
- .getSubscription().then(function (existing) {
- if (existing) return existing;
- return registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey:
- vapidKeyBytes(page.push_vapid_key)
- });
- });
- })
- .then(function (subscription) {
- var keys = subscription.toJSON().keys || {};
- return statum.act(page.push_subscribe, {
- endpoint: subscription.endpoint,
- p256dh: keys.p256dh,
- auth: keys.auth
- });
- })
- .catch(function (error) { fail(error); });
- });
- }
- function disablePush() {
- var page = state();
- navigator.serviceWorker.getRegistration('/')
- .then(function (registration) {
- if (!registration) return null;
- return registration.pushManager.getSubscription();
- }).then(function (subscription) {
- var done = subscription
- ? subscription.unsubscribe() : Promise.resolve(true);
- return done.then(function () {
- if (subscription && page.push_unsubscribe) {
- return statum.act(page.push_unsubscribe, {
- endpoint: subscription.endpoint
- });
- }
- });
- }).catch(function (error) { fail(error); });
- }
- function sendTest() {
- var page = state();
- if (!page.push_test) {
- fail('not available on this server');
- return;
- }
- statum.act(page.push_test, {})
- .catch(function (error) { fail(error); });
- }
- var browserSubscribed = null; // null = still asking the browser
- var hereNow = false; // the displayed verdict (a click acts on it)
- function sha256Hex16(text) {
- // Mirrors PushService.endpoint_fingerprint: the
- // first 8 bytes of the SHA-256, hex.
- // crypto.subtle needs a secure context, exactly
- // like pushManager itself.
- var data = new TextEncoder().encode(text);
- return crypto.subtle.digest('SHA-256', data)
- .then(function (digest) {
- var bytes = new Uint8Array(digest).slice(0, 8);
- var hex = '';
- for (var index = 0; index < bytes.length; index++) {
- hex += ('0' + bytes[index].toString(16)).slice(-2);
- }
- return hex;
- });
- }
- function myFingerprint() {
- if (!('serviceWorker' in navigator)
- || !('PushManager' in window)) {
- return Promise.resolve(null);
- }
- return navigator.serviceWorker.getRegistration('/')
- .then(function (registration) {
- if (!registration) return null;
- return registration.pushManager
- .getSubscription().then(function (sub) {
- return sub ? sha256Hex16(sub.endpoint) : null;
- });
- }).catch(function () { return null; });
- }
- function otherBrowserCount() {
- // The state's fingerprints minus this browser's own.
- var mine = browserSubscribed;
- var list = state().push_endpoints || [];
- if (mine === null || mine === undefined) return list.length;
- var others = 0;
- for (var index = 0; index < list.length; index++) {
- if (list[index] !== mine) others++;
- }
- return others;
- }
- function refreshBell() {
- return myFingerprint().then(function (mine) {
- browserSubscribed = mine;
- var list = state().push_endpoints || [];
- // The page's .push-bell-card is the live one
- // (the dashboard's rail strip or /profile's
- // settings card — one per page).
- var card = document.querySelector('.push-bell-card');
- if (!card) return;
- var here = mine !== null && list.indexOf(mine) !== -1;
- var others = otherBrowserCount();
- var onBtn = card.querySelector('[data-bell-on]');
- var offBtn = card.querySelector('[data-bell-off]');
- var help = card.querySelector('[data-bell-help]');
- var testBtn = card.querySelector('[data-push-test]');
- hereNow = here;
- if (onBtn) onBtn.hidden = here;
- if (offBtn) offBtn.hidden = !here;
- if (testBtn) testBtn.hidden = !here;
- if (help) {
- if (here) {
- help.textContent = others > 0
- ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
- + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
- : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
- } else if (list.length > 0) {
- help.textContent = '\u2014 you are subscribed on '
- + list.length
- + ' other browser(s); enable here too to be notified in this browser';
- } else {
- help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
- }
- }
- });
- }
- // The page state refreshes on every pulse; the
- // bell's per-browser verdict must be re-derived each
- // time (the browser's own subscription may have
- // changed under us).
- statum.addStateListener(stateType, refreshBell);
- refreshBell();
- document.addEventListener('click', function (event) {
- if (!event.target.closest) return;
- var card = event.target.closest('.push-bell-card');
- if (!card) return;
- var bell = event.target.closest('[data-push-bell]');
- var test = event.target.closest('[data-push-test]');
- if (bell) {
- event.preventDefault();
- // Follow the DISPLAYED verdict: a browser
- // holding a subscription the server no longer
- // knows (a pruned row) shows Enable — and
- // enabling re-registers the existing
- // subscription, healing it.
- if (hereNow) disablePush();
- else enablePush();
- } else if (test) {
- event.preventDefault();
- sendTest();
- }
- });
- };
- var quoteHandlers = [];
- var quoteButton = null;
- var quotePopover = null;
- var quotedText = '';
- var activeHandler = null;
- window.initQuoteComments = function (handler) {
- quoteHandlers.push(handler);
- };
- document.addEventListener('mouseup', function (event) {
- if (quoteButton && quoteButton.contains(event.target)) return;
- var selection = window.getSelection();
- var text = selection ? String(selection.toString()).trim() : '';
- activeHandler = matchHandler(event.target);
- if (text.length === 0 || !activeHandler) {
- hideQuoteUi();
- return;
- }
- quotedText = text;
- showQuoteButton(event.pageX, event.pageY);
- });
- document.addEventListener('mousedown', function (event) {
- if (quoteButton && quoteButton.contains(event.target)) return;
- if (quotePopover && quotePopover.contains(event.target)) return;
- hideQuoteUi();
- });
- function matchHandler(node) {
- for (var i = 0; i < quoteHandlers.length; i++) {
- if (quoteHandlers[i].matches(node)) return quoteHandlers[i];
- }
- return null;
- }
- function showQuoteButton(x, y) {
- hideQuoteUi();
- quoteButton = document.createElement('button');
- quoteButton.type = 'button';
- quoteButton.className = 'quote-button';
- quoteButton.textContent = 'Comment on this';
- quoteButton.style.left = Math.max(x - 60, 8) + 'px';
- quoteButton.style.top = (y - 36) + 'px';
- quoteButton.addEventListener('click', function (event) {
- event.preventDefault();
- openQuoteDialog();
- });
- document.body.appendChild(quoteButton);
- }
- function openQuoteDialog() {
- if (quoteButton) { quoteButton.remove(); quoteButton = null; }
- var dialog = document.getElementById('quote-comment-dialog');
- if (!dialog) return;
- var quote = dialog.querySelector('#quote-text');
- var field = dialog.querySelector('input[name="quote"]');
- if (quote) quote.textContent = quotedText;
- if (field) field.value = quotedText;
- var returnField = dialog.querySelector('input[name="return_to"]');
- if (returnField) returnField.value = location.pathname + location.search;
- if (typeof dialog.showModal === 'function') {
- dialog.showModal();
- } else {
- dialog.setAttribute('open', '');
- }
- }
- function hideQuoteUi() {
- if (quoteButton) { quoteButton.remove(); quoteButton = null; }
- if (quotePopover) { quotePopover.remove(); quotePopover = null; }
- activeHandler = null;
- }
- })();
- }
- //
- // Theme hook (goal 41 task 2): the DEVICE-scope "ui" slot carries
- // the account's saved appearance ({theme, mode}). The seven theme
- // stylesheets main.html links stay inert until <html> carries
- // data-theme, and data-mode picks light/dark/auto - so applying a
- // theme is purely attribute work on documentElement. Runs on statum
- // init and after every render cycle, so a saved choice paints the
- // first page and any later change applies immediately.
- //
- window.applyUiSlot = function () {
- if (!window.statum || !statum.read) return;
- var ui = statum.read('ui');
- var root = document.documentElement;
- if (ui && ui.theme) {
- root.setAttribute('data-theme', ui.theme);
- } else {
- root.removeAttribute('data-theme');
- }
- if (ui && ui.mode && ui.mode !== 'auto') {
- root.setAttribute('data-mode', ui.mode);
- } else {
- root.setAttribute('data-mode', 'auto');
- }
- };
- if (window.statum) {
- statum.afterEvaluate(window.applyUiSlot);
- window.applyUiSlot();
- }
- //
- // App dialog helper (goal 41 T4). The create forms on the
- // tasks/goals/projects/stacks lists live in native <dialog>
- // modals styled with Spry's dialog classes; this helper owns
- // opening them and closing them when their stm-action form
- // gets an answer.
- //
- // Verified against js/statum.js (asserted in the T4 bundle):
- // a re-render (pulse or set directive) never rebuilds static
- // markup — only stm-for loop instances are cloned — so an
- // open dialog KEEPS its open state and typed values across
- // re-renders. Nothing in statum closes or resets a form
- // after an action: the server's `notify` directive is the
- // success/failure signal (kind success vs error), and
- // Clanker's create actions ALWAYS notify on both paths. So
- // the helper wraps statum.onNotifyHandler and closes the top
- // open dialog on ANY notify while one is open: a success
- // closes the modal; an error closes it too (the toast
- // carries the message — a failed create with a stuck-open
- // modal is worse than a closed one whose field values are
- // still there when it is reopened). A `navigate` response
- // tears the whole page down, dialog included.
- //
- window.openAppDialog = function (id) {
- var dialog = document.getElementById(id);
- if (!dialog) return;
- var previousNotify = statum.onNotifyHandler;
- statum.onNotifyHandler = function (kind, message) {
- // Restore the app toast handler, then close.
- statum.onNotifyHandler = previousNotify === null
- ? null : previousNotify;
- if (dialog.open) dialog.close();
- if (statum.onNotifyHandler) {
- statum.onNotifyHandler(kind, message);
- }
- };
- if (typeof dialog.showModal === 'function') {
- dialog.showModal();
- } else {
- dialog.setAttribute('open', '');
- }
- };
- window.closeAppDialog = function (id) {
- var dialog = document.getElementById(id);
- if (dialog && dialog.open) dialog.close();
- };
|