mnfst 0.5.68 → 0.5.70

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.
@@ -0,0 +1,212 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://manifestx.dev/manifest.schema.json",
4
+ "title": "Manifest project configuration",
5
+ "description": "Combined PWA manifest and Manifest framework configuration. See https://manifestx.dev/docs/getting-started/setup.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "format": "uri"
12
+ },
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Full application name shown by the OS and browsers."
16
+ },
17
+ "short_name": {
18
+ "type": "string",
19
+ "description": "Short name used on home screens and limited-width contexts."
20
+ },
21
+ "description": {
22
+ "type": "string"
23
+ },
24
+ "author": {
25
+ "type": "string"
26
+ },
27
+ "email": {
28
+ "type": "string",
29
+ "format": "email"
30
+ },
31
+ "live_url": {
32
+ "type": "string",
33
+ "format": "uri",
34
+ "description": "Production URL — used by the prerender pipeline for canonical/sitemap output."
35
+ },
36
+ "start_url": {
37
+ "type": "string",
38
+ "default": "/"
39
+ },
40
+ "scope": {
41
+ "type": "string",
42
+ "default": "/"
43
+ },
44
+ "display": {
45
+ "type": "string",
46
+ "enum": ["fullscreen", "standalone", "minimal-ui", "browser"]
47
+ },
48
+ "orientation": {
49
+ "type": "string",
50
+ "enum": ["any", "natural", "landscape", "landscape-primary", "landscape-secondary", "portrait", "portrait-primary", "portrait-secondary"]
51
+ },
52
+ "background_color": {
53
+ "type": "string",
54
+ "pattern": "^#([0-9A-Fa-f]{3}){1,2}$"
55
+ },
56
+ "theme_color": {
57
+ "type": "string",
58
+ "pattern": "^#([0-9A-Fa-f]{3}){1,2}$"
59
+ },
60
+ "icons": {
61
+ "type": "array",
62
+ "items": {
63
+ "type": "object",
64
+ "required": ["src"],
65
+ "properties": {
66
+ "src": { "type": "string" },
67
+ "sizes": { "type": "string" },
68
+ "type": { "type": "string" },
69
+ "purpose": { "type": "string" }
70
+ }
71
+ }
72
+ },
73
+ "components": {
74
+ "type": "array",
75
+ "description": "Component HTML files registered as <x-name> tags.",
76
+ "items": { "type": "string" }
77
+ },
78
+ "preloadedComponents": {
79
+ "type": "array",
80
+ "description": "Components loaded eagerly on initial page load (header, logo, etc.).",
81
+ "items": { "type": "string" }
82
+ },
83
+ "data": {
84
+ "type": "object",
85
+ "description": "Named data sources made available via $x.<sourceName>. Each value is either a string path/URL or a config object.",
86
+ "additionalProperties": {
87
+ "oneOf": [
88
+ {
89
+ "type": "string",
90
+ "description": "Path to a CSV, JSON, or YAML file, or an absolute HTTP URL."
91
+ },
92
+ {
93
+ "type": "object",
94
+ "description": "Localized source, HTTP source with config, or Appwrite-backed source.",
95
+ "properties": {
96
+ "url": {
97
+ "type": "string",
98
+ "description": "HTTP endpoint URL."
99
+ },
100
+ "headers": {
101
+ "type": "object",
102
+ "additionalProperties": { "type": "string" }
103
+ },
104
+ "params": {
105
+ "type": "object"
106
+ },
107
+ "transform": {
108
+ "type": "string",
109
+ "description": "Optional dot-path into the response body (e.g. 'data.items')."
110
+ },
111
+ "defaultValue": {
112
+ "description": "Value used while loading or on error."
113
+ },
114
+ "locales": {
115
+ "type": "string",
116
+ "description": "Single CSV with locale columns; locale resolution happens internally."
117
+ },
118
+ "appwriteTableId": { "type": "string" },
119
+ "appwriteBucketId": { "type": "string" },
120
+ "appwriteDatabaseId": { "type": "string" },
121
+ "appwriteProjectId": { "type": "string" },
122
+ "appwriteEndpoint": { "type": "string" },
123
+ "appwriteDevKey": { "type": "string" },
124
+ "scope": {
125
+ "oneOf": [
126
+ { "type": "string", "enum": ["user", "team"] },
127
+ { "type": "array", "items": { "type": "string", "enum": ["user", "team"] } }
128
+ ]
129
+ },
130
+ "autoInjectUserId": { "type": "boolean" },
131
+ "autoInjectTeamId": { "type": "boolean" },
132
+ "queries": {
133
+ "type": "array",
134
+ "items": { "type": "string" }
135
+ },
136
+ "storage": {
137
+ "type": "object",
138
+ "description": "Map of storage-bucket source names to the column on this row that holds their file IDs.",
139
+ "additionalProperties": { "type": "string" }
140
+ },
141
+ "throttle": { "type": "number" },
142
+ "cleanupInterval": { "type": "number" },
143
+ "minChangeThreshold": { "type": "number" },
144
+ "idleThreshold": { "type": "number" },
145
+ "enableVisualRendering": { "type": "boolean" },
146
+ "includeVelocity": { "type": "boolean" }
147
+ },
148
+ "additionalProperties": true
149
+ }
150
+ ]
151
+ }
152
+ },
153
+ "appwrite": {
154
+ "type": "object",
155
+ "description": "Appwrite global configuration. Per-source overrides live on the data entry.",
156
+ "properties": {
157
+ "projectId": { "type": "string" },
158
+ "endpoint": { "type": "string", "format": "uri" },
159
+ "databaseId": { "type": "string" },
160
+ "devKey": {
161
+ "type": "string",
162
+ "description": "May reference an env var via ${VAR_NAME}."
163
+ },
164
+ "auth": {
165
+ "type": "object",
166
+ "properties": {
167
+ "methods": {
168
+ "type": "array",
169
+ "items": {
170
+ "type": "string",
171
+ "enum": ["guest", "guest-manual", "magic", "oauth", "password"]
172
+ }
173
+ },
174
+ "teams": {
175
+ "type": "object",
176
+ "properties": {
177
+ "permanent": { "type": "array", "items": { "type": "string" } },
178
+ "template": { "type": "array", "items": { "type": "string" } }
179
+ }
180
+ },
181
+ "roles": {
182
+ "type": "object",
183
+ "additionalProperties": {
184
+ "type": "object",
185
+ "additionalProperties": {
186
+ "type": "array",
187
+ "items": { "type": "string" }
188
+ }
189
+ }
190
+ },
191
+ "creatorRole": { "type": "string" }
192
+ }
193
+ }
194
+ }
195
+ },
196
+ "prerender": {
197
+ "type": "object",
198
+ "description": "Static rendering configuration consumed by mnfst-render.",
199
+ "properties": {
200
+ "localUrl": { "type": "string", "format": "uri" },
201
+ "liveUrl": { "type": "string", "format": "uri" },
202
+ "output": { "type": "string", "default": "dist" },
203
+ "routerBase": { "type": "string", "default": "" },
204
+ "locales": {
205
+ "type": "array",
206
+ "items": { "type": "string" }
207
+ }
208
+ },
209
+ "additionalProperties": true
210
+ }
211
+ }
212
+ }
@@ -109,12 +109,46 @@ function initializeTabsPlugin() {
109
109
  }
