mnfst 0.5.135 → 0.5.137

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.
@@ -5,6 +5,12 @@
5
5
  --color-chart-grid: var(--color-line, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent));
6
6
  --color-chart-label: var(--color-content-subtle, oklch(55.6% 0 0));
7
7
 
8
+ /* Heatmap ramp — single-hue sequential scale derived from --color-chart-1,
9
+ spanning a pale tint to a deep, saturated shade so values stay legible
10
+ even when the brand color itself is light. */
11
+ --color-chart-heat-low: oklch(from var(--color-chart-1) 0.95 calc(c * 0.3) h);
12
+ --color-chart-heat-high: oklch(from var(--color-chart-1) 0.55 calc(c * 1.15) h);
13
+
8
14
  /* Chart data segments */
9
15
  --color-chart-1: var(--color-yellow-400, oklch(85.2% 0.199 91.936));
10
16
  --color-chart-2: var(--color-yellow-500, oklch(79.5% 0.184 86.047));
@@ -119,6 +125,23 @@
119
125
  }
120
126
  }
121
127
 
128
+ /* Gauge — track band + value arc */
129
+ & path.gauge-track {
130
+ fill: var(--color-chart-color, var(--color-line, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent)))
131
+ }
132
+
133
+ & path.gauge-value {
134
+ fill: var(--color-chart-color, var(--color-chart-1))
135
+ }
136
+
137
+ /* Gauge centered readout */
138
+ & text.gauge-label {
139
+ fill: var(--color-content-stark, oklch(20.5% 0 0));
140
+ font-size: 1.75rem;
141
+ font-weight: 700;
142
+ pointer-events: none
143
+ }
144
+
122
145
  /* Value labels drawn on/above segments */
123
146
  & text.value {
124
147
  fill: var(--color-content-stark, oklch(20.5% 0 0));
@@ -135,6 +158,17 @@
135
158
  }
136
159
  }
137
160
 
161
+ /* Heatmap cell — fill interpolates between the two heat tokens by the
162
+ per-cell --heat percentage (0% = low, 100% = high) */
163
+ & rect.heat-cell {
164
+ fill: color-mix(in oklch, var(--color-chart-heat-high, var(--color-chart-1)) var(--heat, 0%), var(--color-chart-heat-low, var(--color-surface-2)));
165
+ transition: opacity var(--transition-duration, .1s) ease;
166
+
167
+ &:hover {
168
+ opacity: 0.82
169
+ }
170
+ }
171
+
138
172
  /* Legend — <footer> sibling below the SVG; <span> items, <i> swatches */
139
173
  & footer {
140
174
  display: flex;
@@ -160,6 +194,21 @@
160
194
  }
161
195
  }
162
196
 
197
+ /* Heatmap gradient legend — low label, ramp bar (spans the grid), high
198
+ label. Footer is padded to the chart margins in JS so the bar aligns
199
+ with the columns. */
200
+ & footer.heat-legend {
201
+ flex-wrap: nowrap;
202
+ gap: 0.5rem;
203
+
204
+ & i {
205
+ flex: 1;
206
+ height: 0.5rem;
207
+ border-radius: 1rem;
208
+ background: linear-gradient(to right, var(--color-chart-heat-low, var(--color-surface-2)), var(--color-chart-heat-high, var(--color-chart-1)))
209
+ }
210
+ }
211
+
163
212
  /* Cursor-following tooltip — chrome comes from manifest.tooltip.css
164
213
  (.tooltip); only the pointer-tracking positioning lives here */
165
214
  & .tooltip {
@@ -147,6 +147,7 @@
147
147
  cfg.grid = cfg.grid !== false;
148
148
  cfg.tooltip = cfg.tooltip !== false; // hover tooltips on by default
149
149
  cfg.dataLabels = !!cfg.dataLabels; // static value labels off by default
150
+ cfg.gap = num(cfg.gap, 1); // heatmap tile gutter in px
150
151
  cfg.labels = Array.isArray(cfg.labels) ? cfg.labels : [];
151
152
 
152
153
  // Series may be omitted in favor of a single `data` array.
@@ -161,6 +162,13 @@
161
162
  s.data = s.data.map(d => num(d.value, 0));
162
163
  }
163
164
  }
165
+
166
+ // Gauge: a single value, from `value` or the first series datum.
167
+ if (cfg.type === 'gauge') {
168
+ if (cfg.value != null && !cfg.series.length) cfg.series = [{ data: [num(cfg.value, 0)] }];
169
+ cfg.min = num(cfg.min, 0);
170
+ cfg.max = num(cfg.max, 100);
171
+ }
164
172
  return cfg;
