mrmd-editor 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,222 @@ class HiddenWidget extends WidgetType {
55
55
  }
56
56
  }
57
57
 
58
+ function escapeHtml(text) {
59
+ return String(text ?? '')
60
+ .replace(/&/g, '&')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;')
63
+ .replace(/"/g, '&quot;')
64
+ .replace(/'/g, '&#039;');
65
+ }
66
+
67
+ function tryParseJsonOutput(content) {
68
+ if (!content || hasAnsi(content)) return null;
69
+ if (content.length > 250_000) return null; // Guard large payloads
70
+
71
+ const trimmed = content.trim();
72
+ if (!trimmed) return null;
73
+ const looksLikeObjectOrArray =
74
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
75
+ (trimmed.startsWith('[') && trimmed.endsWith(']'));
76
+ if (!looksLikeObjectOrArray) return null;
77
+
78
+ try {
79
+ return JSON.parse(trimmed);
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function jsonType(value) {
86
+ if (value === null) return 'null';
87
+ if (Array.isArray(value)) return 'array';
88
+ return typeof value;
89
+ }
90
+
91
+ function formatJsonPrimitive(value) {
92
+ const type = jsonType(value);
93
+ if (type === 'string') return `"${value}"`;
94
+ if (type === 'number' || type === 'boolean' || type === 'null') return String(value);
95
+ if (type === 'undefined') return 'undefined';
96
+ return String(value);
97
+ }
98
+
99
+ function summarizeJson(value) {
100
+ const type = jsonType(value);
101
+ if (type === 'array') return `Array(${value.length})`;
102
+ if (type === 'object') return `Object(${Object.keys(value).length})`;
103
+ return type;
104
+ }
105
+
106
+ function previewJsonValue(value) {
107
+ const type = jsonType(value);
108
+ if (type === 'array') {
109
+ const sample = value.slice(0, 3).map(v => formatJsonPrimitive(v)).join(', ');
110
+ return `[${sample}${value.length > 3 ? ', …' : ''}]`;
111
+ }
112
+ if (type === 'object') {
113
+ const keys = Object.keys(value);
114
+ return `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? ', …' : ''}}`;
115
+ }
116
+ return formatJsonPrimitive(value);
117
+ }
118
+
119
+ function buildJsonTreeNode(key, value, depth = 0) {
120
+ const type = jsonType(value);
121
+ const isExpandable = type === 'array' || type === 'object';
122
+
123
+ if (!isExpandable) {
124
+ const row = document.createElement('div');
125
+ row.className = 'cm-json-node cm-json-node-leaf';
126
+
127
+ if (key !== null) {
128
+ const keyEl = document.createElement('span');
129
+ keyEl.className = 'cm-json-key';
130
+ keyEl.textContent = String(key);
131
+ row.appendChild(keyEl);
132
+ }
133
+
134
+ const valueEl = document.createElement('span');
135
+ valueEl.className = `cm-json-value cm-json-type-${type}`;
136
+ valueEl.textContent = formatJsonPrimitive(value);
137
+ row.appendChild(valueEl);
138
+ return row;
139
+ }
140
+
141
+ const details = document.createElement('details');
142
+ details.className = 'cm-json-node cm-json-node-expandable';
143
+ details.open = depth < 1;
144
+
145
+ const summary = document.createElement('summary');
146
+ summary.className = 'cm-json-node-summary';
147
+
148
+ if (key !== null) {
149
+ const keyEl = document.createElement('span');
150
+ keyEl.className = 'cm-json-key';
151
+ keyEl.textContent = String(key);
152
+ summary.appendChild(keyEl);
153
+ }
154
+
155
+ const meta = document.createElement('span');
156
+ meta.className = 'cm-json-meta';
157
+ meta.textContent = summarizeJson(value);
158
+ summary.appendChild(meta);
159
+
160
+ const preview = document.createElement('span');
161
+ preview.className = 'cm-json-preview';
162
+ preview.textContent = previewJsonValue(value);
163
+ summary.appendChild(preview);
164
+
165
+ details.appendChild(summary);
166
+
167
+ const children = document.createElement('div');
168
+ children.className = 'cm-json-children';
169
+
170
+ const entries = Array.isArray(value)
171
+ ? value.map((v, i) => [`[${i}]`, v])
172
+ : Object.entries(value);
173
+
174
+ if (entries.length === 0) {
175
+ const empty = document.createElement('div');
176
+ empty.className = 'cm-json-empty';
177
+ empty.textContent = Array.isArray(value) ? '[]' : '{}';
178
+ children.appendChild(empty);
179
+ } else {
180
+ for (const [childKey, childValue] of entries) {
181
+ children.appendChild(buildJsonTreeNode(childKey, childValue, depth + 1));
182
+ }
183
+ }
184
+
185
+ details.appendChild(children);
186
+ return details;
187
+ }
188
+
189
+ function stripCssComments(css) {
190
+ return String(css ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
191
+ }
192
+
193
+ function parseCssSelectors(css) {
194
+ const cleaned = stripCssComments(css);
195
+ const selectors = [];
196
+ let ruleCount = 0;
197
+ const ruleRegex = /([^{}]+)\{/g;
198
+ let match;
199
+
200
+ while ((match = ruleRegex.exec(cleaned)) !== null) {
201
+ const selectorGroup = match[1].trim();
202
+ if (!selectorGroup || selectorGroup.startsWith('@')) continue;
203
+
204
+ let addedInRule = 0;
205
+ for (const selector of selectorGroup.split(',')) {
206
+ const trimmed = selector.trim();
207
+ if (!trimmed) continue;
208
+ if (trimmed === 'from' || trimmed === 'to' || /^\d+%$/.test(trimmed)) continue;
209
+ selectors.push(trimmed);
210
+ addedInRule++;
211
+ }
212
+ if (addedInRule > 0) ruleCount++;
213
+ }
214
+
215
+ return { selectors, ruleCount };
216
+ }
217
+
218
+ function analyzeCssAgainstDocument(css, scope = 'all') {
219
+ return analyzeCssAgainstRoots(css, getCssQueryRoots(scope), scope);
220
+ }
221
+
222
+ function getCssQueryRoots(scope = 'all') {
223
+ const roots = [];
224
+ if (scope === 'all' || scope === 'main') {
225
+ roots.push(document);
226
+ }
227
+ try {
228
+ const artifactIframe = document.getElementById('artifact-iframe');
229
+ const artifactDoc = artifactIframe?.contentDocument || artifactIframe?.contentWindow?.document;
230
+ if (artifactDoc && (scope === 'all' || scope === 'artifact')) {
231
+ roots.push(artifactDoc);
232
+ }
233
+ } catch {
234
+ // Cross-origin or unavailable iframe; ignore.
235
+ }
236
+ return roots;
237
+ }
238
+
239
+ function analyzeCssAgainstRoots(css, queryRoots, scope = 'all') {
240
+ const { selectors, ruleCount } = parseCssSelectors(css);
241
+ const uniqueSelectors = Array.from(new Set(selectors));
242
+ const maxSelectors = 24;
243
+ const visibleSelectors = uniqueSelectors.slice(0, maxSelectors);
244
+
245
+ const selectorMatches = visibleSelectors.map((selector) => {
246
+ let count = 0;
247
+ try {
248
+ for (const root of queryRoots) {
249
+ count += root.querySelectorAll(selector).length;
250
+ }
251
+ return { selector, count, valid: true };
252
+ } catch {
253
+ return { selector, count: 0, valid: false };
254
+ }
255
+ });
256
+
257
+ const matchedSelectors = selectorMatches.filter((s) => s.valid && s.count > 0).length;
258
+ const totalMatches = selectorMatches
259
+ .filter((s) => s.valid)
260
+ .reduce((acc, s) => acc + s.count, 0);
261
+
262
+ return {
263
+ ruleCount,
264
+ totalSelectors: uniqueSelectors.length,
265
+ selectorMatches,
266
+ matchedSelectors,
267
+ totalMatches,
268
+ scope,
269
+ rootsReady: queryRoots.length > 0,
270
+ truncated: uniqueSelectors.length > maxSelectors,
271
+ };
272
+ }
273
+
58
274
  // =============================================================================
59
275
  // Height Cache for Stable Layout (prevents jitter when editing output blocks)
60
276
  // =============================================================================
@@ -330,8 +546,7 @@ class HtmlOutputWidget extends WidgetType {
330
546
  }
331
547
 
332
548
  /**
333
- * Widget for rendering CSS output with visual preview.
334
- * Injects CSS into a scoped container and shows a preview.
549
+ * Widget for rendering CSS output as compact impact summary.
335
550
  */
336
551
  class CssOutputWidget extends WidgetType {
337
552
  /**
@@ -340,16 +555,21 @@ class CssOutputWidget extends WidgetType {
340
555
  * @param {number} blockStart - Document position where this output block starts
341
556
  * @param {string|null} execId - Execution ID for this output block
342
557
  */
343
- constructor(content, hidden = false, blockStart = 0, execId = null) {
558
+ constructor(content, hidden = false, blockStart = 0, execId = null, targetScope = 'all') {
344
559
  super();
345
560
  this.content = content;
346
561
  this.hidden = hidden;
347
562
  this.blockStart = blockStart;
348
563
  this.execId = execId;
564
+ this.targetScope = targetScope;
349
565
  }
350
566
 
351
567
  eq(other) {
352
- return other.content === this.content && other.hidden === this.hidden && other.blockStart === this.blockStart && other.execId === this.execId;
568
+ return other.content === this.content &&
569
+ other.hidden === this.hidden &&
570
+ other.blockStart === this.blockStart &&
571
+ other.execId === this.execId &&
572
+ other.targetScope === this.targetScope;
353
573
  }
354
574
 
355
575
  toDOM() {
@@ -360,81 +580,249 @@ class CssOutputWidget extends WidgetType {
360
580
  container.dataset.execId = this.execId;
361
581
  }
362
582
 
363
- // Generate unique scope class
364
- const scopeClass = `mrmd-css-scope-${this.execId || Date.now()}`.replace(/[^a-z0-9-]/gi, '-');
365
-
366
- // Create style element with scoped CSS
367
- const style = document.createElement('style');
368
- style.dataset.mrmdCssOutput = this.execId || '';
369
-
370
- // Scope all CSS selectors to prevent leaking into the main document
371
- // This is a simplified scoping - prepends .scopeClass to each selector
372
- const scopedCss = this.content.replace(
373
- /([^{}]+)\{/g,
374
- (match, selectors) => {
375
- const scoped = selectors
376
- .split(',')
377
- .map(sel => {
378
- const trimmed = sel.trim();
379
- // Don't scope @rules, keyframe percentages, or empty selectors
380
- if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith('from') ||
381
- trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
382
- return trimmed;
383
- }
384
- // Replace :root with the scope class
385
- if (trimmed === ':root' || trimmed === 'html' || trimmed === 'body') {
386
- return `.${scopeClass}`;
387
- }
388
- return `.${scopeClass} ${trimmed}`;
389
- })
390
- .join(', ');
391
- return `${scoped} {`;
392
- }
393
- );
394
- style.textContent = scopedCss;
395
-
396
- // Inject the style into document head
397
- document.head.appendChild(style);
398
-
399
- // Create preview container
400
- const preview = document.createElement('div');
401
- preview.className = `cm-css-preview ${scopeClass}`;
402
- preview.innerHTML = `
403
- <div class="cm-css-preview-header">
404
- <span class="cm-css-preview-badge">CSS Applied</span>
405
- <span class="cm-css-preview-info">${this.content.split('{').length - 1} rules</span>
406
- </div>
407
- <div class="cm-css-preview-demo">
408
- <p>Preview text with <strong>bold</strong> and <em>italic</em></p>
409
- <button>Button</button>
410
- <a href="#">Link</a>
411
- <div class="box">Box element</div>
412
- </div>
583
+ const header = document.createElement('div');
584
+ header.className = 'cm-css-header';
585
+ const badge = document.createElement('span');
586
+ badge.className = 'cm-css-badge';
587
+ badge.textContent = 'CSS';
588
+ const statsEl = document.createElement('span');
589
+ statsEl.className = 'cm-css-stats';
590
+ const totalTargetsEl = document.createElement('span');
591
+ totalTargetsEl.className = 'cm-css-total-targets';
592
+ header.appendChild(badge);
593
+ header.appendChild(statsEl);
594
+ header.appendChild(totalTargetsEl);
595
+ container.appendChild(header);
596
+
597
+ const selectorList = document.createElement('div');
598
+ selectorList.className = 'cm-css-chip-list';
599
+ container.appendChild(selectorList);
600
+
601
+ const note = document.createElement('div');
602
+ note.className = 'cm-css-note';
603
+ container.appendChild(note);
604
+
605
+ const sourceDetails = document.createElement('details');
606
+ sourceDetails.className = 'cm-css-source';
607
+ sourceDetails.innerHTML = `
608
+ <summary>Show CSS</summary>
609
+ <pre class="cm-css-source-code">${escapeHtml(this.content)}</pre>
413
610
  `;
611
+ container.appendChild(sourceDetails);
612
+
613
+ const renderAnalysis = (analysis) => {
614
+ const statsText = analysis.ruleCount > 0
615
+ ? `${analysis.ruleCount} rule${analysis.ruleCount === 1 ? '' : 's'} · ${analysis.matchedSelectors}/${analysis.totalSelectors} selectors match`
616
+ : 'No parseable CSS rules detected';
617
+ statsEl.textContent = statsText;
618
+ totalTargetsEl.textContent = `${analysis.totalMatches} target${analysis.totalMatches === 1 ? '' : 's'}`;
619
+
620
+ selectorList.innerHTML = '';
621
+ if (analysis.selectorMatches.length === 0) {
622
+ const empty = document.createElement('div');
623
+ empty.className = 'cm-css-note';
624
+ empty.textContent = 'Run a CSS cell to see selector impact.';
625
+ selectorList.appendChild(empty);
626
+ } else {
627
+ for (const item of analysis.selectorMatches) {
628
+ const chip = document.createElement('div');
629
+ const stateClass = item.valid ? (item.count > 0 ? 'cm-css-chip-match' : 'cm-css-chip-nomatch') : 'cm-css-chip-invalid';
630
+ chip.className = `cm-css-chip ${stateClass}`;
631
+ chip.title = item.valid
632
+ ? `${item.count} match${item.count === 1 ? '' : 'es'} in document/artifact`
633
+ : 'Invalid selector';
634
+ chip.innerHTML = `
635
+ <code class="cm-css-chip-selector">${escapeHtml(item.selector)}</code>
636
+ <span class="cm-css-chip-count">${item.valid ? item.count : '!'}</span>
637
+ `;
638
+ selectorList.appendChild(chip);
639
+ }
640
+ }
641
+
642
+ if (analysis.truncated) {
643
+ note.textContent = `Showing first ${analysis.selectorMatches.length} selectors.`;
644
+ note.style.display = '';
645
+ } else if (analysis.scope === 'artifact' && !analysis.rootsReady) {
646
+ note.textContent = 'Artifact preview is not ready yet.';
647
+ note.style.display = '';
648
+ } else {
649
+ note.textContent = '';
650
+ note.style.display = 'none';
651
+ }
652
+ };
414
653
 
415
- container.appendChild(preview);
654
+ let refreshPending = false;
655
+ const queueRefresh = () => {
656
+ if (refreshPending) return;
657
+ refreshPending = true;
658
+ requestAnimationFrame(() => {
659
+ refreshPending = false;
660
+ if (!container.isConnected) return;
661
+ renderAnalysis(analyzeCssAgainstDocument(this.content, this.targetScope));
662
+ });
663
+ };
416
664
 
417
- // Clean up style when widget is destroyed
418
- const observer = new MutationObserver((mutations) => {
419
- for (const mutation of mutations) {
420
- for (const node of mutation.removedNodes) {
421
- if (node === container || node.contains?.(container)) {
422
- style.remove();
423
- observer.disconnect();
424
- return;
425
- }
665
+ // Keep counts live as HTML/artifact DOM updates.
666
+ const rootObserver = new MutationObserver((mutations) => {
667
+ let relevant = false;
668
+ for (const m of mutations) {
669
+ const target = /** @type {Node|null} */ (m.target);
670
+ if (!target) {
671
+ relevant = true;
672
+ break;
673
+ }
674
+ if (target === container || container.contains(target)) {
675
+ continue;
676
+ }
677
+ if (!container.contains(target)) {
678
+ relevant = true;
679
+ break;
426
680
  }
427
681
  }
682
+ if (relevant) queueRefresh();
428
683
  });
684
+ if (document.documentElement && (this.targetScope === 'all' || this.targetScope === 'main')) {
685
+ rootObserver.observe(document.documentElement, { childList: true, subtree: true });
686
+ }
429
687
 
430
- // Start observing when attached
431
- requestAnimationFrame(() => {
432
- if (container.parentElement) {
433
- observer.observe(container.parentElement.parentElement || document.body, {
434
- childList: true,
435
- subtree: true
436
- });
688
+ let artifactObserver = null;
689
+ let artifactIframe = null;
690
+ let artifactLoadHandler = null;
691
+ try {
692
+ artifactIframe = document.getElementById('artifact-iframe');
693
+ const bindArtifactObserver = () => {
694
+ artifactObserver?.disconnect();
695
+ const artifactDoc = artifactIframe?.contentDocument || artifactIframe?.contentWindow?.document;
696
+ const artifactRoot = artifactDoc?.documentElement || artifactDoc?.body;
697
+ if (!artifactRoot) return;
698
+ artifactObserver = new MutationObserver(() => queueRefresh());
699
+ artifactObserver.observe(artifactRoot, { childList: true, subtree: true, characterData: true });
700
+ };
701
+ if (artifactIframe) {
702
+ artifactLoadHandler = () => {
703
+ bindArtifactObserver();
704
+ queueRefresh();
705
+ };
706
+ artifactIframe.addEventListener('load', artifactLoadHandler);
707
+ }
708
+ if (this.targetScope === 'all' || this.targetScope === 'artifact') {
709
+ bindArtifactObserver();
710
+ }
711
+ } catch {
712
+ // Ignore inaccessible artifact iframe.
713
+ }
714
+
715
+ const removalObserver = new MutationObserver(() => {
716
+ if (container.isConnected) return;
717
+ rootObserver.disconnect();
718
+ artifactObserver?.disconnect();
719
+ if (artifactIframe && artifactLoadHandler) {
720
+ artifactIframe.removeEventListener('load', artifactLoadHandler);
437
721
  }
722
+ removalObserver.disconnect();
723
+ });
724
+ if (document.body) {
725
+ removalObserver.observe(document.body, { childList: true, subtree: true });
726
+ }
727
+
728
+ renderAnalysis(analyzeCssAgainstDocument(this.content, this.targetScope));
729
+ // Artifact updates can race cell rendering; refresh shortly after mount.
730
+ setTimeout(queueRefresh, 120);
731
+ setTimeout(queueRefresh, 500);
732
+
733
+ return container;
734
+ }
735
+
736
+ ignoreEvent() {
737
+ return false;
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Widget for rendering JSON output with an expandable tree.
743
+ */
744
+ class JsonOutputWidget extends WidgetType {
745
+ /**
746
+ * @param {string} content - JSON content
747
+ * @param {boolean} hidden - Whether widget should be hidden
748
+ * @param {number} blockStart - Document position where this output block starts
749
+ * @param {string|null} execId - Execution ID for this output block
750
+ */
751
+ constructor(content, hidden = false, blockStart = 0, execId = null) {
752
+ super();
753
+ this.content = content;
754
+ this.hidden = hidden;
755
+ this.blockStart = blockStart;
756
+ this.execId = execId;
757
+ }
758
+
759
+ eq(other) {
760
+ return other.content === this.content &&
761
+ other.hidden === this.hidden &&
762
+ other.blockStart === this.blockStart &&
763
+ other.execId === this.execId;
764
+ }
765
+
766
+ toDOM() {
767
+ const container = document.createElement('div');
768
+ container.className = 'cm-json-output-widget' + (this.hidden ? ' cm-output-widget-hidden' : '');
769
+ container.dataset.outputBlockStart = String(this.blockStart);
770
+ if (this.execId) {
771
+ container.dataset.execId = this.execId;
772
+ }
773
+
774
+ const parsed = tryParseJsonOutput(this.content);
775
+ if (parsed === null) {
776
+ container.innerHTML = `<pre class="cm-json-fallback">${escapeHtml(this.content)}</pre>`;
777
+ return container;
778
+ }
779
+
780
+ const header = document.createElement('div');
781
+ header.className = 'cm-json-header';
782
+ header.innerHTML = `
783
+ <span class="cm-json-badge">JSON</span>
784
+ <span class="cm-json-summary">${escapeHtml(summarizeJson(parsed))}</span>
785
+ <div class="cm-json-actions">
786
+ <button type="button" class="cm-json-action" data-action="expand">Expand</button>
787
+ <button type="button" class="cm-json-action" data-action="collapse">Collapse</button>
788
+ <button type="button" class="cm-json-action" data-action="copy">Copy</button>
789
+ </div>
790
+ `;
791
+ container.appendChild(header);
792
+
793
+ const tree = document.createElement('div');
794
+ tree.className = 'cm-json-tree';
795
+ tree.appendChild(buildJsonTreeNode(null, parsed));
796
+ container.appendChild(tree);
797
+
798
+ const actionButtons = header.querySelectorAll('.cm-json-action');
799
+ actionButtons.forEach((btn) => {
800
+ btn.addEventListener('click', (e) => {
801
+ e.preventDefault();
802
+ e.stopPropagation();
803
+ const action = btn.getAttribute('data-action');
804
+ if (action === 'expand') {
805
+ tree.querySelectorAll('details').forEach((d) => { d.open = true; });
806
+ } else if (action === 'collapse') {
807
+ let first = true;
808
+ tree.querySelectorAll('details').forEach((d) => {
809
+ if (first) {
810
+ d.open = true;
811
+ first = false;
812
+ } else {
813
+ d.open = false;
814
+ }
815
+ });
816
+ } else if (action === 'copy') {
817
+ navigator.clipboard.writeText(JSON.stringify(parsed, null, 2)).then(() => {
818
+ const previous = btn.textContent;
819
+ btn.textContent = 'Copied';
820
+ setTimeout(() => {
821
+ btn.textContent = previous;
822
+ }, 1200);
823
+ });
824
+ }
825
+ });
438
826
  });
439
827
 
440
828
  return container;
@@ -606,16 +994,26 @@ function buildDecorations(view, awarenessSystem) {
606
994
  const blockStart = match.index;
607
995
  const blockEnd = blockStart + match[0].length;
608
996
 
609
- // Parse execId and output type (format: execId:type, e.g., "exec-123:html")
997
+ // Parse execId + output metadata from fence line.
998
+ // Supported formats:
999
+ // - output:execId
1000
+ // - output:execId:css
1001
+ // - output:execId:css:artifact
1002
+ // - output:execId:css:main
610
1003
  let execId = rawExecId;
611
- let outputType = null; // 'html', 'css', or null for regular output
612
- if (rawExecId && rawExecId.includes(':')) {
1004
+ let outputType = null; // 'html', 'css', 'json', or null for regular output
1005
+ let cssTargetScope = 'all'; // 'all' | 'main' | 'artifact'
1006
+ if (rawExecId) {
613
1007
  const parts = rawExecId.split(':');
614
- // Check if last part is a type indicator
615
- const lastPart = parts[parts.length - 1].toLowerCase();
616
- if (['html', 'htm', 'css', 'style'].includes(lastPart)) {
617
- outputType = lastPart === 'htm' || lastPart === 'html' ? 'html' : 'css';
618
- execId = parts.slice(0, -1).join(':');
1008
+ execId = parts[0] || rawExecId;
1009
+ const tags = parts.slice(1).map(p => p.toLowerCase());
1010
+ if (tags.includes('html') || tags.includes('htm')) outputType = 'html';
1011
+ else if (tags.includes('json')) outputType = 'json';
1012
+ else if (tags.includes('css') || tags.includes('style') || tags.includes('stylesheet')) outputType = 'css';
1013
+
1014
+ if (outputType === 'css') {
1015
+ if (tags.includes('artifact')) cssTargetScope = 'artifact';
1016
+ else if (tags.includes('main')) cssTargetScope = 'main';
619
1017
  }
620
1018
  }
621
1019
 
@@ -648,6 +1046,10 @@ function buildDecorations(view, awarenessSystem) {
648
1046
  // Check if output is empty (just whitespace)
649
1047
  const trimmedContent = content.trim();
650
1048
  const isEmpty = trimmedContent.length === 0;
1049
+ const parsedJson = !isEmpty && (outputType === null || outputType === 'json')
1050
+ ? tryParseJsonOutput(trimmedContent)
1051
+ : null;
1052
+ const shouldRenderJson = parsedJson !== null;
651
1053
 
652
1054
  if (anyCollaboratorFocused) {
653
1055
  // EDITING MODE: Keep ANSI colors rendered, but make escape sequences
@@ -711,15 +1113,25 @@ function buildDecorations(view, awarenessSystem) {
711
1113
  // VIEWING MODE: Render output visually
712
1114
  // For HTML/CSS, use rich widgets; for regular output, use ANSI styling
713
1115
 
714
- // Style the fence lines (opening and closing fences) - always hidden
1116
+ // Style the fence lines (opening and closing fences).
1117
+ // Rich output widgets (HTML/CSS, including Mermaid rendered as HTML) are
1118
+ // attached to the opening fence line, so that line must remain unclipped.
1119
+ const richOutput = outputType === 'html' || outputType === 'css' || shouldRenderJson;
1120
+ const startFenceClass = richOutput
1121
+ ? 'cm-output-fence-line cm-output-fence-start cm-output-fence-rich-start'
1122
+ : 'cm-output-fence-line cm-output-fence-start';
1123
+ const endFenceClass = richOutput
1124
+ ? 'cm-output-fence-line cm-output-fence-end cm-output-fence-rich-end'
1125
+ : 'cm-output-fence-line cm-output-fence-end';
1126
+
715
1127
  decorations.push(
716
1128
  Decoration.line({
717
- class: 'cm-output-fence-line cm-output-fence-start',
1129
+ class: startFenceClass,
718
1130
  }).range(startLine.from)
719
1131
  );
720
1132
  decorations.push(
721
1133
  Decoration.line({
722
- class: 'cm-output-fence-line cm-output-fence-end',
1134
+ class: endFenceClass,
723
1135
  }).range(endLine.from)
724
1136
  );
725
1137
 
@@ -742,6 +1154,23 @@ function buildDecorations(view, awarenessSystem) {
742
1154
  side: 1,
743
1155
  }).range(startLine.to)
744
1156
  );
1157
+ } else if (shouldRenderJson) {
1158
+ // JSON OUTPUT: Hide content lines and add expandable JSON tree widget
1159
+ for (let i = startLine.number + 1; i < endLine.number; i++) {
1160
+ const line = doc.line(i);
1161
+ decorations.push(
1162
+ Decoration.line({
1163
+ class: 'cm-output-content-line cm-rich-output-hidden',
1164
+ }).range(line.from)
1165
+ );
1166
+ }
1167
+
1168
+ decorations.push(
1169
+ Decoration.widget({
1170
+ widget: new JsonOutputWidget(trimmedContent, false, blockStart, execId),
1171
+ side: 1,
1172
+ }).range(startLine.to)
1173
+ );
745
1174
  } else if (outputType === 'css' && !isEmpty) {
746
1175
  // CSS OUTPUT: Hide content lines and add CSS widget
747
1176
  for (let i = startLine.number + 1; i < endLine.number; i++) {
@@ -756,7 +1185,7 @@ function buildDecorations(view, awarenessSystem) {
756
1185
  // Add CSS preview widget
757
1186
  decorations.push(
758
1187
  Decoration.widget({
759
- widget: new CssOutputWidget(trimmedContent, false, blockStart, execId),
1188
+ widget: new CssOutputWidget(trimmedContent, false, blockStart, execId, cssTargetScope),
760
1189
  side: 1,
761
1190
  }).range(startLine.to)
762
1191
  );
@@ -1052,6 +1481,14 @@ export const outputWidgetStyles = `
1052
1481
  margin: 0 !important;
1053
1482
  }
1054
1483
 
1484
+ /* Rich output widgets (HTML/CSS/Mermaid->HTML) are mounted on the opening
1485
+ * fence line. Keep that line unclipped so the inline widget can paint. */
1486
+ .cm-output-fence-rich-start {
1487
+ height: auto !important;
1488
+ overflow: visible !important;
1489
+ line-height: 1 !important;
1490
+ }
1491
+
1055
1492
  /* Hide CodeMirror's special character rendering (escape symbols) in output blocks */
1056
1493
  /* CM renders control chars like ESC as visible ␛ symbols - we hide these since ANSI is rendered via colors */
1057
1494
  .cm-output-content-line .cm-specialChar {
@@ -1416,80 +1853,284 @@ export const outputWidgetStyles = `
1416
1853
  border-radius: 4px;
1417
1854
  }
1418
1855
 
1419
- /* CSS Output Widget - shows CSS with preview */
1856
+ /* CSS Output Widget - compact selector impact summary */
1420
1857
  .cm-css-output-widget {
1421
1858
  position: relative;
1422
1859
  margin: 8px 0;
1423
1860
  padding: 0;
1424
1861
  background: var(--widget-surface, rgba(0, 0, 0, 0.35));
1425
1862
  border-radius: var(--widget-border-radius, 6px);
1863
+ border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.08));
1864
+ border-left: 2px solid var(--widget-accent-css, #64b5f6);
1865
+ }
1866
+
1867
+ .cm-css-header {
1868
+ display: flex;
1869
+ align-items: center;
1870
+ gap: 8px;
1871
+ padding: 8px 10px;
1872
+ border-bottom: 1px solid var(--widget-border, rgba(255, 255, 255, 0.08));
1873
+ background: var(--widget-surface-elevated, rgba(255, 255, 255, 0.02));
1874
+ }
1875
+
1876
+ .cm-css-badge {
1877
+ font-size: 10px;
1878
+ color: var(--widget-accent-css, #64b5f6);
1879
+ background: color-mix(in srgb, var(--widget-accent-css, #64b5f6) 16%, transparent);
1880
+ border: 1px solid color-mix(in srgb, var(--widget-accent-css, #64b5f6) 35%, transparent);
1881
+ padding: 2px 6px;
1882
+ border-radius: 3px;
1883
+ font-family: var(--widget-font-mono, monospace);
1884
+ text-transform: uppercase;
1885
+ letter-spacing: 0.4px;
1886
+ }
1887
+
1888
+ .cm-css-stats {
1889
+ font-size: 11px;
1890
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.72));
1891
+ }
1892
+
1893
+ .cm-css-total-targets {
1894
+ margin-left: auto;
1895
+ font-size: 11px;
1896
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.55));
1897
+ font-family: var(--widget-font-mono, monospace);
1898
+ }
1899
+
1900
+ .cm-css-chip-list {
1901
+ display: flex;
1902
+ flex-wrap: wrap;
1903
+ gap: 6px;
1904
+ padding: 8px 10px 4px 10px;
1905
+ }
1906
+
1907
+ .cm-css-chip {
1908
+ display: inline-flex;
1909
+ align-items: center;
1910
+ gap: 6px;
1911
+ max-width: 100%;
1912
+ border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.14));
1913
+ border-radius: 999px;
1914
+ padding: 2px 8px;
1915
+ font-size: 11px;
1916
+ background: var(--widget-surface-inset, rgba(255, 255, 255, 0.03));
1917
+ }
1918
+
1919
+ .cm-css-chip-selector {
1920
+ color: var(--widget-text, #e0e0e0);
1921
+ font-family: var(--widget-font-mono, monospace);
1922
+ white-space: nowrap;
1426
1923
  overflow: hidden;
1427
- border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.1));
1428
- border-left: 3px solid var(--widget-accent-css, #64b5f6);
1924
+ text-overflow: ellipsis;
1925
+ max-width: 360px;
1429
1926
  }
1430
1927
 
1431
- .cm-css-preview {
1432
- padding: var(--widget-padding-y, 8px) var(--widget-padding-x, 12px);
1928
+ .cm-css-chip-count {
1929
+ font-family: var(--widget-font-mono, monospace);
1930
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.7));
1931
+ }
1932
+
1933
+ .cm-css-chip-match {
1934
+ border-color: color-mix(in srgb, #22c55e 45%, transparent);
1935
+ background: color-mix(in srgb, #22c55e 12%, transparent);
1936
+ }
1937
+
1938
+ .cm-css-chip-nomatch {
1939
+ border-color: color-mix(in srgb, #94a3b8 35%, transparent);
1433
1940
  }
1434
1941
 
1435
- .cm-css-preview-header {
1942
+ .cm-css-chip-invalid {
1943
+ border-color: color-mix(in srgb, #ef4444 45%, transparent);
1944
+ background: color-mix(in srgb, #ef4444 12%, transparent);
1945
+ }
1946
+
1947
+ .cm-css-note {
1948
+ padding: 2px 10px 8px 10px;
1949
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.55));
1950
+ font-size: 11px;
1951
+ }
1952
+
1953
+ .cm-css-source {
1954
+ margin: 0;
1955
+ border-top: 1px solid var(--widget-border, rgba(255, 255, 255, 0.08));
1956
+ }
1957
+
1958
+ .cm-css-source > summary {
1959
+ cursor: pointer;
1960
+ padding: 7px 10px;
1961
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.75));
1962
+ font-size: 11px;
1963
+ font-family: var(--widget-font-mono, monospace);
1964
+ user-select: none;
1965
+ }
1966
+
1967
+ .cm-css-source > summary:hover {
1968
+ color: var(--widget-text, #e0e0e0);
1969
+ }
1970
+
1971
+ .cm-css-source-code {
1972
+ margin: 0;
1973
+ padding: 0 10px 10px 10px;
1974
+ white-space: pre-wrap;
1975
+ word-break: break-word;
1976
+ color: var(--widget-text, #e0e0e0);
1977
+ font-size: 12px;
1978
+ font-family: var(--widget-font-mono, monospace);
1979
+ }
1980
+
1981
+ /* JSON Output Widget - expandable tree view */
1982
+ .cm-json-output-widget {
1983
+ position: relative;
1984
+ margin: 8px 0;
1985
+ background: var(--widget-surface, rgba(0, 0, 0, 0.35));
1986
+ border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.1));
1987
+ border-left: 3px solid var(--widget-accent-json, #8cc0ff);
1988
+ border-radius: var(--widget-border-radius, 6px);
1989
+ overflow: hidden;
1990
+ }
1991
+
1992
+ .cm-json-header {
1436
1993
  display: flex;
1437
1994
  align-items: center;
1438
1995
  gap: 8px;
1439
- margin-bottom: 8px;
1440
- padding-bottom: 8px;
1441
- border-bottom: 1px solid var(--widget-border, rgba(255, 255, 255, 0.1));
1996
+ padding: 8px 10px;
1997
+ border-bottom: 1px solid var(--widget-border, rgba(255, 255, 255, 0.08));
1998
+ background: var(--widget-surface-elevated, rgba(255, 255, 255, 0.02));
1442
1999
  }
1443
2000
 
1444
- .cm-css-preview-badge {
2001
+ .cm-json-badge {
1445
2002
  font-size: 10px;
1446
- color: var(--widget-accent-css, #64b5f6);
1447
- background: rgba(100, 181, 246, 0.15);
1448
- padding: 2px 8px;
2003
+ color: var(--widget-accent-json, #8cc0ff);
2004
+ background: color-mix(in srgb, var(--widget-accent-json, #8cc0ff) 16%, transparent);
2005
+ border: 1px solid color-mix(in srgb, var(--widget-accent-json, #8cc0ff) 35%, transparent);
1449
2006
  border-radius: 3px;
1450
- font-family: var(--widget-font-mono, monospace);
2007
+ padding: 2px 6px;
2008
+ letter-spacing: 0.4px;
1451
2009
  text-transform: uppercase;
1452
- letter-spacing: 0.5px;
2010
+ font-family: var(--widget-font-mono, monospace);
1453
2011
  }
1454
2012
 
1455
- .cm-css-preview-info {
2013
+ .cm-json-summary {
2014
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.65));
1456
2015
  font-size: 11px;
1457
- color: var(--widget-text-muted, rgba(255, 255, 255, 0.5));
1458
2016
  }
1459
2017
 
1460
- .cm-css-preview-demo {
1461
- padding: 12px;
1462
- background: white;
2018
+ .cm-json-actions {
2019
+ margin-left: auto;
2020
+ display: flex;
2021
+ gap: 6px;
2022
+ }
2023
+
2024
+ .cm-json-action {
2025
+ background: var(--widget-surface-inset, rgba(255, 255, 255, 0.04));
2026
+ border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.14));
2027
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.75));
1463
2028
  border-radius: 4px;
1464
- color: #333;
1465
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1466
- font-size: 14px;
2029
+ padding: 2px 7px;
2030
+ font-size: 11px;
2031
+ cursor: pointer;
2032
+ font-family: var(--widget-font-mono, monospace);
1467
2033
  }
1468
2034
 
1469
- .cm-css-preview-demo p {
1470
- margin: 0 0 8px 0;
2035
+ .cm-json-action:hover {
2036
+ background: var(--widget-surface-hover, rgba(255, 255, 255, 0.08));
2037
+ color: var(--widget-text, #e0e0e0);
1471
2038
  }
1472
2039
 
1473
- .cm-css-preview-demo button {
1474
- padding: 6px 12px;
1475
- margin-right: 8px;
1476
- border: 1px solid #ccc;
1477
- border-radius: 4px;
1478
- background: #f5f5f5;
2040
+ .cm-json-tree {
2041
+ padding: 8px 10px 10px 10px;
2042
+ font-family: var(--widget-font-mono, 'SF Mono', Monaco, monospace);
2043
+ font-size: 12px;
2044
+ color: var(--widget-text, #e0e0e0);
2045
+ max-height: 420px;
2046
+ overflow: auto;
2047
+ }
2048
+
2049
+ .cm-json-node {
2050
+ margin-left: 0;
2051
+ }
2052
+
2053
+ .cm-json-node summary {
2054
+ list-style: none;
2055
+ }
2056
+
2057
+ .cm-json-node summary::-webkit-details-marker {
2058
+ display: none;
2059
+ }
2060
+
2061
+ .cm-json-node-summary {
1479
2062
  cursor: pointer;
2063
+ display: flex;
2064
+ align-items: baseline;
2065
+ gap: 8px;
2066
+ padding: 2px 0;
2067
+ }
2068
+
2069
+ .cm-json-node-summary::before {
2070
+ content: '▶';
2071
+ font-size: 9px;
2072
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.5));
2073
+ margin-right: 2px;
2074
+ transform: translateY(-1px);
2075
+ }
2076
+
2077
+ .cm-json-node[open] > .cm-json-node-summary::before {
2078
+ content: '▼';
2079
+ }
2080
+
2081
+ .cm-json-node-leaf {
2082
+ display: flex;
2083
+ align-items: baseline;
2084
+ gap: 8px;
2085
+ padding: 2px 0;
2086
+ }
2087
+
2088
+ .cm-json-key {
2089
+ color: var(--widget-text-accent, #8cc0ff);
2090
+ min-width: 0;
2091
+ }
2092
+
2093
+ .cm-json-meta {
2094
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.5));
2095
+ font-size: 11px;
1480
2096
  }
1481
2097
 
1482
- .cm-css-preview-demo a {
1483
- color: #0066cc;
1484
- text-decoration: underline;
1485
- margin-right: 8px;
2098
+ .cm-json-preview {
2099
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.7));
2100
+ opacity: 0.9;
2101
+ overflow: hidden;
2102
+ text-overflow: ellipsis;
2103
+ white-space: nowrap;
2104
+ }
2105
+
2106
+ .cm-json-children {
2107
+ margin-left: 18px;
2108
+ border-left: 1px dotted color-mix(in srgb, var(--widget-border, rgba(255, 255, 255, 0.2)) 75%, transparent);
2109
+ padding-left: 10px;
2110
+ }
2111
+
2112
+ .cm-json-empty {
2113
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.45));
2114
+ font-style: italic;
2115
+ padding: 2px 0;
1486
2116
  }
1487
2117
 
1488
- .cm-css-preview-demo .box {
1489
- margin-top: 8px;
1490
- padding: 8px;
1491
- border: 1px dashed #ccc;
1492
- background: #fafafa;
2118
+ .cm-json-value {
2119
+ white-space: pre-wrap;
2120
+ word-break: break-word;
2121
+ }
2122
+
2123
+ .cm-json-type-string { color: #ce9178; }
2124
+ .cm-json-type-number { color: #b5cea8; }
2125
+ .cm-json-type-boolean { color: #569cd6; }
2126
+ .cm-json-type-null { color: #808080; }
2127
+
2128
+ .cm-json-fallback {
2129
+ margin: 0;
2130
+ padding: 10px;
2131
+ white-space: pre-wrap;
2132
+ word-break: break-word;
2133
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.72));
1493
2134
  }
1494
2135
 
1495
2136
  /* ANSI text styles */
@@ -1782,6 +2423,60 @@ ${ansiStyles}
1782
2423
  .cm-stdin-widget[data-password="true"] .cm-stdin-content {
1783
2424
  letter-spacing: 0.2em;
1784
2425
  }
2426
+
2427
+ /* ==========================================================================
2428
+ MOBILE RESPONSIVE
2429
+
2430
+ Output blocks need to be readable on narrow screens.
2431
+ Horizontal scroll for wide output, larger text for readability.
2432
+ ========================================================================== */
2433
+
2434
+ @media (max-width: 768px) {
2435
+ .cm-output-widget {
2436
+ font-size: max(var(--output-font-size, 0.7em), 12px);
2437
+ left: 0; /* No inset on mobile — use full width */
2438
+ right: 0;
2439
+ padding: 8px 12px;
2440
+ }
2441
+
2442
+ /* Output content lines: ensure they don't overflow */
2443
+ .cm-output-content-line {
2444
+ font-size: max(var(--output-font-size, 0.8em), 12px);
2445
+ }
2446
+
2447
+ /* Rich output (HTML renders, plots): full width */
2448
+ .cm-output-rich-widget {
2449
+ max-width: 100%;
2450
+ }
2451
+
2452
+ .cm-output-rich-widget iframe {
2453
+ max-width: 100%;
2454
+ }
2455
+
2456
+ /* Stdin widget: bigger input on mobile */
2457
+ .cm-stdin-widget .cm-stdin-input {
2458
+ font-size: 16px; /* Prevents iOS zoom */
2459
+ padding: 10px;
2460
+ }
2461
+
2462
+ .cm-stdin-widget .cm-stdin-prompt {
2463
+ font-size: 14px;
2464
+ }
2465
+ }
2466
+
2467
+ @media (pointer: coarse) {
2468
+ /* Output container: larger tap target for focus/interaction */
2469
+ .cm-output-widget {
2470
+ padding: 10px 14px;
2471
+ }
2472
+
2473
+ /* Collapsed output: easy to tap to expand */
2474
+ .cm-output-collapsed {
2475
+ min-height: 44px;
2476
+ display: flex;
2477
+ align-items: center;
2478
+ }
2479
+ }
1785
2480
  `;
1786
2481
 
1787
2482
  // #endregion STYLES