110
110
  }
111
111
 
112
- // Process panels for this group - add x-show attributes
112
+ // ----- Accessibility wiring (WAI-ARIA Tabs pattern) -----
113
+ //
114
+ // Per the ARIA APG, a tabs widget needs:
115
+ // - role="tablist" on the tab container (parent of buttons)
116
+ // - role="tab" + aria-selected + aria-controls + tabindex on each button
117
+ // - role="tabpanel" + aria-labelledby + tabindex="0" on each panel
118
+ // - arrow-key navigation between tabs (with roving tabindex)
119
+ //
120
+ // We compute the tab-container element as the closest common ancestor of
121
+ // the relevant buttons (often a <nav> or <div>). Each button is assigned
122
+ // a stable id if it doesn't have one, so panels can reference it.
123
+
124
+ // Assign ids where missing.
125
+ const buttonIdByTabValue = {};
126
+ relevantButtons.forEach((button, i) => {
127
+ const tabValue = button.getAttribute('x-tab');
128
+ if (!tabValue) return;
129
+ if (!button.id) {
130
+ button.id = `mnfst-tab-${sanitizedPanelSet || 'g'}-${tabValue.replace(/[^a-zA-Z0-9_-]/g, '-')}-${i}`;
131
+ }
132
+ buttonIdByTabValue[tabValue] = button.id;
133
+ });
134
+
135
+ // Process panels for this group - add x-show + a11y attributes
113
136
  panels.forEach(panel => {
114
137
  // Create condition that checks if tab property matches this panel's identifier
115
138
  const showCondition = `${tabProp} === '${panel.id}'`;
116
139
  panel.element.setAttribute('x-show', showCondition);
117
140
 
141
+ // Ensure panel has an id (Alpine needs one for aria-labelledby on buttons)
142
+ if (!panel.element.id) panel.element.id = panel.id;
143
+
144
+ // ARIA: role + label + focusable
145
+ panel.element.setAttribute('role', 'tabpanel');
146
+ if (!panel.element.hasAttribute('tabindex')) {
147
+ panel.element.setAttribute('tabindex', '0');
148
+ }
149
+ const labelledBy = buttonIdByTabValue[panel.id];
150
+ if (labelledBy) panel.element.setAttribute('aria-labelledby', labelledBy);
151
+
118
152
  // Remove x-tabpanel attribute since we've converted it
119
153
  panel.element.removeAttribute('x-tabpanel');
120
154
  });