165
173
  }
166
174
 
@@ -176,8 +184,13 @@
176
184
  t.appendChild(document.createTextNode(str == null ? '' : String(str))); // untrusted-safe
177
185
  return t;
178
186
  }
187
+ // Entry animations run only on a chart's first draw. Reactive redraws
188
+ // (a bound value changing, a resize) must paint the final state directly
189
+ // — otherwise dragging a slider replays the reveal every frame and the
190
+ // chart flickers. drawChart sets this before dispatching.
191
+ let _suppressAnim = false;
179
192
  function animate(el, keyframes, opts) {
180
- if (prefersReducedMotion() || typeof el.animate !== 'function') return;
193
+ if (_suppressAnim || prefersReducedMotion() || typeof el.animate !== 'function') return;
181
194
  try { el.animate(keyframes, Object.assign({ duration: 600, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }, opts)); } catch (_) { }
182
195
  }
183
196
  // Cursor-following tooltip. Manifest's x-tooltip relies on CSS anchor
@@ -280,11 +293,17 @@
280
293
  const hasData = cfg.series.some(s => Array.isArray(s.data) && s.data.length);
281
294
  if (!hasData) { const d = document.createElement('small'); d.textContent = 'No data'; el.appendChild(d); return; }
282
295
 
296
+ // Animate the reveal only on the first paint; redraws snap to state.
297
+ _suppressAnim = !!state._drawn;
298
+ state._drawn = true;
299
+
283
300
  // Label via aria-label (not an SVG <title>, which renders a native
284
301
  // browser tooltip that conflicts with our cursor tooltip).
285
302
  const root = svg('svg', { viewBox: `0 0 ${width} ${height}`, width: '100%', height: String(height), role: 'img', 'aria-label': cfg.title || (cfg.type + ' chart'), preserveAspectRatio: 'xMidYMid meet' }, el);
286
303
 
287
304
  if (cfg.type === 'pie' || cfg.type === 'donut') drawPie(state, root, width, height);
305
+ else if (cfg.type === 'gauge') drawGauge(state, root, width, height);
306
+ else if (cfg.type === 'heatmap') drawHeatmap(state, root, width, height);
288
307
  else drawCartesian(state, root, width, height);
289
308
  }
290
309
 
@@ -303,6 +322,17 @@
303
322
  }
304
323
  function seriesColorVar(i, explicit) { return explicit || `var(--color-chart-${(i % _paletteN) + 1})`; }
305
324
 
325
+ // Resolve the theme --radius token to user-space px (the viewBox is 1:1
326
+ // with CSS px), so SVG corners match the rest of the UI's rounding.
327
+ function cssRadius(el) {
328
+ try {
329
+ const v = getComputedStyle(el).getPropertyValue('--radius').trim() || '0.5rem';
330
+ if (v.endsWith('rem')) return parseFloat(v) * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
331
+ if (v.endsWith('px')) return parseFloat(v);
332
+ return parseFloat(v) || 8;
333
+ } catch (_) { return 8; }
334
+ }
335
+
306
336
  // Line interpolation: monotone (smooth, default) | linear | step | natural.
307
337
  function curveFor(d3, name) {
308
338
  switch (String(name || '').toLowerCase()) {
@@ -479,7 +509,7 @@
479
509
  });
480
510
  }
481
511
  function animateBar(rect, ih) {
482
- if (prefersReducedMotion() || typeof rect.animate !== 'function') return;
512
+ if (_suppressAnim || prefersReducedMotion() || typeof rect.animate !== 'function') return;
483
513
  rect.style.transformBox = 'fill-box'; rect.style.transformOrigin = 'center bottom';
484
514
  try { rect.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], { duration: 600, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }); } catch (_) { }
485
515
  }
@@ -511,7 +541,7 @@
511
541
  const path = svg('path', { class: 'slice', d: arc(slice), style: `--color-chart-color:${seriesColorVar(i)}` }, g);
512
542
  applyTip(path, labels[i] + ': ' + data[i], cfg);
513
543
  if (cfg.dataLabels) { const c = arc.centroid(slice); dataLabel(g, data[i], c[0], c[1], 'middle', 'central', 'inverse'); }
514
- if (!prefersReducedMotion() && typeof path.animate === 'function') {
544
+ if (!_suppressAnim && !prefersReducedMotion() && typeof path.animate === 'function') {
515
545
  path.style.transformBox = 'fill-box'; path.style.transformOrigin = 'center';
516
546
  try { path.animate([{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'scale(1)' }], { duration: 450, delay: i * 60, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }); } catch (_) { }
517
547
  }
@@ -519,6 +549,128 @@
519
549
  if (cfg.legend) drawLegend(state, labels);
520
550
  }
