mnfst 0.5.123 → 0.5.125

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.
@@ -714,8 +714,10 @@ async function setupCodeGroup(group) {
714
714
  // That way the tablist can have its own overflow-x scrolling (when
715
715
  // there are too many tabs to fit) without dragging sibling header
716
716
  // content into the scroll region. CSS targets the inner element via
717
- // [role="tablist"] no extra class needed.
717
+ // [role="tablist"]. The unstyle class opts out of the generic tab
718
+ // bar styling in manifest.form.css; manifest.code.css styles it.
718
719
  tablist = document.createElement('div');
720
+ tablist.className = 'unstyle';
719
721
  tablist.setAttribute('role', 'tablist');
720
722
  tablist.setAttribute('aria-label', 'Code examples');
721
723
  header.appendChild(tablist);
package/lib/manifest.css CHANGED
@@ -2360,8 +2360,9 @@
2360
2360
 
2361
2361
  @layer components {
2362
2362
 
2363
- /* Group wrapper */
2364
- :where([role=group]:has(button, input, select)):not(.unstyle) {
2363
+ /* Group & tab bar wrappers */
2364
+ :where([role=group]:has(button, [role=button], input, select),
2365
+ [role=tablist]:not(:has(>:not(button, a, [role=button], [role=tab], template)))):not(.unstyle) {
2365
2366
  display: flex;
2366
2367
  flex-flow: row nowrap;
2367
2368
  align-items: center;
@@ -2378,20 +2379,6 @@
2378
2379
  }
2379
2380
  }
2380
2381
 
2381
- &>*:first-child {
2382
- border-start-end-radius: 0;
2383
- border-end-end-radius: 0
2384
- }
2385
-
2386
- &>*:not(:first-child):not(:last-child) {
2387
- border-radius: 0
2388
- }
2389
-
2390
- &>*:last-child {
2391
- border-start-start-radius: 0;
2392
- border-end-start-radius: 0
2393
- }
2394
-
2395
2382
  &.even>* {
2396
2383
  flex-shrink: initial;
2397
2384
  width: 100%
@@ -2400,6 +2387,80 @@
2400
2387
  & input {
2401
2388
  width: fit-content
2402
2389
  }
2390
+
2391
+ /* Groups connect children with shared borders */
2392
+ &:where([role=group]) {
2393
+
2394
+ &>*:first-child {
2395
+ border-start-end-radius: 0;
2396
+ border-end-end-radius: 0
2397
+ }
2398
+
2399
+ &>*:not(:first-child):not(:last-child) {
2400
+ border-radius: 0
2401
+ }
2402
+
2403
+ &>*:last-child {
2404
+ border-start-start-radius: 0;
2405
+ border-end-start-radius: 0
2406
+ }
2407
+ }
2408
+
2409
+ /* Tab bar track */
2410
+ &[role=tablist] {
2411
+ position: relative;
2412
+ isolation: isolate;
2413
+ gap: calc(var(--spacing, 0.25rem) * 0.5);
2414
+ height: var(--spacing-field-height, calc(var(--spacing) * 9));
2415
+ padding: calc(var(--spacing, 0.25rem) / 2);
2416
+ background-color: var(--color-field-surface, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent));
2417
+ border-radius: var(--radius, 0.5rem);
2418
+ anchor-scope: --selected-tab;
2419
+
2420
+ /* Concentric corners */
2421
+ &>* {
2422
+ height: 100%;
2423
+ background-color: transparent;
2424
+ border-radius: max(calc(var(--radius, 0.5rem) - var(--spacing, 0.25rem) / 2), 0px);
2425
+
2426
+ &:hover:not(.selected, [aria-selected=true], [aria-current]) {
2427
+ background-color: color-mix(in oklch, var(--color-field-surface-hover, oklch(37.1% 0 0)) 40%, transparent)
2428
+ }
2429
+ }
2430
+
2431
+ /* Selected tab */
2432
+ &>:is(.selected, [aria-selected=true], [aria-current]) {
2433
+ background-color: color-mix(in oklch, var(--color-field-surface, color-mix(oklch(20.5% 0 0) 10%, transparent)) 75%, var(--color-field-inverse, oklch(43.9% 0 0)));
2434
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
2435
+ }
2436
+
2437
+ /* Background slider for selected tab) */
2438
+ @supports (anchor-scope: --tab) {
2439
+
2440
+ &>:is(.selected, [aria-selected=true], [aria-current]) {
2441
+ /* --co-anchor lets inline anchor-name writers (e.g. tooltips) compose with the slider anchor */
2442
+ anchor-name: --selected-tab;
2443
+ --co-anchor: --selected-tab;
2444
+ background-color: transparent;
2445
+ box-shadow: none;
2446
+ }
2447
+
2448
+ &:has(>:is(.selected, [aria-selected=true], [aria-current]))::before {
2449
+ content: "";
2450
+ position: absolute;
2451
+ z-index: -1;
2452
+ position-anchor: --selected-tab;
2453
+ top: anchor(top);
2454
+ left: anchor(left);
2455
+ width: anchor-size(width);
2456
+ height: anchor-size(height);
2457
+ background-color: color-mix(in oklch, var(--color-field-surface, color-mix(oklch(20.5% 0 0) 10%, transparent)) 75%, var(--color-field-inverse, oklch(43.9% 0 0)));
2458
+ border-radius: max(calc(var(--radius, 0.5rem) - var(--spacing, 0.25rem) / 2), 0px);
2459
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
2460
+ transition: all 0.15s ease-in-out;
2461
+ }
2462
+ }
2463
+ }
2403
2464
  }
2404
2465
 
2405
2466
  :where(form):not(.unstyle) {
@@ -10177,49 +10177,27 @@ function registerFilesDirective() {
10177
10177
  let cleanupCallbacks = [];
10178
10178
  let watchCreated = false; // Track if watch has been created to prevent duplicates
10179
10179
 
10180
- // CRITICAL: Always create a NEW isolated x-data scope for this directive element
10181
- // This ensures complete isolation - each directive instance has its own scope
10182
- // We MUST do this even if a parent scope exists, to prevent property conflicts
10183
10180
  const directiveInstanceId = `directive-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
10184
10181
 
10185
- // Create a unique x-data object for this directive instance
10186
- // This ensures Alpine creates a completely new scope for this element
10182
+ // Isolated reactive scope for this directive instance. Alpine never
10183
+ // re-reads x-data attributes on initialized elements, so editing the
10184
+ // attribute would resolve $data(el) to the ANCESTOR scope and pollute
10185
+ // it — addScopeToNode attaches a fresh scope layer to this node.
10187
10186
  const isolatedData = Alpine.reactive({
10188
10187
  files: [],
10189
10188
  loadingFiles: false,
10190
10189
  filesError: null
10191
10190
  });
10191
+ const removeIsolatedScope = Alpine.addScopeToNode(el, isolatedData);
10192
+ cleanupCallbacks.push(removeIsolatedScope);
10192
10193
 
10193
- // Set x-data attribute with a unique object reference
10194
- // Alpine will create a new scope from this, completely isolated from parent scopes
10195
- el.setAttribute('x-data', `{}`);
10196
-
10197
- // Get the scope AFTER setting x-data (Alpine creates it when x-data is set)
10198
- let scope;
10199
- try {
10200
- scope = Alpine.$data(el);
10201
- } catch (e) {
10202
- // If Alpine hasn't initialized yet, create scope manually
10203
- scope = {};
10204
- Alpine.initTree(el);
10205
- scope = Alpine.$data(el);
10206
- }
10207
-
10208
- // CRITICAL: Directly assign properties to the scope object
10209
- // Since this is a NEW isolated scope, we can safely assign directly without conflicts
10210
- scope.files = isolatedData.files;
10211
- scope.loadingFiles = isolatedData.loadingFiles;
10212
- scope.filesError = isolatedData.filesError;
10194
+ // Merged scope view: writes to files/loadingFiles/filesError land on
10195
+ // isolatedData (top of stack), magics like $watch resolve from ancestors
10196
+ const scope = Alpine.$data(el);
10213
10197
 
10214
10198
  // Store reference in WeakMap for access in closures
10215
10199
  dataFilesNamespaces.set(el, isolatedData);
10216
10200
 
10217
- // DIAGNOSTIC: Log scope identity and element info
10218
- const scopeId = `scope-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
10219
- scope._debugScopeId = scopeId;
10220
- scope._debugDirectiveId = directiveInstanceId;
10221
- scope._debugElement = el;
10222
-
10223
10201
  // Initialize local references
10224
10202
  files = isolatedData.files;
10225
10203
  loadingFiles = isolatedData.loadingFiles;
@@ -10584,7 +10562,6 @@ function registerFilesDirective() {
10584
10562
  watchCreated = true; // Mark watch as created to prevent duplicates
10585
10563
  const projectId = currentProjectId; // Capture project ID for closure
10586
10564
  let isProcessing = false; // Guard against multiple simultaneous updates
10587
- let isInitializing = true; // Skip first evaluation (initialization)
10588
10565
 
10589
10566
  // Initialize lastFileIds with current value from the store to prevent false positives
10590
10567
  const store = Alpine.store('data');
@@ -10616,12 +10593,6 @@ function registerFilesDirective() {
10616
10593
  return JSON.stringify(currentProject.fileIds || []);
10617
10594
  },
10618
10595
  (currentFileIdsJson) => {
10619
- // Skip first evaluation (initialization) - we already loaded files above
10620
- if (isInitializing) {
10621
- isInitializing = false;
10622
- return;
10623
- }
10624
-
10625
10596
  // Guard against processing multiple updates simultaneously
10626
10597
  if (isProcessing) {
10627
10598
  return;
@@ -10,11 +10,13 @@ document.addEventListener('pointerdown', () => { lastInputModality = 'mouse'; },
10
10
 
11
11
  // Initialize plugin when either DOM is ready or Alpine is ready
12
12
  function initializeDropdownPlugin() {
13
- // Ensure Alpine.js context exists for directives to work
13
+ // Ensure Alpine.js context exists for directives to work. Keep the scope
14
+ // empty — seeding properties here collides with author state and the
15
+ // tabs plugin's page-level `tab` property.
14
16
  function ensureAlpineContext() {
15
17
  const body = document.body;
16
18
  if (!body.hasAttribute('x-data')) {
17
- body.setAttribute('x-data', '{ tab: \'local-data\' }');
19
+ body.setAttribute('x-data', '{}');
18
20
  }
19
21
  }
20
22
 
@@ -2,8 +2,9 @@
2
2
 
3
3
  @layer components {
4
4
 
5
- /* Group wrapper */
6
- :where([role=group]:has(button, input, select)):not(.unstyle) {
5
+ /* Group & tab bar wrappers */
6
+ :where([role=group]:has(button, [role=button], input, select),
7
+ [role=tablist]:not(:has(>:not(button, a, [role=button], [role=tab], template)))):not(.unstyle) {
7
8
  display: flex;
8
9
  flex-flow: row nowrap;
9
10
  align-items: center;
@@ -20,20 +21,6 @@
20
21
  }
21
22
  }
22
23
 
23
- &>*:first-child {
24
- border-start-end-radius: 0;
25
- border-end-end-radius: 0
26
- }
27
-
28
- &>*:not(:first-child):not(:last-child) {
29
- border-radius: 0
30
- }
31
-
32
- &>*:last-child {
33
- border-start-start-radius: 0;
34
- border-end-start-radius: 0
35
- }
36
-
37
24
  &.even>* {
38
25
  flex-shrink: initial;
39
26
  width: 100%
@@ -42,6 +29,80 @@
42
29
  & input {
43
30
  width: fit-content
44
31
  }
32
+
33
+ /* Groups connect children with shared borders */
34
+ &:where([role=group]) {
35
+
36
+ &>*:first-child {
37
+ border-start-end-radius: 0;
38
+ border-end-end-radius: 0
39
+ }
40
+
41
+ &>*:not(:first-child):not(:last-child) {
42
+ border-radius: 0
43
+ }
44
+
45
+ &>*:last-child {
46
+ border-start-start-radius: 0;
47
+ border-end-start-radius: 0
48
+ }
49
+ }
50
+
51
+ /* Tab bar track */
52
+ &[role=tablist] {
53
+ position: relative;
54
+ isolation: isolate;
55
+ gap: calc(var(--spacing, 0.25rem) * 0.5);
56
+ height: var(--spacing-field-height, calc(var(--spacing) * 9));
57
+ padding: calc(var(--spacing, 0.25rem) / 2);
58
+ background-color: var(--color-field-surface, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent));
59
+ border-radius: var(--radius, 0.5rem);
60
+ anchor-scope: --selected-tab;
61
+
62
+ /* Concentric corners */
63
+ &>* {
64
+ height: 100%;
65
+ background-color: transparent;
66
+ border-radius: max(calc(var(--radius, 0.5rem) - var(--spacing, 0.25rem) / 2), 0px);
67
+
68
+ &:hover:not(.selected, [aria-selected=true], [aria-current]) {
69
+ background-color: color-mix(in oklch, var(--color-field-surface-hover, oklch(37.1% 0 0)) 40%, transparent)
70
+ }
71
+ }
72
+
73
+ /* Selected tab */
74
+ &>:is(.selected, [aria-selected=true], [aria-current]) {
75
+ background-color: color-mix(in oklch, var(--color-field-surface, color-mix(oklch(20.5% 0 0) 10%, transparent)) 75%, var(--color-field-inverse, oklch(43.9% 0 0)));
76
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
77
+ }
78
+
79
+ /* Background slider for selected tab) */
80
+ @supports (anchor-scope: --tab) {
81
+
82
+ &>:is(.selected, [aria-selected=true], [aria-current]) {
83
+ /* --co-anchor lets inline anchor-name writers (e.g. tooltips) compose with the slider anchor */
84
+ anchor-name: --selected-tab;
85
+ --co-anchor: --selected-tab;
86
+ background-color: transparent;
87
+ box-shadow: none;
88
+ }
89
+
90
+ &:has(>:is(.selected, [aria-selected=true], [aria-current]))::before {
91
+ content: "";
92
+ position: absolute;
93
+ z-index: -1;
94
+ position-anchor: --selected-tab;
95
+ top: anchor(top);
96
+ left: anchor(left);
97
+ width: anchor-size(width);
98
+ height: anchor-size(height);
99
+ background-color: color-mix(in oklch, var(--color-field-surface, color-mix(oklch(20.5% 0 0) 10%, transparent)) 75%, var(--color-field-inverse, oklch(43.9% 0 0)));
100
+ border-radius: max(calc(var(--radius, 0.5rem) - var(--spacing, 0.25rem) / 2), 0px);
101
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
102
+ transition: all 0.15s ease-in-out;
103
+ }
104
+ }
105
+ }
45
106
  }
46
107
 
47
108
  :where(form):not(.unstyle) {
@@ -3,13 +3,13 @@
3
3
  "manifest.appwrite.data.js": "sha384-00ulLT+GAIuPHA/rRT9p98vYlsyDzkyKXtg86BDQ6FGQa5vVVN+W6kuforniBAsz",
4
4
  "manifest.appwrite.presence.js": "sha384-uxRpx9/Jj0kGtklH5QmUlAzD3zdSvFRfK6bcJQqxl+Bsf5tOo4zgwqJTQgtZoHQP",
5
5
  "manifest.charts.js": "sha384-k4nJoXhwjdAg2LzM8uLsE8SMGDczYnUee1kR7OCzBOcX2N5UQ1v78Pndv9JrvCqq",
6
- "manifest.code.js": "sha384-0uQckfvrEyDVV1Er8SpciZM5egnBzgJG4QFl7ZNdFOWiNLkENeLcRjJKzPLlQO6G",
6
+ "manifest.code.js": "sha384-nP6DncLx/UuJtloyVKMCOXwIBAq32DshTb/Lc0vVRBWX7kSbxiBnY5aEyqqvK8Kg",
7
7
  "manifest.color.js": "sha384-Z9G/lzt0vVMxjz4wkPuGG1X9mmQAJR15aOoGX3ephf7r2wnlUWet5GLgkUMtT4vt",
8
8
  "manifest.colorpicker.js": "sha384-Wqz0ZIbeIi7KarqqqSLsQk+7E/fMaKhb32hrq5/eWzX1yjqMrpPZKH8y+jZ3mfg+",
9
9
  "manifest.components.js": "sha384-73CB1A+LAGfNexkd7aT69APFSHMzix8irse9uzOkYehHHio4px3oR8JHJeaMH+jI",
10
- "manifest.data.js": "sha384-AAdxm3uR/K3GKztxqbLNK+eMSXvAD6nQWdmSMJeSGQ05mcp4tnjLRI4zPMkmBTxa",
10
+ "manifest.data.js": "sha384-PZiaRsDV4nnRGvmT8y2jEZV+BolcEZGJXbMJU75YqWGXI8b3y/L93zhhllovf4Go",
11
11
  "manifest.datepicker.js": "sha384-ZKSSEm04AoMBpra/R0guKGUrLMMqmo3nqPvVb2b3akfDQeirPfYTHOMzAw+6KIGE",
12
- "manifest.dropdowns.js": "sha384-oT+pTLJg3PzPCVAj7AC44hbTmmsq4+uHd9VrkG27RVrDH4ms0Vnf1MKak9sG81dX",
12
+ "manifest.dropdowns.js": "sha384-PFn6K1PCD3ict0j7yN25EH4EhMfpRsfR51B0GJB75jM9J7NlXquJkbY6s7SxXlib",
13
13
  "manifest.export.js": "sha384-RsTGzsPCBw3yO4+TdAGd4F+o3FnzUNlqnMBqtnn/kUfv7axpzRdPc2AnsExV/93c",
14
14
  "manifest.icons.js": "sha384-uOkboYrovjCpl22eey3Jaxpey+pOnot5NDnRRumcRxiR7IOVaRh1i20gYnWXR5dW",
15
15
  "manifest.localization.js": "sha384-M3HRb2Ma8PemfFeqq9rgWgw/+Vdb/8d5LGW2MFbVsXaWUPqr/mPuxWtl5Pv8wolL",
@@ -20,11 +20,11 @@
20
20
  "manifest.slides.js": "sha384-3uRTkyK9XPLmnxI2+igZlpi4EyPlU/7IHj5j3BZJJ2KN455vXyk99fiXV3feO/XY",
21
21
  "manifest.status.js": "sha384-7cEl+Nh729ncqy5GtRYMqo5R4d257QPsoFm/hx9Znp9uV/D85pjxVzQ1fhiD+sO6",
22
22
  "manifest.svg.js": "sha384-wPfasscODIO6pyMFNIqZ7/C12cR4QYDnVl/wYNhwBO7gFNBGrhimNzL18VTpMPIL",
23
- "manifest.tabs.js": "sha384-v6Ti0zHfdLhkFHbTMg0FH6uMrThuBvZrL2PQgVBeeXhDjuN7x4MtoNWogPbAQTaD",
23
+ "manifest.tabs.js": "sha384-7Kb1EHIbqh1NOl8J1NMp087lcF1gVmwm55QNM3s7JamV6sYiH/WZbdnknAZFtsfW",
24
24
  "manifest.tailwind.js": "sha384-aHLvl2oSuUgy06VaBqhhByn5wWxqvnqxw6KCwehakKUS00F/s/Nb62umeASS6Y4P",
25
25
  "manifest.toasts.js": "sha384-ytd5rDbax/Ou9z23uedFXPZbxDPsk2E/pxCTq4WLvfv+os1qTI6kELp0kPp07g24",
26
- "manifest.tooltips.js": "sha384-Hhip5ZN66xhDw3m0XBrKLKLpcVRz3Z9RszPKqo6xvFF0mrUgQBVZ+mZjZsXgOOjS",
26
+ "manifest.tooltips.js": "sha384-59szmOO26KgSq3ea//8LKy+pUzO1SkRUREjCmAUN4KVI5UTX/lKkJnJaIU888M/B",
27
27
  "manifest.url.parameters.js": "sha384-FIufiClqDx1rJpU/QUc9z/D43qClQ6Qm8rBahipbJl9BDHUvhrOsUDegmTWW7Tuf",
28
28
  "manifest.utilities.js": "sha384-+cS3BJdncunt8zYHWzGQMcNzr70GLRWkjUNavoTYj0566qStZkqVRB6BfTOlkBEJ",
29
- "manifest.js": "sha384-Bj2o6IIME2mQW71aOXIUiWS/yQera+0GzS4RlE8qIZ8SAPxSoVcF6T2IsgZ3Q4Hm"
29
+ "manifest.js": "sha384-cAO4OK7ec8QQUhy3xd8MlFR0XPde+3LZHMrgTKs30tgxt3vXCJDL4ysZ7JUAroV4"
30
30
  }
package/lib/manifest.js CHANGED
@@ -38,15 +38,6 @@
38
38
  const prerenderMeta = document.querySelector('meta[name="manifest:prerendered"]');
39
39
  if (!prerenderMeta || prerenderMeta.getAttribute('content') === '0') return;
40
40
 
41
- // Remove baked x-for/x-if clones the prerender kept for crawlers. Their
42
- // <template> is still live, so Alpine re-renders the list/conditional on
43
- // boot; dropping the baked copies first (before Alpine runs) avoids a
44
- // duplicate render. data-hydrate islands keep their baked DOM.
45
- document.querySelectorAll('[data-mnfst-prerender-clone]').forEach((el) => {
46
- if (el.closest && el.closest('[data-hydrate]')) return;
47
- el.remove();
48
- });
49
-
50
41
  const blob = document.getElementById('__manifest_hydrate__');
51
42
  if (!blob) return;
52
43
  let entries;
@@ -149,6 +140,31 @@
149
140
  blob.remove();
150
141
  }
151
142
 
143
+ /*
144
+ * Remove baked x-for/x-if clones the prerender kept for crawlers. Their
145
+ * <template> is still live, so Alpine re-renders the list/conditional on
146
+ * boot; dropping the baked copies first avoids a duplicate render.
147
+ * data-hydrate islands keep their baked DOM.
148
+ *
149
+ * Deliberately NOT part of hydratePrerenderedPage(): this wipe is
150
+ * destructive, so it runs at the last safe moment — `alpine:init`, which
151
+ * Alpine dispatches after its script has arrived and executed but BEFORE
152
+ * it walks the DOM and re-renders x-for/x-if from their live templates.
153
+ * If Alpine never arrives (CDN failure, offline), the listener never
154
+ * fires and the page keeps its complete baked content instead of losing
155
+ * the clones with nothing to re-render them.
156
+ */
157
+ function removePrerenderClones() {
158
+ if (typeof document === 'undefined' || !document.querySelectorAll) return;
159
+ document.querySelectorAll('[data-mnfst-prerender-clone]').forEach((el) => {
160
+ if (el.closest && el.closest('[data-hydrate]')) return;
161
+ el.remove();
162
+ });
163
+ }
164
+ if (typeof document !== 'undefined') {
165
+ document.addEventListener('alpine:init', removePrerenderClones, { once: true });
166
+ }
167
+
152
168
  // Run hydration BEFORE Alpine's deferred script executes.
153
169
  //
154
170
  // Timing: `<script defer>` runs AFTER HTML parsing finishes but BEFORE
@@ -284,32 +300,67 @@
284
300
  return `https://cdn.jsdelivr.net/npm/alpinejs@${dataAlpine}/dist/cdn.min.js`;
285
301
  }
286
302
 
287
- // Load Alpine.js from CDN. Called by the loader AFTER all plugin scripts
288
- // have finished loading and registered their directives/magics. We do
289
- // NOT use `defer` here defer fires at DOMContentLoaded, which may race
290
- // the plugin loads; instead we wait for every plugin script's load event
291
- // explicitly and then append Alpine synchronously (the script downloads
292
- // but Alpine's `auto-start` hooks DOMContentLoaded if still loading, or
293
- // runs immediately if past it).
294
- function loadAlpine(alpineUrl = ALPINE_CDN_URL) {
295
- // Fast check: Alpine already initialized
296
- if (window.Alpine) {
297
- return;
298
- }
303
+ // Has DOMContentLoaded already fired? readyState alone can't tell:
304
+ // 'interactive' covers both "deferred scripts still running" (DCL pending)
305
+ // and "DCL done, subresources still loading". Disambiguate via the
306
+ // navigation timing entry, which records the event the moment it runs.
307
+ function domContentLoadedFired() {
308
+ if (document.readyState === 'complete') return true;
309
+ if (document.readyState === 'loading') return false;
310
+ try {
311
+ const nav = performance.getEntriesByType('navigation')[0];
312
+ if (nav) return nav.domContentLoadedEventEnd > 0;
313
+ } catch (_) { /* fall through */ }
314
+ return false;
315
+ }
299
316
 
300
- // Fallback: if an existing Alpine <script> tag is already in the DOM
301
- // (e.g. the fixture explicitly added one), wait for it don't inject
302
- // a second copy.
303
- const existingAlpine = document.querySelector('script[src*="alpinejs"]');
304
- if (existingAlpine) {
317
+ // Run fn once the document's deferred scripts have all executed (i.e. at
318
+ // or after DOMContentLoaded). The window 'load' listener is a belt-and-
319
+ // braces fallback for environments where the navigation entry is missing.
320
+ function whenDomReady(fn) {
321
+ if (domContentLoadedFired()) {
322
+ fn();
305
323
  return;
306
324
  }
325
+ let done = false;
326
+ const run = () => { if (!done) { done = true; fn(); } };
327
+ document.addEventListener('DOMContentLoaded', run, { once: true });
328
+ window.addEventListener('load', run, { once: true });
329
+ }
307
330
 
308
- const script = document.createElement('script');
309
- script.src = alpineUrl;
310
- // No `defer` — we're already past plugin registration, so Alpine
311
- // should load and execute as soon as it arrives.
312
- document.head.appendChild(script);
331
+ // Load Alpine.js from CDN. Called by the loader AFTER all plugin scripts
332
+ // have finished loading and registered their directives/magics.
333
+ //
334
+ // Gated on DOMContentLoaded: the page's own deferred scripts register
335
+ // x-data components and magics via `alpine:init`, and the defer queue
336
+ // spins the event loop while a script is still in flight — so on a warm
337
+ // cache an injected Alpine script can load and EXECUTE between two
338
+ // deferred scripts, firing `alpine:init` before the page's registrations
339
+ // exist. Waiting for DCL (which fires only after every deferred script
340
+ // has run) makes the ordering deterministic. In the common cold-cache
341
+ // case DCL has long passed by the time the plugin loads settle, so the
342
+ // gate adds no delay.
343
+ function loadAlpine(alpineUrl = ALPINE_CDN_URL) {
344
+ whenDomReady(() => {
345
+ // Fast check: Alpine already initialized
346
+ if (window.Alpine) {
347
+ return;
348
+ }
349
+
350
+ // Fallback: if an existing Alpine <script> tag is already in the DOM
351
+ // (e.g. the fixture explicitly added one), wait for it — don't inject
352
+ // a second copy.
353
+ const existingAlpine = document.querySelector('script[src*="alpinejs"]');
354
+ if (existingAlpine) {
355
+ return;
356
+ }
357
+
358
+ const script = document.createElement('script');
359
+ script.src = alpineUrl;
360
+ // No `defer` — we're past plugin registration and past DCL, so
361
+ // Alpine should load and execute as soon as it arrives.
362
+ document.head.appendChild(script);
363
+ });
313
364
  }
314
365
 
315
366
  // Add a script tag to the head and wait for it to load and execute