@omega.js/client 0.1.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.
Files changed (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * icon-core — the ONE place Font Awesome icon semantics live (C4 cp108).
3
+ *
4
+ * Pure functions, zero runtime assumptions: no DOM, no fs, no transport.
5
+ * Web's build-time inlining pass (@omega.js/web src/inline-icons.js) and
6
+ * @omega.js/desktop's main-process icon server both consume THIS module, so
7
+ * lookup rules and rendered SVG markup can never drift between surfaces
8
+ * again. File reading stays with each consumer (build tags read at build
9
+ * time, desktop main reads at runtime) — this module owns every decision
10
+ * ABOUT the files: valid names/styles, candidate lookup order, the inline
11
+ * root attributes, the package preference order (Pro when the brand
12
+ * supplies it, free floor), and alias mapping from the icon set's own
13
+ * metadata ('search' → 'magnifying-glass').
14
+ *
15
+ * CJS on purpose: template-kit and Electron main require() it directly (via
16
+ * the package's dist exports); browser modules import it with standard
17
+ * interop.
18
+ */
19
+
20
+ // Icon asset packages, best-first (cp111): a brand that supplies Font
21
+ // Awesome Pro gets it automatically; the free set is the always-present
22
+ // floor (a declared dependency of web + desktop). Pro is NEVER a dependency
23
+ // of any omega package — redistribution is a license violation — each brand
24
+ // brings its own licensed copy (npm token install, or a fontawesome.com
25
+ // download dir via OMEGA_FONTAWESOME_ROOT).
26
+ const PACKAGES = ['@fortawesome/fontawesome-pro', '@fortawesome/fontawesome-free'];
27
+
28
+ // The ONE emitted directory name, every surface: a site serves it at
29
+ // /assets/icons/<style>/<name>.svg, an extension packs it at
30
+ // assets/icons/… ([#619](https://github.com/Omega-JS-Stack/omega/issues/619)).
31
+ // `icons` and not `fa` because flags ride the same tree as a second
32
+ // namespace, and nothing about the channel is Font Awesome specific. It lives
33
+ // HERE with the rest of the lookup rules — the build side re-exports it
34
+ // (@omega.js/devkit/icons) and every browser transport derives its base from
35
+ // it, so the name can never drift between what a build emits and what a page
36
+ // fetches. (The extension manifest's web_accessible_resources stays a literal:
37
+ // manifest.json is static JSON, read by Chrome, not by us.)
38
+ const ICONS_DIR = 'icons';
39
+
40
+ // The free set's svgs/ directories. Pro supplies more (light, thin,
41
+ // duotone, sharp-*, …) — validation is by shape, not this list, so new
42
+ // Pro families work without this module tracking Font Awesome's catalog.
43
+ const STYLES = ['solid', 'regular', 'brands'];
44
+
45
+ // Style dirs are path segments too — same traversal rule as names, so an
46
+ // unknown style can only ever be a file-not-found, never an escape.
47
+ const STYLE_REGEX = /^[a-z][a-z-]*$/;
48
+
49
+ // Lowercase slug names only — lookups build file paths, so this whitelist
50
+ // is also what keeps callers (like desktop's IPC channel) from ever reading
51
+ // outside the icon directories.
52
+ const NAME_REGEX = /^[a-z0-9-]+$/;
53
+
54
+ // Attributes injected on the <svg> root at serve time: icons size to the
55
+ // surrounding font and inherit its color. overflow="visible" mirrors FA's
56
+ // own kit CSS (.svg-inline--fa { overflow: visible }) — FA 7 glyphs may
57
+ // draw OUTSIDE their viewBox (fa-lock's shackle peaks at y=-32 in a
58
+ // 0 0 384 512 box) and the SVG-root default of overflow:hidden clips them.
59
+ const SVG_ATTRIBUTES = [
60
+ ['width', 'width="1em"'],
61
+ ['height', 'height="1em"'],
62
+ ['fill', 'fill="currentColor"'],
63
+ ['aria-hidden', 'aria-hidden="true"'],
64
+ ['focusable', 'focusable="false"'],
65
+ ['overflow', 'overflow="visible"'],
66
+ ];
67
+
68
+ /**
69
+ * Whether a value is a valid icon slug ('magnifying-glass').
70
+ *
71
+ * @param {*} name - Candidate icon name.
72
+ * @returns {boolean} True for lowercase slug strings.
73
+ */
74
+ function isValidIconName(name) {
75
+ return typeof name === 'string' && NAME_REGEX.test(name);
76
+ }
77
+
78
+ /**
79
+ * Whether a value is a plausible icon style ('solid', 'duotone',
80
+ * 'sharp-light'). Shape-validated (path-safe slug), not whitelist-validated:
81
+ * whether the style actually exists in the supplied icon set is decided by
82
+ * the file lookup — a bogus style is a missing icon, never a crash or an
83
+ * escape from the icon directories.
84
+ *
85
+ * @param {*} style - Candidate style.
86
+ * @returns {boolean} True for lowercase path-safe style slugs.
87
+ */
88
+ function isValidStyle(style) {
89
+ return typeof style === 'string' && STYLE_REGEX.test(style);
90
+ }
91
+
92
+ /**
93
+ * Inject the shared root attributes into a raw icon SVG string — each one
94
+ * only when the root doesn't already carry it, so hand-authored SVGs keep
95
+ * their own sizing/fill.
96
+ *
97
+ * @param {string} svg - Raw SVG source.
98
+ * @returns {string} SVG with the inline-icon root attributes.
99
+ */
100
+ function injectSvgAttributes(svg) {
101
+ if (typeof svg !== 'string' || !svg.includes('<svg')) return svg;
102
+
103
+ // No comment survives into the injected markup: Font Awesome ships its
104
+ // license comment inside every glyph, and that comment's `-->` TERMINATES
105
+ // any HTML comment the icon happens to sit inside — a commented-out block
106
+ // carrying an icon (body.html's parked flash-sale banner) rendered on every
107
+ // page. Attribution stays with the set: the emitted /assets/icons/ files
108
+ // are copies of the originals, comments intact.
109
+ return svg.replace(/<!--[\s\S]*?-->/g, '').replace(/<svg([^>]*)>/, (match, existingAttrs) => {
110
+ const toAdd = SVG_ATTRIBUTES
111
+ .filter(([attr]) => !existingAttrs.includes(`${attr}=`))
112
+ .map(([, pair]) => pair);
113
+
114
+ return toAdd.length ? `<svg${existingAttrs} ${toAdd.join(' ')}>` : match;
115
+ }).trim();
116
+ }
117
+
118
+ /**
119
+ * Relative candidate paths for an icon, in lookup order: the requested
120
+ * style first, then the brands fallback — so logos like 'apple' resolve
121
+ * without callers knowing which side of the set they live on.
122
+ *
123
+ * @param {string} name - Icon slug.
124
+ * @param {string} style - Requested style.
125
+ * @returns {string[]} Relative paths to try against each icon root.
126
+ */
127
+ function candidateRelPaths(name, style) {
128
+ const candidates = [`${style}/${name}.svg`];
129
+ if (style !== 'brands') {
130
+ candidates.push(`brands/${name}.svg`);
131
+ }
132
+ return candidates;
133
+ }
134
+
135
+ // Font Awesome's class model: a base WEIGHT class, an optional FAMILY
136
+ // prefix class, and everything else is a modifier or the icon name.
137
+ const BASE_STYLE_CLASSES = {
138
+ 'fa-solid': 'solid', fas: 'solid',
139
+ 'fa-regular': 'regular', far: 'regular',
140
+ 'fa-light': 'light', fal: 'light',
141
+ 'fa-thin': 'thin', fat: 'thin',
142
+ 'fa-brands': 'brands', fab: 'brands',
143
+ };
144
+ const FAMILY_CLASSES = { 'fa-sharp': 'sharp', 'fa-duotone': 'duotone', fad: 'duotone', 'fa-sharp-duotone': 'sharp-duotone' };
145
+
146
+ // fa-* classes that are modifiers (style/size/animation/layout), not icon names.
147
+ // The size roster is the sheet's $fa-sizes map, all 12 of it (@omega.js/web
148
+ // core/css/core/_custom-font-awesome.scss). A shipped size class this list
149
+ // misses reads as an icon NAME and the renderer hunts for a glyph called '4xl'.
150
+ const MODIFIER_REGEX = /^fa-(?:solid|brands|regular|light|thin|duotone|sharp-duotone|sharp|fw|2xs|xs|sm|base|md|lg|xl|2xl|3xl|4xl|5xl|6xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|fade|beat-fade|bounce|shake|flip(?:-horizontal|-vertical|-both)?|rotate-(?:90|180|270|by)|inverse|border|pull-left|pull-right|stack(?:-1x|-2x)?|li|ul|sr-only)$/;
151
+
152
+ // Country flags are the SECOND namespace on this pipeline (#619) — not Font
153
+ // Awesome, but the same markup shape, the same lookup, the same emitted tree:
154
+ // `<i class="omega-flag omega-flag-us">` is `flags/us.svg`.
155
+ const FLAG_CLASS_REGEX = /^omega-flag-([a-z0-9-]+)$/;
156
+
157
+ /**
158
+ * Parse an element's class list the way Font Awesome does:
159
+ * `fa-sharp fa-light fa-play me-2` → { name: 'play', style: 'sharp-light' }.
160
+ * Weight defaults to solid; a family class prefixes it (duotone-solid lives
161
+ * in the bare `duotone` dir). A flag class (`omega-flag-us`) is the one
162
+ * non-FA namespace and resolves to the `flags` style. Null when no icon name
163
+ * is present.
164
+ *
165
+ * @param {Iterable<string>} classList - Element class names.
166
+ * @returns {{ name: string, style: string }|null} Parsed lookup, or null.
167
+ */
168
+ function parseIconClasses(classList) {
169
+ let base = 'solid';
170
+ let family = '';
171
+ let name = null;
172
+
173
+ for (const cls of classList) {
174
+ const flag = FLAG_CLASS_REGEX.exec(cls);
175
+ if (flag) {
176
+ return { name: flag[1], style: 'flags' };
177
+ }
178
+ if (BASE_STYLE_CLASSES[cls]) {
179
+ base = BASE_STYLE_CLASSES[cls];
180
+ } else if (FAMILY_CLASSES[cls]) {
181
+ family = FAMILY_CLASSES[cls];
182
+ } else if (!name && cls.startsWith('fa-') && !MODIFIER_REGEX.test(cls)) {
183
+ name = cls.slice(3);
184
+ }
185
+ }
186
+ if (!name) {
187
+ return null;
188
+ }
189
+
190
+ const style = (base === 'brands' || !family) ? base
191
+ : (family === 'duotone' && base === 'solid') ? 'duotone'
192
+ : `${family}-${base}`;
193
+ return { name, style };
194
+ }
195
+
196
+ /**
197
+ * Build the alias → canonical name map from fontawesome-free's
198
+ * metadata/icon-families.json ('search' → 'magnifying-glass').
199
+ *
200
+ * @param {object} iconFamilies - Parsed icon-families.json content.
201
+ * @returns {Map<string, string>} Alias slug → canonical slug.
202
+ */
203
+ function buildAliasMap(iconFamilies) {
204
+ const map = new Map();
205
+ for (const [canonical, entry] of Object.entries(iconFamilies || {})) {
206
+ for (const alias of entry?.aliases?.names || []) {
207
+ map.set(alias, canonical);
208
+ }
209
+ }
210
+ return map;
211
+ }
212
+
213
+ module.exports = {
214
+ PACKAGES,
215
+ ICONS_DIR,
216
+ STYLES,
217
+ STYLE_REGEX,
218
+ NAME_REGEX,
219
+ SVG_ATTRIBUTES,
220
+ isValidIconName,
221
+ isValidStyle,
222
+ injectSvgAttributes,
223
+ candidateRelPaths,
224
+ parseIconClasses,
225
+ buildAliasMap,
226
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * icon-renderer — the ONE Font Awesome DOM auto-render (C4 cp112), shared
3
+ * by every browser surface: desktop renderers, web pages, and extension
4
+ * pages when one appears. Authoring is plain Font Awesome markup:
5
+ *
6
+ * <i class="fa-solid fa-rocket"></i> — static HTML
7
+ * <i class="omega-flag omega-flag-us"></i> — the flags namespace (#619)
8
+ * el.className = 'fa-sharp fa-light fa-play' — set or CHANGED via JS,
9
+ * any time; both render.
10
+ *
11
+ * The module owns everything except transport: scanning, the
12
+ * MutationObserver (insertions AND class changes), class parsing
13
+ * (icon-core's parseIconClasses), SVG root attributes, caching, and
14
+ * re-render/clear semantics. The caller injects ONE function — where SVG
15
+ * text comes from:
16
+ *
17
+ * desktop → (name, style) => ipc.invoke('desktop:fontawesome:get', …)
18
+ * web → (name, style) => fetch(`/assets/icons/${style}/${name}.svg`)
19
+ * extension→ (name, style) => fetch(chrome.runtime.getURL(…))
20
+ *
21
+ * Rendered elements carry data-omega-fa="<style>/<name>". Unknown icons
22
+ * leave the element empty (marked) — a missing icon is a content problem,
23
+ * never a crash, and never a wrong-style fallback.
24
+ */
25
+
26
+ const { parseIconClasses, isValidIconName, isValidStyle, injectSvgAttributes } = require('./icon-core.js');
27
+
28
+ // Both namespaces the class parser understands (#619): Font Awesome's fa-*
29
+ // and the country flags' omega-flag-*.
30
+ const ICON_SELECTOR = 'i[class*="fa-"], i[class*="omega-flag-"]';
31
+ const RENDERED_SELECTOR = `${ICON_SELECTOR}, i[data-omega-fa]`;
32
+
33
+ /**
34
+ * Create an icon renderer bound to a transport.
35
+ *
36
+ * @param {object} options
37
+ * @param {function(string, string): Promise<string|null>} options.resolve -
38
+ * Icon transport: (name, style) → raw SVG text or null.
39
+ * @returns {{ start: function, stop: function, scan: function }}
40
+ */
41
+ function createIconRenderer(options) {
42
+ const cache = new Map(); // 'style/name' → Promise<string|null>
43
+ let observer = null;
44
+ let started = false;
45
+
46
+ const resolve = (name, style) => {
47
+ const key = `${style}/${name}`;
48
+ if (!cache.has(key)) {
49
+ cache.set(key, Promise.resolve()
50
+ .then(() => options.resolve(name, style))
51
+ .then((svg) => (svg ? injectSvgAttributes(svg) : null))
52
+ .catch(() => null));
53
+ }
54
+ return cache.get(key);
55
+ };
56
+
57
+ const render = (el) => {
58
+ const parsed = parseIconClasses(el.classList);
59
+ if (!parsed || !isValidIconName(parsed.name) || !isValidStyle(parsed.style)) {
60
+ // No (valid) icon classes left — clear a previously rendered icon.
61
+ if (el.dataset.omegaFa) {
62
+ delete el.dataset.omegaFa;
63
+ el.innerHTML = '';
64
+ }
65
+ return;
66
+ }
67
+
68
+ const key = `${parsed.style}/${parsed.name}`;
69
+ if (el.dataset.omegaFa === key) {
70
+ return;
71
+ }
72
+
73
+ el.dataset.omegaFa = key;
74
+ el.innerHTML = '';
75
+ resolve(parsed.name, parsed.style).then((svg) => {
76
+ // Stale guard: classes may have changed again while resolving.
77
+ if (svg && el.isConnected && el.dataset.omegaFa === key && !el.querySelector('svg')) {
78
+ el.innerHTML = svg;
79
+ }
80
+ });
81
+ };
82
+
83
+ const scan = (root) => {
84
+ if (root.matches?.(ICON_SELECTOR)) {
85
+ render(root);
86
+ }
87
+ root.querySelectorAll?.(ICON_SELECTOR).forEach(render);
88
+ };
89
+
90
+ /**
91
+ * Scan the document and observe it for inserted icons AND class changes
92
+ * on existing ones. Idempotent.
93
+ *
94
+ * @param {Document} [doc] - Defaults to the global document.
95
+ */
96
+ const start = (doc = typeof document !== 'undefined' ? document : null) => {
97
+ if (!doc || started) {
98
+ return;
99
+ }
100
+ started = true;
101
+
102
+ const begin = () => {
103
+ observer = new MutationObserver((mutations) => {
104
+ for (const mutation of mutations) {
105
+ if (mutation.type === 'attributes') {
106
+ if (mutation.target.matches?.(RENDERED_SELECTOR)) {
107
+ render(mutation.target);
108
+ }
109
+ continue;
110
+ }
111
+ mutation.addedNodes.forEach((node) => {
112
+ if (node.nodeType === 1) {
113
+ scan(node);
114
+ }
115
+ });
116
+ }
117
+ });
118
+ observer.observe(doc.documentElement, {
119
+ childList: true,
120
+ subtree: true,
121
+ attributes: true,
122
+ attributeFilter: ['class'],
123
+ });
124
+
125
+ scan(doc.documentElement);
126
+ };
127
+
128
+ // Deferred until the document exists — preload-time callers (the
129
+ // desktop test harness) run before documentElement is built.
130
+ if (doc.documentElement && doc.readyState !== 'loading') {
131
+ begin();
132
+ } else {
133
+ doc.addEventListener('DOMContentLoaded', begin, { once: true });
134
+ }
135
+ };
136
+
137
+ const stop = () => {
138
+ if (observer) {
139
+ observer.disconnect();
140
+ observer = null;
141
+ }
142
+ started = false;
143
+ cache.clear();
144
+ };
145
+
146
+ return { start, stop, scan };
147
+ }
148
+
149
+ module.exports = { createIconRenderer };
@@ -0,0 +1,235 @@
1
+ /**
2
+ * live-page — the primitives a page that REFRESHES ITSELF needs, ported from
3
+ * the workkit tower's page runtime.
4
+ *
5
+ * A live page is polled: it re-reads its feeds on a timer and re-renders with
6
+ * the answers. The naive shape of that — write the whole body on every tick —
7
+ * repaints identical markup ten times a minute, which blinks the cards, drops
8
+ * the scroll position inside a scrolling strip and closes whatever `details`
9
+ * was open. And before the first answer arrives the same render draws an empty
10
+ * region, so a slow feed looks like an empty one.
11
+ *
12
+ * Three primitives, one rule each:
13
+ * - `swap(host, markup)` writes ONLY when the markup differs from what was
14
+ * written last, so an unchanged section is left alone — DOM, focus, scroll
15
+ * and all.
16
+ * - `loading(message)` is what a section shows while its feed has never
17
+ * answered: a first paint says which read it is waiting on, never a blank.
18
+ * - `createFeedPoller(options)` owns the declared feed table, the in-flight
19
+ * count and the keep-last-good rule — a refresh that fails leaves the last
20
+ * good answer on screen, marked stale with the reason.
21
+ *
22
+ * Transport-free, like `icon-renderer` and `motion`: nothing here fetches. The
23
+ * poller takes its fetcher as an ARGUMENT — an `omega.request`-shaped function
24
+ * (resolves with the body, throws an Error carrying `.code`) — so the embedding
25
+ * page passes `omega.request` and a non-singleton context (desktop main, the
26
+ * extension service worker) passes its own `createRequest(...)` instance, the
27
+ * same seam `request.js` already offers.
28
+ */
29
+
30
+ import Utilities from './utilities.js';
31
+
32
+ // Escaping is utilities' job, never this module's. The helpers are pure — they
33
+ // touch no manager state — so a manager-free instance is the whole dependency.
34
+ const { escapeHTML } = new Utilities();
35
+
36
+ /** What swap last wrote into each host, keyed by the element itself. */
37
+ const written = new WeakMap();
38
+
39
+ /**
40
+ * The markup a section shows while its feed has not answered yet.
41
+ *
42
+ * The theme's spinner, in the muted voice a "nothing here" line uses.
43
+ *
44
+ * @param {string} message - what is being read, in the page's own words
45
+ * @returns {string} markup
46
+ */
47
+ export function loading(message) {
48
+ return `<div class="d-flex align-items-center gap-2 text-body-secondary">
49
+ <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
50
+ <span class="classy-micro" aria-live="polite">${escapeHTML(message)}</span>
51
+ </div>`;
52
+ }
53
+
54
+ /**
55
+ * Write markup into a host, but only when it is not already there.
56
+ *
57
+ * The comparison is against what swap itself last wrote, held in a WeakMap, and
58
+ * NOT against `host.innerHTML` — the browser re-serializes what it parses
59
+ * (attribute order, entities, void tags), so reading it back never matches the
60
+ * string that produced it and every tick would count as a change.
61
+ *
62
+ * @param {{innerHTML: string}} host - the element to draw into
63
+ * @param {string} markup - what the render produced this time
64
+ * @returns {boolean} true when the DOM was written, false when it was left
65
+ * alone — the caller's post-draw work (charts, listeners) hangs off this
66
+ */
67
+ export function swap(host, markup) {
68
+ if (written.get(host) === markup) {
69
+ return false;
70
+ }
71
+
72
+ written.set(host, markup);
73
+ host.innerHTML = markup;
74
+
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * Boot a feed poller over a declared feed table.
80
+ *
81
+ * The cadence is the poller's own: it stops while the page is hidden (a covered
82
+ * tab) and resumes on the way back with one immediate read. There is no option
83
+ * for it.
84
+ *
85
+ * @param {object} options
86
+ * @param {Object<string, {path: string, every: number, fresh?: string}>} options.feeds -
87
+ * every feed this page reads: where it lives, how often it is re-read, and
88
+ * the optional path a user-triggered refresh uses instead (a cache bypass)
89
+ * @param {(path: string) => Promise<any>} options.fetcher - an
90
+ * `omega.request`-shaped function: resolves with the body, throws on failure
91
+ * @param {() => void} [options.onChange] - called at every state transition (a
92
+ * read starting, a read landing), which is when the page repaints
93
+ * @returns {{state: object, read: Function, readAll: Function, staleFeeds: Function, start: Function, stop: Function}}
94
+ */
95
+ export function createFeedPoller(options) {
96
+ if (typeof options.fetcher !== 'function') {
97
+ throw new Error('createFeedPoller requires a fetcher');
98
+ }
99
+
100
+ const feeds = options.feeds || {};
101
+ const names = Object.keys(feeds);
102
+ const onChange = options.onChange || (() => {});
103
+
104
+ // `pending` is how many reads are in flight, which a page's chrome draws its
105
+ // spinner from: a refresh is visible while it happens, and the page under it
106
+ // keeps showing the data it already has.
107
+ const state = { feeds: {}, pending: 0, stamp: '' };
108
+
109
+ let timers = [];
110
+ let started = false;
111
+
112
+ // The hidden-tab rule needs a document to read: a poller running where there
113
+ // is none (the extension service worker the fetcher seam exists for) has no
114
+ // tab that can be covered, and just keeps its cadence.
115
+ const page = typeof document === 'undefined' ? null : document;
116
+
117
+ // The fetcher throws; a feed result never does. Every way a read can let a
118
+ // page down lands in the same four-key shape, told apart by status and reason.
119
+ const ask = async (path) => {
120
+ try {
121
+ return { ok: true, data: await options.fetcher(path), status: null, reason: null };
122
+ } catch (error) {
123
+ return { ok: false, data: null, status: error?.code ?? null, reason: error?.message || 'the read failed' };
124
+ }
125
+ };
126
+
127
+ const read = async (name, fresh) => {
128
+ const spec = feeds[name];
129
+ if (!spec) {
130
+ throw new Error(`createFeedPoller: no feed named "${name}"`);
131
+ }
132
+
133
+ state.pending += 1;
134
+ onChange();
135
+
136
+ const answer = await ask(fresh && spec.fresh ? spec.fresh : spec.path);
137
+ const previous = state.feeds[name];
138
+ state.pending -= 1;
139
+
140
+ // A refresh that fails does not take the page down with it. The last good
141
+ // answer stays on screen, marked stale so the chrome can say a feed is
142
+ // unavailable — replacing a full board with an error line because one poll
143
+ // missed is the "clearing to empty" this module exists to prevent.
144
+ state.feeds[name] = !answer.ok && previous && previous.ok
145
+ ? { ...previous, stale: answer.reason }
146
+ : answer;
147
+ state.stamp = `updated ${new Date().toLocaleTimeString()}`;
148
+ onChange();
149
+ };
150
+
151
+ const readAll = (fresh) => Promise.all(names.map((name) => read(name, fresh)));
152
+
153
+ /**
154
+ * Every feed that is not currently telling the truth — one that failed
155
+ * outright, and one showing a last-good answer behind a failed refresh.
156
+ *
157
+ * @returns {Array<{name: string, reason: string}>}
158
+ */
159
+ const staleFeeds = () => Object.entries(state.feeds)
160
+ .filter(([, result]) => result && (!result.ok || result.stale))
161
+ .map(([name, result]) => ({ name, reason: result.stale || result.reason }));
162
+
163
+ const arm = () => {
164
+ timers = names.map((name) => setInterval(() => { read(name, false); }, feeds[name].every));
165
+ };
166
+
167
+ const disarm = () => {
168
+ timers.forEach((timer) => clearInterval(timer));
169
+ timers = [];
170
+ };
171
+
172
+ // A covered TAB reads nothing. Polling a page nobody is looking at spends the
173
+ // device's battery and the backend's quota on answers that are thrown away,
174
+ // so the intervals are cleared while the page is hidden and re-armed when it
175
+ // comes back — with ONE immediate read, because whatever is on screen is as
176
+ // old as the time the tab spent covered.
177
+ //
178
+ // `document.hidden` on purpose, and never focus: a visible-but-unfocused
179
+ // window is still being WATCHED — a board on a second monitor is the case
180
+ // this module exists for — and keeps its cadence.
181
+ const onVisibilityChange = () => {
182
+ if (!started) {
183
+ return;
184
+ }
185
+
186
+ if (page.hidden) {
187
+ disarm();
188
+ return;
189
+ }
190
+
191
+ // Armed already means this was not a return from hidden (a browser can say
192
+ // "visible" to a page that never left), and there is nothing to catch up on.
193
+ if (timers.length) {
194
+ return;
195
+ }
196
+
197
+ readAll(false);
198
+ arm();
199
+ };
200
+
201
+ const start = async () => {
202
+ if (started) {
203
+ return;
204
+ }
205
+ started = true;
206
+
207
+ await readAll(false);
208
+
209
+ // Armed AFTER the first pass so a slow first read is never overlapped by
210
+ // its own timer. A stop() during that first pass flips `started` back off
211
+ // before this line runs — arming anyway would leave intervals nothing can
212
+ // ever clear, so the re-check is what makes stop() final.
213
+ if (!started) {
214
+ return;
215
+ }
216
+
217
+ page?.addEventListener('visibilitychange', onVisibilityChange);
218
+
219
+ // A start in a tab that is ALREADY covered arms nothing: the listener above
220
+ // is what gives the page its cadence when it comes into view.
221
+ if (page?.hidden) {
222
+ return;
223
+ }
224
+
225
+ arm();
226
+ };
227
+
228
+ const stop = () => {
229
+ page?.removeEventListener('visibilitychange', onVisibilityChange);
230
+ disarm();
231
+ started = false;
232
+ };
233
+
234
+ return { state, read, readAll, staleFeeds, start, stop };
235
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The client's runtime log tag — the ONE identity tag every OMEGA surface
3
+ * prints: `[@omega.js/client:<module>]` ([#12](https://github.com/Omega-JS-Stack/omega/issues/12)).
4
+ *
5
+ * No timestamp on purpose: devtools already stamps runtime lines. The
6
+ * build-time twin (`@omega.js/devkit/logger`) is the one that prefixes
7
+ * `[HH:MM:SS]` before the same tag.
8
+ *
9
+ * Every client module logs through this factory — no module hand-writes a tag,
10
+ * so the shape can never drift again.
11
+ */
12
+
13
+ // The package segment — this file IS @omega.js/client, so it is a literal.
14
+ const PACKAGE = '@omega.js/client';
15
+
16
+ // Create a tagged console for one module, e.g. createLogger('push').
17
+ export function createLogger(module) {
18
+ const tag = `[${PACKAGE}:${module}]`;
19
+
20
+ // GETTERS returning a BOUND console method, not wrapper arrows: devtools
21
+ // attributes a line to the frame that called console, so a wrapper would make
22
+ // every line in the app read as coming from this file. Binding hands the real
23
+ // call site back. Resolution stays at ACCESS time, so a test (or a consumer)
24
+ // that swaps console[method] still sees its own stub.
25
+ const logger = { tag };
26
+ for (const method of ['log', 'info', 'warn', 'error', 'debug']) {
27
+ Object.defineProperty(logger, method, {
28
+ get: () => console[method].bind(console, tag),
29
+ enumerable: true,
30
+ });
31
+ }
32
+
33
+ return logger;
34
+ }
35
+
36
+ export default createLogger;