521
551
 
552
+ // Gauge — a single value swept across a 180° dome. Track + value arc
553
+ // reuse the pie/donut arc primitive; optional `zones` paint threshold
554
+ // bands (e.g. positive/warning/negative ranges). `unit` suffixes the
555
+ // centered readout; `min`/`max` default 0–100.
556
+ function drawGauge(state, root, width, height) {
557
+ const cfg = state.config, d3 = state.d3;
558
+ const value = num(cfg.series[0] && cfg.series[0].data[0], 0);
559
+ const min = cfg.min, max = cfg.max;
560
+ const START = -Math.PI / 2, END = Math.PI / 2;
561
+ const scale = d3.scaleLinear().domain([min, max]).range([START, END]).clamp(true);
562
+ const unit = cfg.unit || '';
563
+
564
+ const r = Math.min(width / 2, height) - 8;
565
+ const thickness = Math.max(8, r * 0.22);
566
+ const cx = width / 2, cy = 8 + r;
567
+ const g = svg('g', { transform: `translate(${cx},${cy})` }, root);
568
+ const arc = d3.arc().innerRadius(r - thickness).outerRadius(r).cornerRadius(cssRadius(state.el));
569
+
570
+ // Track, or threshold zone bands when `zones` is given.
571
+ if (Array.isArray(cfg.zones) && cfg.zones.length) {
572
+ let from = min;
573
+ cfg.zones.forEach((z, i) => {
574
+ const to = num(z.to, max);
575
+ svg('path', { class: 'gauge-track', d: arc({ startAngle: scale(from), endAngle: scale(to) }), style: `--color-chart-color:${z.color || seriesColorVar(i)}`, opacity: 0.35 }, g);
576
+ from = to;
577
+ });
578
+ } else {
579
+ svg('path', { class: 'gauge-track', d: arc({ startAngle: START, endAngle: END }) }, g);
580
+ }
581
+
582
+ // Value arc — final geometry is set unconditionally so the gauge is
583
+ // correct even if the entry animation never runs (background tab);
584
+ // the sweep is a CSS reveal, never the source of the end state.
585
+ const color = (cfg.series[0] && cfg.series[0].color) || 'var(--color-chart-1)';
586
+ const valueAngle = scale(value);
587
+ const vArc = svg('path', { class: 'gauge-value', d: arc({ startAngle: START, endAngle: valueAngle }), style: `--color-chart-color:${color}` }, g);
588
+ applyTip(vArc, (cfg.title ? cfg.title + ': ' : '') + value + unit, cfg);
589
+ // Reveal via fade (WAAPI, origin-independent) — the final geometry above
590
+ // is unconditional, so the gauge is correct even if this never runs.
591
+ animate(vArc, [{ opacity: 0 }, { opacity: 1 }], { duration: 500 });
592
+
593
+ // Centered readout + range end labels.
594
+ text(g, value + unit, { class: 'gauge-label', x: 0, y: -r * 0.12, 'text-anchor': 'middle', 'dominant-baseline': 'central' });
595
+ if (cfg.axis) {
596
+ const lr = r - thickness / 2;
597
+ text(g, String(min), { x: -lr, y: 16, 'text-anchor': 'middle' });
598
+ text(g, String(max), { x: lr, y: 16, 'text-anchor': 'middle' });
599
+ }
600
+ }
601
+
602
+ // Heatmap — a matrix of cells: each series is a row, each datum a column
603
+ // aligned to `labels`. Cell colour is a CSS color-mix between the two
604
+ // --color-chart-heat-* tokens (no JS colour-interpolation dep), driven
605
+ // by the per-cell `--heat` percentage.
606
+ function drawHeatmap(state, root, width, height) {
607
+ const cfg = state.config, d3 = state.d3;
608
+ const rows = cfg.series;
609
+ const cols = cfg.labels.length ? cfg.labels : (rows[0] && Array.isArray(rows[0].data) ? rows[0].data.map((_, i) => i + 1) : []);
610
+ const rowName = (r, i) => r.name || String(i + 1);
611
+ const showLabels = cfg.axis;
612
+
613
+ const m = { top: 4, right: 4, bottom: showLabels ? 24 : 4, left: showLabels ? 64 : 4 };
614
+ const iw = width - m.left - m.right;
615
+ const ih = height - m.top - m.bottom;
616
+ // Tiles abut (padding 0); the gutter comes from insetting each rect
617
+ // by `gap` px (config, default 1; set 0 for a seamless field).
618
+ const gap = Math.max(0, cfg.gap);
619
+ const x = d3.scaleBand().domain(cols.map(String)).range([0, iw]).padding(0);
620
+ const yb = d3.scaleBand().domain(rows.map(rowName)).range([0, ih]).padding(0);
621
+ const cw = Math.max(0, x.bandwidth() - gap), ch = Math.max(0, yb.bandwidth() - gap);
622
+
623
+ // Value domain across every cell.
624
+ let lo = Infinity, hi = -Infinity;
625
+ rows.forEach(r => (r.data || []).forEach(v => { const n = num(v, 0); if (n < lo) lo = n; if (n > hi) hi = n; }));
626
+ if (!isFinite(lo)) { lo = 0; hi = 1; }
627
+ if (lo === hi) hi = lo + 1;
628
+
629
+ const plot = svg('g', { transform: `translate(${m.left},${m.top})` }, root);
630
+
631
+ // Round only the grid's outer corners: square tiles clipped to a single
632
+ // rounded rect hugging the cell extent (right/bottom edge sits at the
633
+ // last tile's inner edge, so the radius isn't clipping empty gutter).
634
+ const clipId = 'mnfst-heat-' + (++_uid);
635
+ const cp = svg('clipPath', { id: clipId }, svg('defs', null, root));
636
+ svg('rect', { x: 0, y: 0, width: Math.max(0, iw - gap), height: Math.max(0, ih - gap), rx: cssRadius(state.el) }, cp);
637
+ const cellsG = svg('g', { 'clip-path': `url(#${clipId})` }, plot);
638
+
639
+ rows.forEach((r, ri) => {
640
+ const yy = yb(rowName(r, ri));
641
+ cols.forEach((c, ci) => {
642
+ const v = num(r.data[ci], 0);
643
+ const t = Math.round(((v - lo) / (hi - lo)) * 100);
644
+ const cell = svg('rect', { class: 'heat-cell', x: x(String(c)), y: yy, width: cw, height: ch, style: `--heat:${t}%` }, cellsG);
645
+ applyTip(cell, (r.name ? r.name + ' · ' : '') + c + ': ' + v, cfg);
646
+ animate(cell, [{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: (ri + ci) * 20 });
647
+ if (cfg.dataLabels) dataLabel(plot, v, x(String(c)) + x.bandwidth() / 2, yy + yb.bandwidth() / 2, 'middle', 'central');
648
+ });
649
+ });
650
+
651
+ if (showLabels) {
652
+ rows.forEach((r, ri) => text(plot, rowName(r, ri), { x: -8, y: yb(rowName(r, ri)) + yb.bandwidth() / 2, 'text-anchor': 'end', 'dominant-baseline': 'central' }));
653
+ cols.forEach(c => text(plot, c, { x: x(String(c)) + x.bandwidth() / 2, y: ih + 16, 'text-anchor': 'middle' }));
654
+ }
655
+
656
+ if (cfg.legend) drawHeatLegend(state, lo, hi, m);
657
+ }
658
+
659
+ // Continuous gradient legend for the heatmap: low label, ramp bar, high
660
+ // label. Padded to the chart's margins so the bar spans the grid width
661
+ // (bar flexes to fill — see .heat-legend in the CSS).
662
+ function drawHeatLegend(state, lo, hi, m) {
663
+ const footer = document.createElement('footer');
664
+ footer.className = 'heat-legend';
665
+ footer.style.paddingLeft = m.left + 'px';
666
+ footer.style.paddingRight = m.right + 'px';
667
+ const a = document.createElement('span'); a.textContent = lo;
668
+ const bar = document.createElement('i');
669
+ const b = document.createElement('span'); b.textContent = hi;
670
+ footer.append(a, bar, b);
671
+ state.el.appendChild(footer);
672
+ }
673
+
522
674
  // Legend is a <footer> sibling below the SVG (inline flex), not an
523
675
  // absolute overlay — so it never collides with axis labels. Each item is
524
676
  // a <span> with an <i> swatch carrying the series colour.
package/lib/manifest.css CHANGED
@@ -651,6 +651,12 @@
651
651
  --color-chart-grid: var(--color-line, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent));
