();
+ if (existingSeen == null) seenDeclKeys.set(cls, seen);
+ for (const decl of declsWithCondition) {
+ // JSON encoding (rather than a delimiter join) so values that contain
+ // the delimiter — e.g. `pseudoElement: '::before'` — cannot collide.
+ const dedupKey = JSON.stringify([
+ decl.property,
+ decl.value,
+ decl.important,
+ decl.condition ?? null,
+ decl.pseudoElement ?? null,
+ ]);
+ if (seen.has(dedupKey)) continue;
+ seen.add(dedupKey);
+ declList.push(decl);
}
for (const decl of decls) {
if (computed[decl.property] == null) {
diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx
index 8f5286808..744f8ad55 100644
--- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx
+++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx
@@ -25,6 +25,7 @@ import { subscribeToSelectionAndNavigation } from '../devtools/events.js';
import { evalInInspectedWindow } from '../devtools/api.js';
import { collectStylexDebugData } from '../inspected/collectStylexDebugData.js';
import { Button } from './components/Button';
+import { CopyMetadataButton } from './components/CopyMetadataButton';
import { DeclarationsList } from './components/DeclarationsList';
import { SourcesList } from './components/SourcesList';
import { Section } from './components/Section';
@@ -131,6 +132,7 @@ function Panel({
{tagName}
+ {!showEmptyState && }
diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js b/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js
new file mode 100644
index 000000000..7f16249b8
--- /dev/null
+++ b/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js
@@ -0,0 +1,91 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict
+ */
+
+'use strict';
+
+import * as React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { StylexDebugData } from '../../types.js';
+import { exportMetadata } from '../../utils/exportMetadata.js';
+import { Button } from './Button';
+
+declare const navigator: any;
+declare const document: any;
+
+// Clipboard access can be flaky inside a DevTools panel context, so fall back
+// to a hidden-textarea + execCommand copy when the async clipboard API is
+// unavailable or rejects (e.g. the panel is not focused).
+async function copyText(text: string): Promise {
+ try {
+ if (
+ navigator.clipboard &&
+ typeof navigator.clipboard.writeText === 'function'
+ ) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {
+ // fall through to the execCommand path
+ }
+ try {
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(textarea);
+ return ok;
+ } catch {
+ return false;
+ }
+}
+
+export function CopyMetadataButton({
+ data,
+}: {
+ data: StylexDebugData,
+}): React.Node {
+ const [label, setLabel] = useState('Copy metadata');
+ const timeoutRef = useRef(null);
+
+ // The panel re-mounts on refresh (its `key` changes), so clear any pending
+ // label-reset timeout on unmount to avoid a state update after unmount.
+ useEffect(
+ () => () => {
+ if (timeoutRef.current != null) {
+ clearTimeout(timeoutRef.current);
+ }
+ },
+ [],
+ );
+
+ const handleClick = useCallback(async () => {
+ if (timeoutRef.current != null) {
+ clearTimeout(timeoutRef.current);
+ }
+ const ok = await copyText(exportMetadata(data));
+ setLabel(ok ? 'Copied!' : 'Copy failed');
+ timeoutRef.current = setTimeout(() => {
+ setLabel('Copy metadata');
+ timeoutRef.current = null;
+ }, 1500);
+ }, [data]);
+
+ return (
+
+ );
+}
diff --git a/packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js b/packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js
new file mode 100644
index 000000000..e4a7fe45e
--- /dev/null
+++ b/packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js
@@ -0,0 +1,129 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict
+ */
+
+'use strict';
+
+import type {
+ StylexDebugData,
+ StylexDeclaration,
+ StylexOverride,
+} from '../types.js';
+
+// Serializes the StyleX inspection of the selected element into compact
+// markdown. The value here is the StyleX-specific metadata that is hard to
+// reconstruct from a screenshot: the resolved declarations, the condition
+// under which each applies, the atomic class that produced it, and the
+// source file:line that authored it.
+
+function conditionLabel(decl: StylexDeclaration): string {
+ if (decl.conditions != null && decl.conditions.length > 0) {
+ return decl.conditions.join(', ');
+ }
+ if (decl.condition != null && decl.condition !== 'default') {
+ return decl.condition;
+ }
+ return 'default';
+}
+
+function formatValue(value: string, important: boolean): string {
+ return important ? `${value} !important` : value;
+}
+
+function renderSources(data: StylexDebugData): Array {
+ if (data.sources.length === 0) return [];
+ const lines = ['## Sources'];
+ for (const source of data.sources) {
+ lines.push(
+ `- ${source.line != null ? `${source.file}:${source.line}` : source.file}`,
+ );
+ }
+ return lines;
+}
+
+function renderAppliedStyles(data: StylexDebugData): Array {
+ // section (pseudo-element, '' for the base element) -> property -> entries
+ const sections: Map<
+ string,
+ Map>,
+ > = new Map();
+ for (const cls of data.applied.classes) {
+ for (const decl of cls.declarations) {
+ const sectionKey = decl.pseudoElement ?? '';
+ let properties = sections.get(sectionKey);
+ if (properties == null) {
+ properties = new Map();
+ sections.set(sectionKey, properties);
+ }
+ const bucket = properties.get(decl.property);
+ if (bucket == null) {
+ properties.set(decl.property, [decl]);
+ } else {
+ bucket.push(decl);
+ }
+ }
+ }
+ if (sections.size === 0) return [];
+
+ // Render the base element first, then any pseudo-element sections.
+ const orderedKeys = [
+ ...(sections.has('') ? [''] : []),
+ ...[...sections.keys()].filter((key) => key !== ''),
+ ];
+
+ const lines = ['## Applied styles'];
+ for (const sectionKey of orderedKeys) {
+ const properties = sections.get(sectionKey);
+ if (properties == null) continue;
+ if (sectionKey !== '') {
+ lines.push(`### ${sectionKey}`);
+ }
+ for (const [property, entries] of properties) {
+ if (entries.length === 1) {
+ const entry = entries[0];
+ const cond = conditionLabel(entry);
+ const condText = cond === 'default' ? '' : ` [${cond}]`;
+ lines.push(
+ `- ${property}: ${formatValue(entry.value, entry.important)}${condText} (.${entry.className ?? '?'})`,
+ );
+ continue;
+ }
+ lines.push(`- ${property}:`);
+ for (const entry of entries) {
+ lines.push(
+ ` - ${conditionLabel(entry)}: ${formatValue(entry.value, entry.important)} (.${entry.className ?? '?'})`,
+ );
+ }
+ }
+ }
+ return lines;
+}
+
+function renderOverrides(overrides: Array): Array {
+ if (overrides.length === 0) return [];
+ const lines = ['## Overrides (live, not yet in source)'];
+ for (const override of overrides) {
+ lines.push(
+ `- ${override.property}: ${formatValue(override.value, override.important)} (${override.kind})`,
+ );
+ }
+ return lines;
+}
+
+export function exportMetadata(data: StylexDebugData): string {
+ const blocks: Array> = [
+ [`# StyleX metadata — <${data.element.tagName}>`],
+ renderSources(data),
+ renderAppliedStyles(data),
+ renderOverrides(data.overrides),
+ ];
+ return blocks
+ .filter((block) => block.length > 0)
+ .map((block) => block.join('\n'))
+ .join('\n\n');
+}