@@ -129,10 +163,57 @@ function initializeTabsPlugin() {
129
163
  const clickHandler = `${tabProp} = '${tabValue}'`;
130
164
  button.setAttribute('x-on:click', clickHandler);
131
165
 
166
+ // ARIA: role, selection state (reactive via :aria-selected), controls
167
+ button.setAttribute('role', 'tab');
168
+ button.setAttribute(':aria-selected', `String(${tabProp} === '${tabValue}')`);
169
+ // Roving tabindex: -1 when not active so arrow keys, not Tab, move between tabs.
170
+ button.setAttribute(':tabindex', `${tabProp} === '${tabValue}' ? '0' : '-1'`);
171
+ const panel = panels.find((p) => p.id === tabValue);
172
+ if (panel && panel.element.id) {
173
+ button.setAttribute('aria-controls', panel.element.id);
174
+ }
175
+
132
176
  // Remove x-tab attribute since we've converted it
133
177
  button.removeAttribute('x-tab');
134
178
  });
135
179
 
180
+ // Find the tablist container — closest common ancestor of all relevant
181
+ // buttons. If they share a direct parent that's the tablist; otherwise
182
+ // walk up until one wraps them all. Set role="tablist" + a keydown
183
+ // handler that walks the focusable tabs on Left/Right/Home/End.
184
+ if (relevantButtons.length > 0) {
185
+ let tablistEl = relevantButtons[0].parentElement;
186
+ while (tablistEl && tablistEl !== document.body) {
187
+ if (relevantButtons.every((b) => tablistEl.contains(b))) break;
188
+ tablistEl = tablistEl.parentElement;
189
+ }
190
+ if (tablistEl) {
191
+ tablistEl.setAttribute('role', 'tablist');
192
+ if (!tablistEl.__mnfstTabsKeydown) {
193
+ tablistEl.__mnfstTabsKeydown = (e) => {
194
+ const target = e.target;
195
+ if (!target || target.getAttribute('role') !== 'tab') return;
196
+ const tabs = Array.from(tablistEl.querySelectorAll('[role="tab"]'));
197
+ const idx = tabs.indexOf(target);
198
+ if (idx === -1) return;
199
+ let nextIdx = null;
200
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') nextIdx = (idx + 1) % tabs.length;
201
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') nextIdx = (idx - 1 + tabs.length) % tabs.length;
202
+ else if (e.key === 'Home') nextIdx = 0;
203
+ else if (e.key === 'End') nextIdx = tabs.length - 1;
204
+ if (nextIdx == null) return;
205
+ e.preventDefault();
206
+ // Automatic activation: focusing a tab selects it. This matches
207
+ // the most common APG variant and Manifest's existing click-to-
208
+ // activate semantics.
209
+ tabs[nextIdx].focus();
210
+ tabs[nextIdx].click();
211
+ };
212
+ tablistEl.addEventListener('keydown', tablistEl.__mnfstTabsKeydown);
213
+ }
214
+ }
215
+ }
216
+
136
217
  // Ensure Alpine processes the updated x-data and x-show attributes