652
652
  --color-chart-label: var(--color-content-subtle, oklch(55.6% 0 0));
653
653
 
654
+ /* Heatmap ramp — single-hue sequential scale derived from --color-chart-1,
655
+ spanning a pale tint to a deep, saturated shade so values stay legible
656
+ even when the brand color itself is light. */
657
+ --color-chart-heat-low: oklch(from var(--color-chart-1) 0.95 calc(c * 0.3) h);
658
+ --color-chart-heat-high: oklch(from var(--color-chart-1) 0.55 calc(c * 1.15) h);
659
+
654
660
  /* Chart data segments */
655
661
  --color-chart-1: var(--color-yellow-400, oklch(85.2% 0.199 91.936));
656
662
  --color-chart-2: var(--color-yellow-500, oklch(79.5% 0.184 86.047));
@@ -765,6 +771,23 @@
765
771
  }
766
772
  }
767
773
 
774
+ /* Gauge — track band + value arc */
775
+ & path.gauge-track {
776
+ fill: var(--color-chart-color, var(--color-line, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent)))
777
+ }
778
+
779
+ & path.gauge-value {
780
+ fill: var(--color-chart-color, var(--color-chart-1))
781
+ }
782
+
783
+ /* Gauge centered readout */
784
+ & text.gauge-label {
785
+ fill: var(--color-content-stark, oklch(20.5% 0 0));
786
+ font-size: 1.75rem;
787
+ font-weight: 700;
788
+ pointer-events: none
789
+ }
790
+
768
791
  /* Value labels drawn on/above segments */
