mnfst 0.5.218 → 0.5.219

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  (function () {
4
4
 
5
- const MANIFEST_BUILD_VERSION = '0.5.218';
5
+ const MANIFEST_BUILD_VERSION = '0.5.219';
6
6
 
7
7
  /* Manifest Data Sources - Configuration */
8
8
 
@@ -1326,6 +1326,13 @@ function setupLocaleChangeListener() {
1326
1326
  store._localeChanging = false;
1327
1327
  bumpAllVersions();
1328
1328
  }
1329
+ } finally {
1330
+ // Early exits (no manifest.data) must not strand the render-ready gate
1331
+ const store = Alpine.store('data');
1332
+ if (store?._localeChanging) {
1333
+ store._localeChanging = false;
1334
+ checkAndDispatchRenderReady();
1335
+ }
1329
1336
  }
1330
1337
  });
1331
1338
  }
@@ -11340,7 +11347,7 @@ function registerFilesDirective() {
11340
11347
  // sending a broken/empty-equal query. Retries once when auth settles further;
11341
11348
  // dedup by key so a source pending across repeated loads doesn't stack listeners.
11342
11349
  const pendingAuthRetries = new Set();
11343
- const AUTH_RETRY_EVENTS = ['manifest:auth:initialized', 'manifest:auth:teams-loaded', 'manifest:auth:login'];
11350
+ const AUTH_RETRY_EVENTS = ['manifest:auth:initialized', 'manifest:auth:teams-loaded', 'manifest:auth:login', 'manifest:auth:anonymous'];
11344
11351
  function scheduleAuthRetry(dataSourceName, locale) {
11345
11352
  if (typeof window === 'undefined') return;
11346
11353
  const key = `${dataSourceName}:${locale}`;
@@ -11354,6 +11361,13 @@ function scheduleAuthRetry(dataSourceName, locale) {
11354
11361
  AUTH_RETRY_EVENTS.forEach(type => window.addEventListener(type, retry));
11355
11362
  }
11356
11363
 
11364
+ // Auth settled with no identity (or no auth plugin): a scoped read can't resolve until sign-in
11365
+ function authSettledSignedOut() {
11366
+ const auth = typeof Alpine !== 'undefined' ? Alpine.store('auth') : null;
11367
+ if (!auth) return true;
11368
+ return auth._initialized === true && auth.isAuthenticated !== true;
11369
+ }
11370
+
11357
11371
  // Client-side scope filter: Appwrite returns all accessible files, so narrow to
11358
11372
  // the current team to match database team-scope behavior.
11359
11373
  async function filterFilesByScope(files, scope) {
@@ -11857,6 +11871,13 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
11857
11871
  const generation = sourceGeneration ? sourceGeneration(dataSourceName) : 0;
11858
11872
  const superseded = () => sourceGeneration && sourceGeneration(dataSourceName) !== generation;
11859
11873
 
11874
+ // Signed out: settle empty (not $loading forever); sign-in reloads via the auth retry
11875
+ const settleSignedOut = () => {
11876
+ if (isInitializing || superseded() || !authSettledSignedOut()) return false;
11877
+ updateStore(dataSourceName, [], { loading: false, error: null, ready: true });
11878
+ return true;
11879
+ };
11880
+
11860
11881
  return runDeduped(cacheKey, async () => {
11861
11882
  let landed = false;
11862
11883
  try {
@@ -11933,9 +11954,10 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
11933
11954
  ? await window.ManifestDataQueries.buildAppwriteQueries(queriesConfig.default || queriesConfig, scope, scopeColumns)
11934
11955
  : await window.ManifestDataQueries.buildAppwriteQueries([], scope, scopeColumns);
11935
11956
 
11936
- // Not ready (auth/scope unresolved): skip this read, stay pending, retry on an auth event
11957
+ // Not ready (auth/scope unresolved): skip this read, retry on an auth event
11937
11958
  if (queries === null) {
11938
11959
  scheduleAuthRetry(dataSourceName, locale);
11960
+ if (settleSignedOut()) landed = true;
11939
11961
  return null;
11940
11962
  }
11941
11963
 
@@ -11974,6 +11996,7 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
11974
11996
  // Not ready (a $auth. arg in queriesConfig unresolved): skip, retry on an auth event
11975
11997
  if (queries === null) {
11976
11998
  scheduleAuthRetry(dataSourceName, locale);
11999
+ if (settleSignedOut()) landed = true;
11977
12000
  return null;
11978
12001
  }
11979
12002
 
@@ -135,7 +135,7 @@ function register(el, rule, opts) {
135
135
  };
136
136
  wire(rec);
137
137
  if (rule === 'route') routes.add(rec);
138
- else if (!rec.lazy) { pending.add(rec); schedule(); }
138
+ else if (!rec.lazy) { addPending(rec); if (!el.closest('[x-route][hidden]')) track(rec); schedule(); }
139
139
  return rec;
140
140
  }
141
141
 
@@ -143,7 +143,7 @@ function render(rec, viaPrewarm) {
143
143
  if (rec.rendered) { if (!viaPrewarm && warm.delete(rec)) schedule(); return; }
144
144
  rec.rendered = true;
145
145
  pending.delete(rec);
146
- if (viaPrewarm) warm.add(rec); else warm.delete(rec);
146
+ if (viaPrewarm) warm.add(rec); else { warm.delete(rec); untrack(rec); }
147
147
  const { el, tpl } = rec;
148
148
  const content = rec.discard ? tpl.content.cloneNode(true) : tpl.content;
149
149
  Alpine.mutateDom(() => {
@@ -165,11 +165,12 @@ function teardown(rec) {
165
165
  });
166
166
  }
167
167
 
168
- // Evict a warm, still-closed, never-opened container back to its stash (least reachable first)
168
+ // Evict a warm, still-closed, never-opened container back to its stash (non-urgent, least reachable first)
169
169
  function restash(rec) {
170
170
  if (!rec.rendered) return;
171
171
  rec.rendered = false;
172
172
  rec.near = undefined;
173
+ rec.urgent = false; // evicted: back to plain prewarm order, or it re-renders and evicts forever
173
174
  const { el, tpl } = rec;
174
175
  Alpine.mutateDom(() => {
175
176
  Array.from(el.children).forEach((child) => Alpine.destroyTree(child));
@@ -177,7 +178,7 @@ function restash(rec) {
177
178
  el.appendChild(tpl);
178
179
  });
179
180
  warm.delete(rec);
180
- pending.add(rec);
181
+ addPending(rec);
181
182
  }
182
183
 
183
184
  function evict() {
@@ -186,10 +187,9 @@ function evict() {
186
187
  while (warm.size > PREWARM_CAP) {
187
188
  let victim = null, score = -1;
188
189
  for (const rec of warm) {
189
- if (!rec.el.isConnected) { warm.delete(rec); continue; }
190
+ if (!rec.el.isConnected) { warm.delete(rec); untrack(rec); continue; }
190
191
  if (rec.rule === 'popover' && isPopoverOpen(rec.el)) continue;
191
- if (rec.near === undefined) rec.near = nearViewport(rec);
192
- const sc = (reachable(rec.el) ? 0 : 2) + (rec.near ? 0 : 1);
192
+ const sc = (rec.urgent ? 0 : 4) + (reachable(rec) ? 0 : 2) + (isNear(rec) ? 0 : 1);
193
193
  if (sc > score) { victim = rec; score = sc; }
194
194
  }
195
195
  if (!victim) return;
@@ -261,15 +261,15 @@ function cancelIdle() {
261
261
  }
262
262
 
263
263
  // A gesture that revealed a pane (tab switch, panel open) makes its containers urgent
264
- function promoteNearViewport() {
264
+ function promoteNearViewport(first) {
265
265
  if (!ready || !bootDrained) return;
266
+ if (first) refreshInvokers();
266
267
  // The on-screen containers nearest the gesture point (a revealed pane sits under the click)
267
268
  const candidates = [];
268
- for (const rec of pending) {
269
- if (rec.urgent || !rec.el.isConnected || !reachable(rec.el)) continue;
270
- const r = anchorRect(rec);
271
- rec.near = !!r && r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
272
- if (!rec.near) continue;
269
+ for (const rec of reachablePending()) {
270
+ if (rec.urgent || !pending.has(rec) || !rec.el.isConnected) continue;
271
+ const r = nearRect(rec);
272
+ if (!r) continue;
273
273
  const dx = Math.max(r.left - gestureX, 0, gestureX - r.right);
274
274
  const dy = Math.max(r.top - gestureY, 0, gestureY - r.bottom);
275
275
  candidates.push([dx * dx + dy * dy, rec]);
@@ -283,49 +283,159 @@ function promoteNearViewport() {
283
283
  }
284
284
  function onGesture(e) {
285
285
  lastGestureAt = now();
286
+ bumpReach();
286
287
  if (e && typeof e.clientX === 'number' && (e.clientX || e.clientY)) { gestureX = e.clientX; gestureY = e.clientY; }
287
288
  if (now() - lastPromoteAt < 300) return;
288
289
  clearTimeout(promoteTimer);
289
290
  lastPromoteAt = now();
290
291
  // Three passes: a revealed pane lays out and mounts its menus over the next few hundred ms
291
- promoteTimer = setTimeout(() => { promoteNearViewport(); promoteTimer = setTimeout(() => { promoteNearViewport(); promoteTimer = setTimeout(promoteNearViewport, 400); }, 150); }, 50);
292
+ promoteTimer = setTimeout(() => { promoteNearViewport(true); promoteTimer = setTimeout(() => { promoteNearViewport(); promoteTimer = setTimeout(promoteNearViewport, 400); }, 150); }, 50);
293
+ }
294
+
295
+ // Only containers the user can reach: not under a hidden route (cached until the next gesture or route change)
296
+ let reachEpoch = 0;
297
+ let pool = null, poolEpoch = -1; // reachable subset of pending, rebuilt once per epoch
298
+ const bumpReach = () => { reachEpoch++; };
299
+ function reachablePending() {
300
+ if (pool && poolEpoch === reachEpoch && pool.length < pending.size * 2 + 64) return pool;
301
+ poolEpoch = reachEpoch;
302
+ pool = [];
303
+ for (const rec of pending) {
304
+ if (!rec.el.isConnected) { pending.delete(rec); untrack(rec); continue; }
305
+ if (reachable(rec)) { track(rec); pool.push(rec); } else untrack(rec);
306
+ }
307
+ return pool;
308
+ }
309
+ function addPending(rec) {
310
+ pending.add(rec);
311
+ if (pool && poolEpoch === reachEpoch && reachable(rec)) { track(rec); pool.push(rec); }
312
+ }
313
+ function reachable(rec) {
314
+ if (rec.reachAt !== reachEpoch) { rec.reachAt = reachEpoch; rec.reach = !rec.el.closest('[x-route][hidden]'); }
315
+ return rec.reach;
316
+ }
317
+
318
+ // ---- Geometry: IntersectionObserver keeps near/box per record; sync reads only until it reports ----
319
+
320
+ let io = null;
321
+ const anchors = new Map(); // anchor el -> { recs, box, near, seen }
322
+ const MAX_DEPTH = 6;
323
+
324
+ function ioReady() {
325
+ if (io === null) io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(onIntersect) : false;
326
+ return io;
327
+ }
328
+
329
+ function applyAnchor(rec, a) {
330
+ if (!a.box && rec.depth + 1 < MAX_DEPTH && rec.anchor.parentElement) { anchorTo(rec, rec.anchor.parentElement, rec.depth + 1); return; }
331
+ rec.box = a.box;
332
+ rec.ioNear = a.near;
333
+ }
334
+
335
+ function onIntersect(entries) {
336
+ for (const e of entries) {
337
+ const a = anchors.get(e.target);
338
+ if (!a) continue;
339
+ const r = e.boundingClientRect;
340
+ a.seen = true;
341
+ a.box = (r.width || r.height) ? r : null;
342
+ a.near = !!a.box && e.isIntersecting;
343
+ for (const rec of Array.from(a.recs)) applyAnchor(rec, a);
344
+ }
345
+ }
346
+
347
+ function anchorTo(rec, el, depth) {
348
+ untrack(rec);
349
+ rec.anchor = el; rec.depth = depth; rec.box = undefined; rec.ioNear = undefined;
350
+ let a = anchors.get(el);
351
+ if (!a) { anchors.set(el, a = { recs: new Set(), box: undefined, near: false, seen: false }); io.observe(el); }
352
+ a.recs.add(rec);
353
+ if (a.seen) applyAnchor(rec, a);
354
+ }
355
+
356
+ function untrack(rec) {
357
+ const el = rec.anchor;
358
+ if (!el) return;
359
+ rec.anchor = null; rec.box = undefined; rec.ioNear = undefined;
360
+ const a = anchors.get(el);
361
+ if (!a) return;
362
+ a.recs.delete(rec);
363
+ if (!a.recs.size) { anchors.delete(el); io.unobserve(el); }
292
364
  }
293
365
 
294
- // Only containers the user can reach: not under a hidden route; visible parents first
295
- const reachable = (el) => !el.closest('[x-route][hidden]');
296
366
  // Judge by the invoker (popovertarget / aria-controls), else the nearest ancestor with a box
367
+ function invokerFor(rec, map) {
368
+ const el = rec.el;
369
+ if (!el.id) return null;
370
+ if (map) return map.get(el.id) || null;
371
+ if (!window.CSS || !CSS.escape) return null;
372
+ const id = CSS.escape(el.id);
373
+ return document.querySelector(`[popovertarget="${id}"], [aria-controls="${id}"]`);
374
+ }
375
+
376
+ function track(rec) {
377
+ if (rec.anchor || !ioReady() || !rec.el.isConnected) return;
378
+ if (rec.invoker === undefined) rec.invoker = invokerFor(rec);
379
+ const start = (rec.invoker && rec.invoker.isConnected) ? rec.invoker : rec.el.parentElement;
380
+ if (start) anchorTo(rec, start, 0);
381
+ }
382
+
383
+ // Late invokers / late-bound ids: once per gesture, re-anchor tracked records that had none
384
+ function refreshInvokers() {
385
+ if (!io) return;
386
+ const orphans = [];
387
+ for (const rec of reachablePending()) if (!rec.invoker && rec.anchor && rec.el.id && pending.has(rec)) orphans.push(rec);
388
+ if (!orphans.length) return;
389
+ let map = null;
390
+ if (orphans.length > 8) {
391
+ map = new Map();
392
+ for (const inv of document.querySelectorAll('[popovertarget], [aria-controls]')) {
393
+ const id = inv.getAttribute('popovertarget') || inv.getAttribute('aria-controls');
394
+ if (id && !map.has(id)) map.set(id, inv);
395
+ }
396
+ }
397
+ for (const rec of orphans) {
398
+ const inv = invokerFor(rec, map);
399
+ if (inv) { rec.invoker = inv; anchorTo(rec, inv, 0); }
400
+ }
401
+ }
402
+
297
403
  // Rect of the invoker or the nearest boxed ancestor (null when nothing has a box)
298
404
  function anchorRect(rec) {
299
405
  const el = rec.el;
300
- if (!rec.invoker && el.id && window.CSS && CSS.escape) {
301
- const id = CSS.escape(el.id);
302
- rec.invoker = document.querySelector(`[popovertarget="${id}"], [aria-controls="${id}"]`) || null;
303
- }
406
+ if (rec.invoker === undefined || (rec.invoker === null && !io)) rec.invoker = invokerFor(rec);
304
407
  let p = (rec.invoker && rec.invoker.isConnected) ? rec.invoker : el.parentElement;
305
- for (let depth = 0; p && depth < 6; p = p.parentElement, depth++) {
408
+ for (let depth = 0; p && depth < MAX_DEPTH; p = p.parentElement, depth++) {
306
409
  const r = p.getBoundingClientRect();
307
410
  if (r.width || r.height) return r;
308
411
  }
309
412
  return null;
310
413
  }
311
- function nearViewport(rec) {
414
+ const inViewport = (r) => !!r && r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
415
+
416
+ // On-screen rect for ranking, or null when off-screen / boxless
417
+ function nearRect(rec) {
418
+ if (rec.box !== undefined) return rec.ioNear ? rec.box : null;
312
419
  const r = anchorRect(rec);
313
- return !!r && r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
420
+ rec.near = inViewport(r);
421
+ return rec.near ? r : null;
422
+ }
423
+ function isNear(rec) {
424
+ if (rec.box !== undefined) return !!rec.ioNear;
425
+ if (rec.near === undefined) rec.near = inViewport(anchorRect(rec));
426
+ return rec.near;
314
427
  }
315
428
  function rank(rec) {
316
- if (rec.near === undefined) rec.near = nearViewport(rec);
317
- return (rec.urgent ? 0 : 4) + (rec.near ? 0 : 2);
429
+ return (rec.urgent ? 0 : 4) + (isNear(rec) ? 0 : 2);
318
430
  }
319
431
  function next() {
320
432
  let best = null, bestRank = 0;
321
- for (const rec of pending) {
322
- if (!rec.el.isConnected) { pending.delete(rec); continue; }
323
- if (!reachable(rec.el)) continue;
433
+ for (const rec of reachablePending()) {
434
+ if (!pending.has(rec)) continue;
435
+ if (!rec.el.isConnected) { pending.delete(rec); untrack(rec); continue; }
324
436
  const r = rank(rec);
325
- if (!best
326
- || r < bestRank
327
- || (r === bestRank && (rec.priority < best.priority
328
- || (rec.priority === best.priority && (best.el.compareDocumentPosition(rec.el) & Node.DOCUMENT_POSITION_PRECEDING))))) {
437
+ // Ties keep registration order: document order for the initial walk, mount order after
438
+ if (!best || r < bestRank || (r === bestRank && rec.priority < best.priority)) {
329
439
  best = rec; bestRank = r;
330
440
  }
331
441
  }
@@ -347,7 +457,7 @@ function schedule() {
347
457
  const rec = next();
348
458
  if (!rec) break;
349
459
  if (!rec.urgent && warm.size >= PREWARM_CAP) break; // cap = max warm; only urgent renders may exceed it
350
- for (const other of pending) other.near = undefined;
460
+ if (!io) for (const other of pending) other.near = undefined;
351
461
  render(rec, true);
352
462
  n++;
353
463
  }
@@ -394,6 +504,7 @@ function initializeDeferPlugin() {
394
504
  Alpine.directive('defer', () => { });
395
505
  if (!killed) Alpine.interceptInit(Alpine.skipDuringClone(intercept));
396
506
  ['pointerdown', 'keydown'].forEach((type) => document.addEventListener(type, onGesture, { capture: true, passive: true }));
507
+ ['manifest:route-change', 'manifest:components-processed'].forEach((type) => window.addEventListener(type, bumpReach));
397
508
  armReady();
398
509
  }
399
510
 
@@ -403,7 +514,7 @@ window.ManifestDefer = {
403
514
  render: (el) => { if (el && el.__mnfstDefer) render(el.__mnfstDefer); },
404
515
  isPending: (el) => !!(el && el.__mnfstDefer && !el.__mnfstDefer.rendered),
405
516
  stats: () => ({
406
- pending: pending.size, warm: warm.size, cap: PREWARM_CAP, ready, bootDrained, armed: idleHandle !== null,
517
+ pending: pending.size, warm: warm.size, cap: PREWARM_CAP, ready, bootDrained, armed: idleHandle !== null, observed: anchors.size,
407
518
  head: (() => { const h = next(); return h ? (h.el.id || h.el.tagName) : null; })(),
408
519
  routes: { enabled: routesOn(), stashed: Array.from(routes).filter((r) => !r.rendered).length, rendered: Array.from(routes).filter((r) => r.rendered).length }
409
520
  })
@@ -9,34 +9,44 @@
9
9
 
10
10
  @layer utilities {
11
11
 
12
- /* ---- Editable area (armed) ----
13
- Chrome is opt-in. Most uses of this plugin are a resize handle or a sortable
14
- list, which should look like themselves; only .authoring gets the outline and
15
- the region label. */
12
+ /* ---- Editing UI scope ----
13
+ ALL chrome — region outline + label, sortable grips, movable/text outlines,
14
+ size-handle hairlines, the floating toolbar — lives under ONE scope:
15
+ [data-edit-ui], on the region itself or any ancestor (e.g. <body data-edit-ui>
16
+ for a whole editor view). The plugin sets it on `.authoring` regions
17
+ automatically; everything else opts in with the attribute. A region outside
18
+ the scope is just as editable — it simply carries no visual cues. */
19
+
20
+ /* ---- Editable area (armed) ---- */
16
21
  :where([data-edit-armed]) {
17
22
  position: relative;
18
23
  }
19
24
 
20
25
  /* Quiet until touched. A page covered in dashed boxes reads as a diagram of
21
- itself; the affordance only has to be there when you go looking for it. */
22
- :where([data-edit-armed][data-edit-authoring]) {
26
+ itself; the affordance only has to be there when you go looking for it.
27
+ While an inner affordance is engaged (text caret, handle, row), the region
28
+ outline stands down so only the element being edited is highlighted. */
29
+ :where([data-edit-armed]):where([data-edit-ui], [data-edit-ui] *) {
23
30
  outline: 1px dashed transparent;
24
31
  outline-offset: 6px;
25
32
  border-radius: var(--radius);
26
33
  transition: outline-color 0.12s ease;
27
34
  }
28
35
 
29
- :where([data-edit-armed][data-edit-authoring]):hover,
30
- :where([data-edit-armed][data-edit-authoring]):focus-within {
36
+ :where([data-edit-armed]):where([data-edit-ui], [data-edit-ui] *):is(:hover, :focus-within):not(:has([contenteditable]:is(:hover, :focus), [data-edit-handle]:is(:hover, :focus), [data-edit-sortable]:is(:hover, :focus-within), [data-edit-movable]:is(:hover, :focus))) {
31
37
  outline-color: var(--color-line);
32
38
  }
33
39
 
34
- :where([data-edit-armed][data-edit-authoring])::before {
40
+ :where([data-edit-armed]):where([data-edit-ui], [data-edit-ui] *)::before {
35
41
  content: attr(data-edit-label);
36
42
  position: absolute;
37
- inset-block-start: -1.5rem;
38
- inset-inline-start: 0;
43
+ inset-block-start: calc(-0.6em - 6px);
44
+ inset-inline-start: 0.5rem;
45
+ padding-inline: 0.3rem;
46
+ background: var(--color-page);
47
+ border-radius: 3px;
39
48
  font-size: 0.7rem;
49
+ line-height: 1.2;
40
50
  letter-spacing: 0.04em;
41
51
  text-transform: uppercase;
42
52
  color: var(--color-content-subtle);
@@ -45,8 +55,7 @@
45
55
  transition: opacity 0.12s ease;
46
56
  }
47
57
 
48
- :where([data-edit-armed][data-edit-authoring]):hover::before,
49
- :where([data-edit-armed][data-edit-authoring]):focus-within::before {
58
+ :where([data-edit-armed]):where([data-edit-ui], [data-edit-ui] *):is(:hover, :focus-within):not(:has([contenteditable]:is(:hover, :focus), [data-edit-handle]:is(:hover, :focus), [data-edit-sortable]:is(:hover, :focus-within), [data-edit-movable]:is(:hover, :focus)))::before {
50
59
  opacity: 1;
51
60
  }
52
61
 
@@ -57,10 +66,28 @@
57
66
  transition: opacity 0.12s ease, outline-color 0.12s ease;
58
67
  }
59
68
 
60
- :where([data-edit-sortable]):hover,
61
- :where([data-edit-sortable]):focus-within {
62
- outline: 1px solid var(--edit-accent, var(--color-brand-content));
63
- outline-offset: 3px;
69
+ /* The grip: the plugin's own inline drag affordance, sitting inside the row
70
+ at its inline start. A dot grid, revealed on approach. --edit-grip sizes
71
+ it; `--edit-grip: 0` removes it (e.g. for grid rows where an extra flex
72
+ item would take a cell). No full-row outline — the grip and the cursor
73
+ say "draggable" without boxing the content. */
74
+ :where([data-edit-ui] [data-edit-sortable], [data-edit-ui][data-edit-sortable])::before {
75
+ content: '';
76
+ align-self: center;
77
+ flex: 0 0 auto;
78
+ width: var(--edit-grip, 0.6rem);
79
+ height: 1rem;
80
+ margin-inline-end: calc(var(--edit-grip, 0.6rem) / 2);
81
+ background-image: radial-gradient(circle, var(--color-content-subtle) 1px, transparent 1px);
82
+ background-size: 0.3rem 0.3rem;
83
+ background-position: center;
84
+ opacity: 0;
85
+ transition: opacity 0.12s ease;
86
+ pointer-events: none;
87
+ }
88
+
89
+ :where([data-edit-ui] [data-edit-sortable], [data-edit-ui][data-edit-sortable]):is(:hover, :focus-visible)::before {
90
+ opacity: 0.9;
64
91
  }
65
92
 
66
93
  :where([data-edit-sortable], [data-edit-movable]):focus-visible {
@@ -105,25 +132,30 @@
105
132
  touch-action: none;
106
133
  }
107
134
 
108
- :where([data-edit-movable]):hover {
135
+ :where([data-edit-ui] [data-edit-movable], [data-edit-ui][data-edit-movable]):hover {
109
136
  outline: 1px solid var(--edit-accent, var(--color-brand-content));
110
137
  outline-offset: 2px;
111
138
  }
112
139
 
113
- /* ---- Inline text ---- */
114
- :where([data-edit-armed]) [contenteditable="true"] {
140
+ /* ---- Inline text ----
141
+ One weight everywhere: hover hints and the focused caret both use the same
142
+ 1px line, so focusing doesn't make the page jump a weight class. */
143
+ :where([data-edit-ui]) :where([data-edit-armed]) [contenteditable="true"],
144
+ :where([data-edit-ui])[data-edit-armed] [contenteditable="true"] {
115
145
  outline: 1px solid transparent;
116
146
  outline-offset: 2px;
117
147
  border-radius: 3px;
118
148
  transition: outline-color 0.12s ease;
119
149
  }
120
150
 
121
- :where([data-edit-armed]) [contenteditable="true"]:hover {
151
+ :where([data-edit-ui]) :where([data-edit-armed]) [contenteditable="true"]:hover,
152
+ :where([data-edit-ui])[data-edit-armed] [contenteditable="true"]:hover {
122
153
  outline-color: color-mix(in oklch, var(--edit-accent, var(--color-brand-content)) 45%, transparent);
123
154
  }
124
155
 
125
- :where([data-edit-armed]) [contenteditable="true"]:focus {
126
- outline: 2px solid var(--edit-accent, var(--color-brand-content));
156
+ :where([data-edit-ui]) :where([data-edit-armed]) [contenteditable="true"]:focus,
157
+ :where([data-edit-ui])[data-edit-armed] [contenteditable="true"]:focus {
158
+ outline-color: var(--edit-accent, var(--color-brand-content));
127
159
  }
128
160
 
129
161
  /* ---- Size handles ----
@@ -133,14 +165,16 @@
133
165
  position: relative;
134
166
  }
135
167
 
136
- /* Handles reveal on approach rather than sitting on the element permanently */
168
+ /* Handles reveal on approach rather than sitting on the element permanently.
169
+ The hairline is chrome, so it follows the [data-edit-ui] scope like every
170
+ other affordance; outside the scope the handles still work (hit areas and
171
+ resize cursors stay), they just draw nothing. */
137
172
  :where([data-edit-sizable]) :where([data-edit-handle])::before {
138
173
  opacity: 0;
139
174
  transition: opacity 0.12s ease, background-color 0.15s ease;
140
175
  }
141
176
 
142
- :where([data-edit-sizable]):hover :where([data-edit-handle])::before,
143
- :where([data-edit-sizable]):focus-within :where([data-edit-handle])::before {
177
+ :where([data-edit-ui] [data-edit-sizable], [data-edit-ui][data-edit-sizable]):is(:hover, :focus-within) :where([data-edit-handle])::before {
144
178
  opacity: 1;
145
179
  }
146
180
 
@@ -300,8 +334,10 @@
300
334
  }
301
335
  }
302
336
 
337
+ /* --edit-toolbar: none (any scope up to :root) suppresses it — e.g. a site
338
+ that demos .authoring regions but has its own publish flow. */
303
339
  :root[data-edit-active] :where([data-edit-toolbar]) {
304
- display: flex;
340
+ display: var(--edit-toolbar, flex);
305
341
  }
306
342
 
307
343
  /* A popover: the top layer sits above every stacking context, so no handle,