137
218
  if (window.Alpine && typeof window.Alpine.initTree === 'function') {
138
219
  // If the parent already has Alpine initialized, we need to update it
@@ -1,6 +1,6 @@
1
1
  /* Manifest Theme CSS
2
2
  /* By Andrew Matlock under MIT license
3
- /* https://manifestjs.org/styles/theme
3
+ /* https://manifestx.dev/styles/theme
4
4
  /* Modify values to customize your project theme
5
5
  */
6
6
 
@@ -32,7 +32,14 @@ function initializeToastPlugin() {
32
32
 
33
33
  // Create toast element
34
34
  const toast = document.createElement('div');
35
- toast.setAttribute('role', 'alert');
35
+ // A11y: route by type. "negative" (and forward-compat "error" / "warning")
36
+ // interrupt with role="alert" (assertive live region). All other types —
37
+ // default, "brand", "accent", "positive" — use role="status" (polite)
38
+ // so screen readers finish what they're saying before announcing,
39
+ // matching the toast's intent as a non-urgent confirmation.
40
+ const isAssertive = type === 'negative' || type === 'error' || type === 'warning';
41
+ toast.setAttribute('role', isAssertive ? 'alert' : 'status');
42
+ if (!isAssertive) toast.setAttribute('aria-live', 'polite');
36
43
  toast.setAttribute('class', type ? `toast ${type}` : 'toast');
37
44
 
38
45
  // Create content with optional icon
@@ -155,6 +155,19 @@ function initializeTooltipPlugin() {
155
155
  s.activeTrigger = trigger;
156
156
  s.currentAnchorName = anchorName;
157
157
 
158
+ // A11y: link the trigger to the tooltip so screen readers announce the
159
+ // tooltip text as a description when the trigger receives focus or hover.
160
+ // Per WAI-ARIA, aria-describedby is the standard for this relationship.
161
+ if (!s.el.id) s.el.id = 'mnfst-tooltip-' + Math.random().toString(36).slice(2, 9);
162
+ s.el.setAttribute('role', 'tooltip');
163
+ // Preserve any author-provided aria-describedby so we don't stomp it.
164
+ if (!trigger._tooltipPriorDescribedBy) {
165
+ trigger._tooltipPriorDescribedBy = trigger.getAttribute('aria-describedby') || '';
166
+ }
167
+ const prior = trigger._tooltipPriorDescribedBy;
168
+ const merged = prior ? `${prior} ${s.el.id}` : s.el.id;
169
+ trigger.setAttribute('aria-describedby', merged);
170
+
158
171
  if (!s.el.matches(':popover-open')) s.el.showPopover();
159
172
  }
160
173
 
@@ -163,6 +176,16 @@ function initializeTooltipPlugin() {
163
176
  document.querySelectorAll('.tooltip[popover="hint"]:popover-open').forEach(el => {
164
177
  try { el.hidePopover(); } catch {}
165
178
  });
179
+ // Restore each tooltip's prior aria-describedby on the trigger it had been
180
+ // bound to. We can't reach the trigger from the popover alone, so we walk
181
+ // the tooltipped triggers and remove our id from their describedby list.
182
+ document.querySelectorAll('[aria-describedby]').forEach((el) => {
183
+ if (!el._tooltipPriorDescribedBy && el._tooltipPriorDescribedBy !== '') return;
184
+ const prior = el._tooltipPriorDescribedBy;
185
+ if (prior) el.setAttribute('aria-describedby', prior);
186
+ else el.removeAttribute('aria-describedby');
187
+ el._tooltipPriorDescribedBy = undefined;
188
+ });
166
189
  markTooltipHidden();
167
190
  }
168
191
 
@@ -258,6 +281,11 @@ function initializeTooltipPlugin() {
258
281
  el.addEventListener('mouseenter', requestShow);
259
282
  el.addEventListener('mouseleave', requestHide);
260
283
 
284
+ // Keyboard / focus interactions — WCAG 2.1 SC 1.4.13 requires tooltip
285
+ // content to be accessible to keyboard users via focus, not hover only.
286
+ el.addEventListener('focus', requestShow);
287
+ el.addEventListener('blur', requestHide);
288
+
261
289
  // Mousedown/click: always hide immediately; scheduleAnchorRestore so the
262
290
  // trigger's anchor-name stays valid long enough for any dropdown popover
263
291
  // it launches to position itself correctly.
@@ -1205,14 +1205,56 @@ TailwindCompiler.prototype.loadAndApplyCache = function () {
1205
1205
  }
1206
1206
  };
1207
1207
 
1208
- // Save cache to localStorage
1208
+ // Cap on persisted cache entries. Each entry stores a full compiled
1209
+ // stylesheet keyed by the union of classes seen on a given page, so on a
1210
+ // multi-page MPA this Map grows fast — and localStorage tops out at ~5MB per
1211
+ // origin. 20 covers typical hot paths; rarer routes recompile (cheap).
1212
+ TailwindCompiler.prototype.MAX_PERSISTED_CACHE_ENTRIES = 20;
1213
+
1214
+ // Drop the oldest entries from this.cache until at most `limit` remain.
1215
+ // Uses entry.timestamp; entries without one are evicted first.
1216
+ TailwindCompiler.prototype.evictOldestCacheEntries = function (limit) {
1217
+ if (this.cache.size <= limit) return;
1218
+ const sorted = Array.from(this.cache.entries())
1219
+ .sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0));
1220
+ const toRemove = sorted.length - limit;
1221
+ for (let i = 0; i < toRemove; i++) {
1222
+ this.cache.delete(sorted[i][0]);
1223
+ }
1224
+ };
1225
+
1226
+ // Save cache to localStorage with size cap and quota-aware eviction.
1209
1227
  TailwindCompiler.prototype.savePersistentCache = function () {
1210
- try {
1211
- const serialized = JSON.stringify(Object.fromEntries(this.cache));
1212
- localStorage.setItem('tailwind-cache', serialized);
1213
- } catch (error) {
1214
- console.warn('Failed to save cached styles:', error);
1228
+ // Proactive cap so we don't write something we know is at risk.
1229
+ this.evictOldestCacheEntries(this.MAX_PERSISTED_CACHE_ENTRIES);
1230
+ let attempts = 0;
1231
+ while (this.cache.size > 0 && attempts < 4) {
1232
+ try {
1233
+ const serialized = JSON.stringify(Object.fromEntries(this.cache));
1234
+ localStorage.setItem('tailwind-cache', serialized);
1235
+ return;
1236
+ } catch (error) {
1237
+ // QuotaExceededError (name varies by browser): drop the oldest
1238
+ // half and retry. Anything else: bail.
1239
+ const isQuotaError =
1240
+ error && (
1241
+ error.name === 'QuotaExceededError' ||
1242
+ error.code === 22 ||
1243
+ error.code === 1014 // Firefox: NS_ERROR_DOM_QUOTA_REACHED
1244
+ );
1245
+ if (!isQuotaError) {
1246
+ console.warn('Failed to save cached styles:', error);
1247
+ return;
1248
+ }
1249
+ const halved = Math.max(1, Math.floor(this.cache.size / 2));
1250
+ this.evictOldestCacheEntries(halved);
1251
+ attempts++;
1252
+ }
1215
1253
  }
1254
+ // Last resort: cache is unusable in this origin right now, clear the slot
1255
+ // so the next session starts clean. This is a perf optimization, not
1256
+ // correctness — drop quietly.
1257
+ try { localStorage.removeItem('tailwind-cache'); } catch (_) {}
1216
1258
  };
1217
1259
 
1218
1260
  // Load cache from localStorage
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "mnfst",
3
- "version": "0.5.68",
3
+ "version": "0.5.70",
4
4
  "private": false,
5
5
  "workspaces": [
6
6
  "templates/starter",
7
- "packages/render"
7
+ "packages/render",
8
+ "packages/types",
9
+ "packages/test"
8
10
  ],
9
11
  "main": "lib/manifest.js",
10
12
  "style": "lib/manifest.css",
@@ -28,8 +30,10 @@
28
30
  "release": "npm version patch --no-git-tag-version && npm publish",
29
31
  "release:run": "cd packages/run && npm version patch --no-git-tag-version && npm publish --auth-type=web",
30
32
  "release:render": "cd packages/render && npm version patch --no-git-tag-version && npm publish --auth-type=web",
33
+ "release:types": "cd packages/types && npm version patch --no-git-tag-version && npm publish --auth-type=web",
34
+ "release:test": "cd packages/test && npm version patch --no-git-tag-version && npm publish --auth-type=web",
31
35
  "release:starter": "cd packages/create-starter && npm version patch --no-git-tag-version && npm publish --auth-type=web",
32
- "release:all": "npm run release:run && npm run release:render && npm run release:starter && npm run release",
36
+ "release:all": "npm run release:run && npm run release:render && npm run release:types && npm run release:test && npm run release:starter && npm run release",
33
37
  "prepublishOnly": "npm run build",
34
38
  "test": "vitest run",
35
39
  "lint": "echo 'No linting configured'"
@@ -60,7 +64,7 @@
60
64
  "author": "Andrew Matlock",
61
65
  "license": "MIT",
62
66
  "description": "A modern, lightweight frontend framework with built-in components and utilities",
63
- "homepage": "https://manifestjs.org",
67
+ "homepage": "https://manifestx.dev",
64
68
  "repository": {
65
69
  "type": "git",
66
70
  "url": "https://github.com/Manifest-X/Manifest.git"
@@ -1,109 +0,0 @@
1
- /* Manifest Themes */
2
-
3
- // Initialize plugin when either DOM is ready or Alpine is ready
4
- function initializeThemePlugin() {
5
-
6
- // Initialize theme state with Alpine reactivity
7
- const theme = Alpine.reactive({
8
- current: localStorage.getItem('theme') || 'system'
9
- })
10
-
11
- // Apply initial theme
12
- applyTheme(theme.current)
13
-
14
- // Setup system theme listener
15
- const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
16
- mediaQuery.addEventListener('change', () => {
17
- if (theme.current === 'system') {
18
- applyTheme('system')
19
- }
20
- })
21
-
22
- // Register theme directive
23
- Alpine.directive('theme', (el, { expression }, { evaluate, cleanup }) => {
24
-
25
- const handleClick = () => {
26
- const newTheme = expression === 'toggle'
27
- ? (document.documentElement.classList.contains('dark') ? 'light' : 'dark')
28
- : evaluate(expression)
29
- setTheme(newTheme)
30
- }
31
-
32
- el.addEventListener('click', handleClick)
33
- cleanup(() => el.removeEventListener('click', handleClick))
34
- })
35
-
36
- // Add $theme magic method
37
- Alpine.magic('theme', () => ({
38
- get current() {
39
- return theme.current
40
- },
41
- set current(value) {
42
- setTheme(value)
43
- }
44
- }))
45
-
46
- function setTheme(newTheme) {
47
- if (newTheme === 'toggle') {
48
- newTheme = theme.current === 'light' ? 'dark' : 'light'
49
- }
50
-
51
- // Update theme state
52
- theme.current = newTheme
53
- localStorage.setItem('theme', newTheme)
54
-
55
- // Apply theme
56
- applyTheme(newTheme)
57
- }
58
-
59
- function applyTheme(theme) {
60
- const isDark = theme === 'system'
61
- ? window.matchMedia('(prefers-color-scheme: dark)').matches
62
- : theme === 'dark'
63
-
64
- // Update document classes
65
- document.documentElement.classList.remove('light', 'dark')
66
- document.documentElement.classList.add(isDark ? 'dark' : 'light')
67
-
68
- // Update meta theme-color
69
- const metaThemeColor = document.querySelector('meta[name="theme-color"]')
70
- if (metaThemeColor) {
71
- metaThemeColor.setAttribute('content', isDark ? '#000000' : '#FFFFFF')
72
- }
73
- }
74
- }
75
-
76
- // Track initialization to prevent duplicates
77
- let themePluginInitialized = false;
78
-
79
- function ensureThemePluginInitialized() {
80
- if (themePluginInitialized) return;
81
- if (!window.Alpine || typeof window.Alpine.directive !== 'function') return;
82
-
83
- themePluginInitialized = true;
84
- initializeThemePlugin();
85
- }
86
-
87
- // Expose on window for loader to call if needed
88
- window.ensureThemePluginInitialized = ensureThemePluginInitialized;
89
-
90
- // Handle both DOMContentLoaded and alpine:init
91
- if (document.readyState === 'loading') {
92
- document.addEventListener('DOMContentLoaded', ensureThemePluginInitialized);
93
- }
94
-
95
- document.addEventListener('alpine:init', ensureThemePluginInitialized);
96
-
97
- // If Alpine is already initialized when this script loads, initialize immediately
98
- if (window.Alpine && typeof window.Alpine.directive === 'function') {
99
- setTimeout(ensureThemePluginInitialized, 0);
100
- } else {
101
- // If document is already loaded but Alpine isn't ready yet, wait for it
102
- const checkAlpine = setInterval(() => {
103
- if (window.Alpine && typeof window.Alpine.directive === 'function') {
104
- clearInterval(checkAlpine);
105
- ensureThemePluginInitialized();
106
- }
107
- }, 10);
108
- setTimeout(() => clearInterval(checkAlpine), 5000);
109
- }