769
792
  & text.value {
770
793
  fill: var(--color-content-stark, oklch(20.5% 0 0));
@@ -781,6 +804,17 @@
781
804
  }
782
805
  }
783
806
 
807
+ /* Heatmap cell — fill interpolates between the two heat tokens by the
808
+ per-cell --heat percentage (0% = low, 100% = high) */
809
+ & rect.heat-cell {
810
+ fill: color-mix(in oklch, var(--color-chart-heat-high, var(--color-chart-1)) var(--heat, 0%), var(--color-chart-heat-low, var(--color-surface-2)));
811
+ transition: opacity var(--transition-duration, .1s) ease;
812
+
813
+ &:hover {
814
+ opacity: 0.82
815
+ }
816
+ }
817
+
784
818
  /* Legend — <footer> sibling below the SVG; <span> items, <i> swatches */
785
819
  & footer {
786
820
  display: flex;
@@ -806,6 +840,21 @@
806
840
  }
807
841
  }
808
842
 
843
+ /* Heatmap gradient legend — low label, ramp bar (spans the grid), high
844
+ label. Footer is padded to the chart margins in JS so the bar aligns
845
+ with the columns. */
846
+ & footer.heat-legend {
847
+ flex-wrap: nowrap;
848
+ gap: 0.5rem;
849
+
850
+ & i {
851
+ flex: 1;
852
+ height: 0.5rem;
853
+ border-radius: 1rem;
854
+ background: linear-gradient(to right, var(--color-chart-heat-low, var(--color-surface-2)), var(--color-chart-heat-high, var(--color-chart-1)))
855
+ }
856
+ }
857
+
809
858
  /* Cursor-following tooltip — chrome comes from manifest.tooltip.css
810
859
  (.tooltip); only the pointer-tracking positioning lives here */
811
860
  & .tooltip {
@@ -2212,7 +2261,7 @@
2212
2261
  margin-left: auto;
2213
2262
  margin-right: auto;
2214
2263
 
2215
- & :where(a, button, [role=button]):not(.unstyle) {
2264
+ & :where(a, button, [role=button]) {
2216
2265
  justify-content: center
2217
2266
  }
2218
2267
  }
@@ -2357,6 +2406,228 @@
2357
2406
  }
2358
2407
  }
2359
2408
 
2409
+ /* Manifest Edit */
2410
+
2411
+ /* Authors override any of this without specificity battles: every rule is wrapped
2412
+ in :where(), state lives on data-attributes, and injected affordances use plain
2413
+ semantic class names (.edit-handle, .edit-frame) — no framework-prefixed litter. */
2414
+
2415
+ @layer utilities {
2416
+
2417
+ /* ---- Editable area (armed) ---- */
2418
+ :where([data-edit-armed]) {
2419
+ position: relative;
2420
+ outline: 1px dashed var(--color-line);
2421
+ outline-offset: 6px;
2422
+ border-radius: var(--radius);
2423
+ }
2424
+
2425
+ :where([data-edit-armed])::before {
2426
+ content: attr(data-edit-label);
2427
+ position: absolute;
2428
+ inset-block-start: -1.5rem;
2429
+ inset-inline-start: 0;
2430
+ font-size: 0.7rem;
2431
+ letter-spacing: 0.04em;
2432
+ text-transform: uppercase;
2433
+ color: var(--color-content-subtle);
2434
+ pointer-events: none;
2435
+ }
2436
+
2437
+ /* ---- Reorder (pointer + keyboard; touch-action:none lets touch-drag beat scroll) ---- */
2438
+ :where([data-edit-sortable]) {
2439
+ cursor: grab;
2440
+ touch-action: none;
2441
+ transition: opacity 0.12s ease, outline-color 0.12s ease;
2442
+ }
2443
+ :where([data-edit-sortable]):hover {
2444
+ outline: 1px solid var(--edit-accent, var(--color-brand-content));
2445
+ outline-offset: 3px;
2446
+ }
2447
+ :where([data-edit-sortable]):focus-visible,
2448
+ :where([data-edit-movable]):focus-visible {
2449
+ outline: 2px solid var(--edit-accent, var(--color-brand-content));
2450
+ outline-offset: 3px;
2451
+ }
2452
+ :where([data-edit-dragging]) { opacity: 0.4; }
2453
+ :where([data-edit-grabbed]) {
2454
+ outline: 2px dashed var(--edit-accent, var(--color-brand-content));
2455
+ outline-offset: 3px;
2456
+ }
2457
+
2458
+ /* Screen-reader announcement region (keyboard grab/move/drop) */
2459
+ :where(.edit-sr) {
2460
+ position: absolute;
2461
+ width: 1px; height: 1px;
2462
+ margin: -1px; padding: 0; border: 0;
2463
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
2464
+ }
2465
+
2466
+ /* ---- Move (positioned children) ---- */
2467
+ :where([data-edit-movable]) { cursor: move; touch-action: none; }
2468
+ :where([data-edit-movable]):hover {
2469
+ outline: 1px solid var(--edit-accent, var(--color-brand-content));
2470
+ outline-offset: 2px;
2471
+ }
2472
+
2473
+ /* ---- Inline text ---- */
2474
+ :where([data-edit-armed]) [contenteditable="true"] {
2475
+ outline: 1px solid color-mix(in srgb, var(--edit-accent, var(--color-brand-content)) 45%, transparent);
2476
+ outline-offset: 2px;
2477
+ border-radius: 3px;
2478
+ }
2479
+ :where([data-edit-armed]) [contenteditable="true"]:focus {
2480
+ outline: 2px solid var(--edit-accent, var(--color-brand-content));
2481
+ }
2482
+
2483
+ /* ---- Size (resize handles) — injected overlay children, fully stylable ---- */
2484
+ /* The element keeps its own markup untouched, so its native :hover/:focus still
2485
+ fire. Handles are separate elements with pointer-events; the body of the
2486
+ element stays interactive. */
2487
+ :where([data-edit-sizable]) {
2488
+ position: relative;
2489
+
2490
+ .edit-handle {
2491
+ position: absolute;
2492
+ z-index: 100;
2493
+ background: transparent;
2494
+ --h: var(--edit-size-handle, 1rem);
2495
+
2496
+ &::before {
2497
+ content: '';
2498
+ position: absolute;
2499
+ top: 50%;
2500
+ left: 50%;
2501
+ transform: translate(-50%, -50%);
2502
+ width: 1px;
2503
+ height: 1px;
2504
+ background: transparent;
2505
+ transition: background-color 0.15s ease;
2506
+ }
2507
+ &:hover::before, &:active::before {
2508
+ background-color: var(--edit-accent, var(--color-brand-content));
2509
+ }
2510
+ }
2511
+
2512
+ .edit-handle-top, .edit-handle-bottom { left: 0; width: 100%; height: var(--h); cursor: ns-resize; &::before { width: 100%; } }
2513
+ .edit-handle-left, .edit-handle-right, .edit-handle-start, .edit-handle-end { top: 0; height: 100%; width: var(--h); cursor: ew-resize; &::before { height: 100%; } }
2514
+ .edit-handle-top { top: calc(var(--h) * -0.5); }
2515
+ .edit-handle-bottom { bottom: calc(var(--h) * -0.5); }
2516
+ .edit-handle-left { left: calc(var(--h) * -0.5); }
2517
+ .edit-handle-right { right: calc(var(--h) * -0.5); }
2518
+ /* Logical edges — inset-inline-* auto-flips for RTL */
2519
+ .edit-handle-start { inset-inline-start: calc(var(--h) * -0.5); }
2520
+ .edit-handle-end { inset-inline-end: calc(var(--h) * -0.5); }
2521
+
2522
+ .edit-handle-top-left, .edit-handle-top-right, .edit-handle-bottom-left, .edit-handle-bottom-right,
2523
+ .edit-handle-top-start, .edit-handle-top-end, .edit-handle-bottom-start, .edit-handle-bottom-end {
2524
+ width: var(--h); height: var(--h);
2525
+ }
2526
+ .edit-handle-top-left { top: calc(var(--h) * -0.5); left: calc(var(--h) * -0.5); cursor: nwse-resize; }
2527
+ .edit-handle-top-right { top: calc(var(--h) * -0.5); right: calc(var(--h) * -0.5); cursor: nesw-resize; }
2528
+ .edit-handle-bottom-left { bottom: calc(var(--h) * -0.5); left: calc(var(--h) * -0.5); cursor: nesw-resize; }
2529
+ .edit-handle-bottom-right { bottom: calc(var(--h) * -0.5); right: calc(var(--h) * -0.5); cursor: nwse-resize; }
2530
+ .edit-handle-top-start { top: calc(var(--h) * -0.5); inset-inline-start: calc(var(--h) * -0.5); cursor: nwse-resize; }
2531
+ .edit-handle-top-end { top: calc(var(--h) * -0.5); inset-inline-end: calc(var(--h) * -0.5); cursor: nesw-resize; }
2532
+ .edit-handle-bottom-start { bottom: calc(var(--h) * -0.5); inset-inline-start: calc(var(--h) * -0.5); cursor: nesw-resize; }
2533
+ .edit-handle-bottom-end { bottom: calc(var(--h) * -0.5); inset-inline-end: calc(var(--h) * -0.5); cursor: nwse-resize; }
2534
+ }
2535
+
2536
+ /* Collapsed state (size dropped below --edit-size-collapse-*) — authors restyle freely */
2537
+ :where([data-edit-collapsed]) { opacity: 0.45; }
2538
+
2539
+ /* Full-viewport capture layer during a size/move drag (mirrors resize overlay) */
2540
+ :where(.edit-overlay) {
2541
+ position: fixed;
2542
+ inset: 0;
2543
+ z-index: 9999;
2544
+ background: transparent;
2545
+ }
2546
+
2547
+ @media (prefers-reduced-motion: reduce) {
2548
+ :where([data-edit-armed]) > [draggable="true"], :where([data-edit-sizable]) .edit-handle::before { transition: none; }
2549
+ }
2550
+ }
2551
+
2552
+ /* ---- Plugin dev chrome (spike-only: toolbar + inspector + class menu) ---- */
2553
+ @layer components {
2554
+
2555
+ :where(.edit-toolbar) {
2556
+ position: fixed;
2557
+ inset-block-end: 1rem;
2558
+ inset-inline-end: 1rem;
2559
+ z-index: 9999;
2560
+ display: flex;
2561
+ gap: 0.5rem;
2562
+ align-items: center;
2563
+ }
2564
+
2565
+ :where(.edit-classes) {
2566
+ position: fixed;
2567
+ z-index: 10000;
2568
+ width: 260px;
2569
+ padding: 0.6rem;
2570
+ display: flex;
2571
+ flex-direction: column;
2572
+ gap: 0.4rem;
2573
+ background: var(--color-surface-1);
2574
+ border: 1px solid var(--color-line);
2575
+ border-radius: var(--radius);
2576
+ box-shadow: 0 8px 30px rgb(0 0 0 / 0.2);
2577
+ }
2578
+ :where(.edit-classes)[hidden] { display: none; }
2579
+ :where(.edit-classes) input { width: 100%; font-family: ui-monospace, Menlo, monospace; font-size: 0.75rem; }
2580
+ :where(.edit-classes) .muted { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-content-subtle); }
2581
+
2582
+ :where(.edit-inspector) {
2583
+ position: fixed;
2584
+ inset-block: 1rem 5rem;
2585
+ inset-inline-end: 1rem;
2586
+ width: 380px;
2587
+ max-width: 42vw;
2588
+ z-index: 9998;
2589
+ overflow: auto;
2590
+ padding: 1rem;
2591
+ background: var(--color-surface-1);
2592
+ border: 1px solid var(--color-line);
2593
+ border-radius: var(--radius);
2594
+ box-shadow: 0 8px 30px rgb(0 0 0 / 0.18);
2595
+ font-size: 0.78rem;
2596
+ }
2597
+ :where(.edit-inspector)[hidden] { display: none; }
2598
+ :where(.edit-inspector) h4 { margin: 0 0 0.25rem; }
2599
+ :where(.edit-inspector) section { margin-bottom: 1rem; }
2600
+ :where(.edit-inspector) pre {
2601
+ margin: 0.25rem 0 0;
2602
+ padding: 0.6rem;
2603
+ background: var(--color-surface-3);
2604
+ border-radius: 6px;
2605
+ white-space: pre-wrap;
2606
+ word-break: break-word;
2607
+ font-family: ui-monospace, Menlo, monospace;
2608
+ font-size: 0.72rem;
2609
+ line-height: 1.45;
2610
+ }
2611
+ :where(.edit-inspector) .muted { color: var(--color-content-subtle); }
2612
+ :where(.edit-inspector) .tag {
2613
+ display: inline-block;
2614
+ font-size: 0.64rem;
2615
+ text-transform: uppercase;
2616
+ letter-spacing: 0.05em;
2617
+ padding: 0.05rem 0.4rem;
2618
+ border-radius: 999px;
2619
+ background: var(--color-surface-3);
2620
+ color: var(--color-content-subtle);
2621
+ margin-inline-start: 0.4rem;
2622
+ }
2623
+ :where(.edit-inspector) .tag.static { background: color-mix(in srgb, var(--color-brand-content) 18%, transparent); }
2624
+ :where(.edit-inspector) .tag.data { background: color-mix(in srgb, var(--color-positive-content) 22%, transparent); }
2625
+ :where(.edit-inspector) .tag.component { background: color-mix(in srgb, var(--color-negative-content) 20%, transparent); }
2626
+ :where(.edit-inspector) .delta { padding: 0.2rem 0.3rem; border-radius: 4px; font-family: ui-monospace, Menlo, monospace; font-size: 0.7rem; line-height: 1.5; }
2627
+ :where(.edit-inspector) .delta.undone { opacity: 0.4; text-decoration: line-through; }
2628
+ :where(.edit-toolbar) button[disabled] { opacity: 0.35; pointer-events: none; }
2629
+ }
2630
+
2360
2631
  /* Manifest Forms */
2361
2632
 
2362
2633
  @layer components {
@@ -1497,6 +1497,15 @@ function numericKeyObjectToArray(obj) {
1497
1497
  return sorted.map(i => obj[String(i)]);
1498
1498
  }
1499
1499
 
1500
+ // Empty value for a header-only CSV (declares columns but has no data rows yet —
1501
+ // e.g. a locales file used only to declare available languages). Shapes the
1502
+ // result like a populated parse would: tabular ('id' + 3+ columns) → [], else
1503
+ // key-value → {}. A header-only CSV is a valid empty source, not an error.
1504
+ function emptyCsvResult(headers) {
1505
+ const first = (headers[0] || '').toLowerCase();
1506
+ return (headers.length > 2 && first === 'id') ? [] : {};
1507
+ }
1508
+
1500
1509
  // Parse CSV text to nested object structure
1501
1510
  function parseCSVToNestedObject(csvText, options = {}) {
1502
1511
  const {
@@ -1520,7 +1529,13 @@ function parseCSVToNestedObject(csvText, options = {}) {
1520
1529
  }
1521
1530
 
1522
1531
  if (!parsed.data || parsed.data.length === 0) {
1523
- throw new Error('[Manifest Data] CSV file is empty or has no data rows');
1532
+ // Header-only CSV is a valid empty source return empty rather than
1533
+ // throw. Only a file with no header row at all is treated as invalid.
1534
+ const fields = parsed.meta?.fields || [];
1535
+ if (fields.length === 0) {
1536
+ throw new Error('[Manifest Data] CSV file is empty or has no headers');
1537
+ }
1538
+ return emptyCsvResult(fields);
1524
1539
  }
1525
1540
 
1526
1541
  const result = {};
@@ -1608,8 +1623,8 @@ function parseCSVToNestedObject(csvText, options = {}) {
1608
1623
  } else {
1609
1624
  // Fallback simple parser (if PapaParse not loaded)
1610
1625
  const lines = csvText.split('\n').filter(line => line.trim());
1611
- if (lines.length < 2) {
1612
- throw new Error('[Manifest Data] CSV file must have at least a header row and one data row');
1626
+ if (lines.length === 0) {
1627
+ throw new Error('[Manifest Data] CSV file is empty or has no headers');
1613
1628
  }
1614
1629
 
1615
1630
  // Simple CSV line parser (handles quoted values)
@@ -1638,6 +1653,11 @@ function parseCSVToNestedObject(csvText, options = {}) {
1638
1653
  throw new Error('[Manifest Data] CSV file must have at least two columns');
1639
1654
  }
1640
1655
 
1656
+ if (lines.length === 1) {
1657
+ // Header-only CSV → valid empty source (see PapaParse path above).
1658
+ return emptyCsvResult(headers);
1659
+ }
1660
+
1641
1661
  // First column is always the key
1642
1662
  const keyColumn = headers[0];
1643
1663
 
@@ -136,7 +136,7 @@
136
136
  margin-left: auto;
137
137
  margin-right: auto;
138
138
 
139
- & :where(a, button, [role=button]):not(.unstyle) {
139
+ & :where(a, button, [role=button]) {
140
140
  justify-content: center
141
141
  }
142
142
  }