// @vitest-environment jsdom

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { useLayoutEffect, useRef, useState } from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ANNOTATION_EVENT } from '../../src/components/PreviewDrawOverlay';

const { saveTemplateMock } = vi.hoisted(() => ({
  saveTemplateMock: vi.fn(),
}));

vi.mock('../../src/state/projects', async () => {
  const actual = await vi.importActual<typeof import('../../src/state/projects')>(
    '../../src/state/projects',
  );
  return {
    ...actual,
    saveTemplate: saveTemplateMock,
  };
});

import {
  CommentSidePanel,
  FileViewer,
  LiveArtifactViewer,
  LiveArtifactRefreshHistoryPanel,
  SvgViewer,
  applyInspectOverridesToSource,
  effectivePreviewScale,
  parseInspectOverridesFromSource,
  serializeInspectOverrides,
  updateInspectOverride,
} from '../../src/components/FileViewer';
import type { InspectOverrideMap } from '../../src/components/FileViewer';
import type { LiveArtifact, LiveArtifactWorkspaceEntry, PreviewComment, ProjectFile } from '../../src/types';
import { I18nProvider } from '../../src/i18n';
import type { Dict } from '../../src/i18n/types';
import { readExpandedIndexCss } from '../helpers/read-expanded-css';

afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
  Reflect.deleteProperty(navigator, 'clipboard');
});

function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
  return {
    name: 'asset.png',
    path: 'asset.png',
    type: 'file',
    size: 1024,
    mtime: 1710000000,
    kind: 'image',
    mime: 'image/png',
    ...overrides,
  };
}

function deferredResponse() {
  let resolve!: (value: Response) => void;
  const promise = new Promise<Response>((next) => {
    resolve = next;
  });
  return { promise, resolve };
}

function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
  return calls
    .map(([message]) => message)
    .filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
      if (typeof message !== 'object' || message === null) return false;
      const data = message as { type?: unknown; html?: unknown };
      return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
    });
}

describe('FileViewer preview scale', () => {
  it('keeps file viewer selectors in the effective global stylesheet', () => {
    const css = readExpandedIndexCss();

    expect(css).toContain('.viewer');
    expect(css).toContain('.viewer-toolbar');
    expect(css).toContain('.viewer-action');
  });

  it('uses the requested zoom for desktop preview overlays', () => {
    expect(effectivePreviewScale('desktop', 1.5, { width: 320, height: 480 })).toBe(1.5);
  });

  it('clamps mobile and tablet overlay scale to the iframe auto-fit scale', () => {
    expect(effectivePreviewScale('mobile', 1, { width: 390, height: 844 })).toBeLessThan(1);
    expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
  });
});

describe('FileViewer JSON artifacts', () => {
  it('pretty-prints valid JSON in the text viewer', async () => {
    const file = baseFile({
      name: 'data.json',
      path: 'data.json',
      kind: 'code',
      mime: 'application/json',
    });
    vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/raw/data.json') {
        return new Response('{"title":"Launch Metrics","stats":{"views":42,"active":true}}');
      }
      return new Response('', { status: 404 });
    }));

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      expect(container.querySelector('.lines')?.textContent).toBe(
        '{\n  "title": "Launch Metrics",\n  "stats": {\n    "views": 42,\n    "active": true\n  }\n}',
      );
    });
  });

  it('keeps raw JSON when pretty-printing would round an unsafe integer', async () => {
    const file = baseFile({
      name: 'data.json',
      path: 'data.json',
      kind: 'code',
      mime: 'application/json',
    });
    const rawJson = '{"id":9007199254740993,"name":"large"}';
    vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/raw/data.json') {
        return new Response(rawJson);
      }
      return new Response('', { status: 404 });
    }));

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      const displayedText = container.querySelector('.lines')?.textContent ?? '';
      expect(displayedText).toBe(rawJson);
      expect(displayedText).toContain('9007199254740993');
      expect(displayedText).not.toContain('9007199254740992');
    });
  });

  it('keeps raw JSON when pretty-printing would round a high-precision decimal', async () => {
    const file = baseFile({
      name: 'data.json',
      path: 'data.json',
      kind: 'code',
      mime: 'application/json',
    });
    const rawJson = '{"ratio":0.1234567890123456789,"name":"precise"}';
    vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/raw/data.json') {
        return new Response(rawJson);
      }
      return new Response('', { status: 404 });
    }));

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      const displayedText = container.querySelector('.lines')?.textContent ?? '';
      expect(displayedText).toBe(rawJson);
      expect(displayedText).toContain('0.1234567890123456789');
      expect(displayedText).not.toContain('0.12345678901234568');
    });
  });

  it('keeps raw JSON when pretty-printing would round a high-precision exponent', async () => {
    const file = baseFile({
      name: 'data.json',
      path: 'data.json',
      kind: 'code',
      mime: 'application/json',
    });
    const rawJson = '{"ratio":1.234567890123456789e2,"name":"precise"}';
    vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/raw/data.json') {
        return new Response(rawJson);
      }
      return new Response('', { status: 404 });
    }));

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      const displayedText = container.querySelector('.lines')?.textContent ?? '';
      expect(displayedText).toBe(rawJson);
      expect(displayedText).toContain('1.234567890123456789e2');
      expect(displayedText).not.toContain('123.45678901234568');
    });
  });

  it('keeps raw JSON when pretty-printing would erase signed negative zero', async () => {
    const file = baseFile({
      name: 'data.json',
      path: 'data.json',
      kind: 'code',
      mime: 'application/json',
    });
    const rawJson = '{"delta":-0}';
    vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/raw/data.json') {
        return new Response(rawJson);
      }
      return new Response('', { status: 404 });
    }));

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      const displayedText = container.querySelector('.lines')?.textContent ?? '';
      expect(displayedText).toBe(rawJson);
      expect(displayedText).toContain('-0');
      expect(displayedText).not.toContain('{"delta":0}');
    });
  });
});

describe('FileViewer SVG artifacts', () => {
  it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
    const file = baseFile({
      name: 'diagram.svg',
      path: 'diagram.svg',
      mime: 'image/svg+xml',
      artifactManifest: {
        version: 1,
        kind: 'svg',
        title: 'Diagram',
        entry: 'diagram.svg',
        renderer: 'svg',
        exports: ['svg'],
      },
    });

    const markup = renderToStaticMarkup(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    expect(markup).toContain('class="viewer svg-viewer"');
    expect(markup).not.toContain('class="viewer image-viewer"');
    expect(markup).toContain('Preview');
    expect(markup).toContain('Code');
    expect(markup).toContain('src="/api/projects/project-1/raw/diagram.svg?v=1710000000&amp;r=0"');
  });

  it('keeps normal image artifacts on the existing image viewer path', () => {
    const file = baseFile({ name: 'photo.png', path: 'photo.png' });

    const markup = renderToStaticMarkup(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    expect(markup).toContain('class="viewer image-viewer"');
    expect(markup).not.toContain('class="viewer svg-viewer"');
    expect(markup).not.toContain('class="viewer-tabs"');
  });

  it('renders sketch json files through the static sketch preview instead of the image viewer', async () => {
    const file = baseFile({
      name: 'board.sketch.json',
      path: 'board.sketch.json',
      kind: 'sketch',
      mime: 'application/json; charset=utf-8',
    });
    const fetchMock = vi.fn(async () => new Response(JSON.stringify({
      version: 1,
      items: [
        {
          kind: 'arrow',
          x1: 16,
          y1: 24,
          x2: 180,
          y2: 108,
          color: '#1c1b1a',
          size: 3,
        },
      ],
    }), {
      status: 200,
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
    }));
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      expect(container.querySelector('[data-testid="sketch-preview-svg"]')).toBeTruthy();
    });
    expect(container.querySelector('.viewer.image-viewer img')).toBeNull();
    expect(fetchMock).toHaveBeenCalledWith('/api/projects/project-1/raw/board.sketch.json', { cache: 'no-store' });
  });

  it('expands the sketch preview viewBox for off-origin sketches outside the default frame', async () => {
    const file = baseFile({
      name: 'offset-board.sketch.json',
      path: 'offset-board.sketch.json',
      kind: 'sketch',
      mime: 'application/json; charset=utf-8',
    });
    const fetchMock = vi.fn(async () => new Response(JSON.stringify({
      version: 1,
      items: [
        {
          kind: 'rect',
          x: 500,
          y: 300,
          w: 20,
          h: 10,
          color: '#1c1b1a',
          size: 2,
        },
      ],
    }), {
      status: 200,
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
    }));
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    await waitFor(() => {
      const svg = container.querySelector<SVGSVGElement>('[data-testid="sketch-preview-svg"] svg');
      expect(svg).toBeTruthy();
      expect(svg?.getAttribute('viewBox')).toBe('0 0 545 335');
    });
  });

  it('marks preview and source modes through the SVG viewer toggle controls', () => {
    const file = baseFile({ name: 'diagram.svg', path: 'diagram.svg', mime: 'image/svg+xml' });

    const previewMarkup = renderToStaticMarkup(
      <SvgViewer projectId="project-1" file={file} initialMode="preview" />,
    );
    const sourceMarkup = renderToStaticMarkup(
      <SvgViewer
        projectId="project-1"
        file={file}
        initialMode="source"
        initialSource="<svg><title>Diagram</title></svg>"
      />,
    );

    expect(previewMarkup).toContain('class="viewer-tab active" aria-pressed="true">Preview</button>');
    expect(previewMarkup).toContain('aria-pressed="false">Code</button>');
    expect(previewMarkup).toContain('<img');

    expect(sourceMarkup).toContain('aria-pressed="false">Preview</button>');
    expect(sourceMarkup).toContain('class="viewer-tab active" aria-pressed="true">Code</button>');
    expect(sourceMarkup).toContain('class="viewer-source"');
    expect(sourceMarkup).not.toContain('<img');
  });

  it('URL-loads a plain HTML preview iframe instead of inlining via srcDoc', () => {
    const file = baseFile({
      name: 'page.html',
      path: 'page.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'page.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    const markup = renderToStaticMarkup(
      <FileViewer projectId="project-1" projectKind="prototype" file={file} liveHtml="<html><body>hi</body></html>" />,
    );

    // Both iframes are always mounted (the lazy srcDoc transport avoids
    // booting the artifact in the inactive frame). `data-od-active` and
    // the testid pair identify which iframe is currently the user-facing
    // one without unmounting either side.
    expect(markup).toContain('data-testid="artifact-preview-frame"');
    expect(markup).toContain('data-od-render-mode="url-load"');
    expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
    expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
    expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0"');
    expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
  });

  it('keeps inactive HTML preview transports mounted without booting the artifact', async () => {
    const file = baseFile({
      name: 'page.html',
      path: 'page.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'page.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    const { container } = render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={file}
        liveHtml='<html><body><script>window.__odArtifactBootCount = (window.__odArtifactBootCount || 0) + 1;</script><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    const urlFrame = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
    const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;

    expect(urlFrame).toBeTruthy();
    expect(srcDocFrame).toBeTruthy();
    expect(urlFrame?.getAttribute('data-od-active')).toBe('true');
    expect(srcDocFrame?.getAttribute('data-od-active')).toBe('false');
    expect(srcDocFrame?.srcdoc).toContain('data-od-lazy-srcdoc-transport');
    expect(srcDocFrame?.srcdoc).not.toContain('__odArtifactBootCount');

    const postMessageSpy = vi.spyOn(srcDocFrame!.contentWindow!, 'postMessage');
    fireEvent.click(screen.getByTestId('inspect-mode-toggle'));

    const urlFrameAfter = container.querySelector('iframe[data-od-render-mode="url-load"]') as HTMLIFrameElement | null;
    const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null;

    expect(urlFrameAfter).toBe(urlFrame);
    expect(srcDocFrameAfter).toBe(srcDocFrame);
    expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false');
    expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank');
    expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true');
    expect(srcDocFrameAfter?.srcdoc).toContain('data-od-lazy-srcdoc-transport');
    expect(srcDocFrameAfter?.srcdoc).not.toContain('__odArtifactBootCount');

    await waitFor(() => {
      const activations = srcDocActivationMessages(postMessageSpy.mock.calls);
      expect(activations.at(-1)?.html).toContain('__odArtifactBootCount');
      expect(activations.at(-1)?.html).toContain('data-od-selection-bridge');
      expect(activations.at(-1)?.html).toContain('data-od-preview-focus-guard');
    });
  });

  it('reactivates the srcDoc transport after switching source back to preview', async () => {
    const file = baseFile({
      name: 'page.html',
      path: 'page.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'page.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={file}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    fireEvent.click(screen.getByTestId('inspect-mode-toggle'));

    await waitFor(() => {
      const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
      expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
    });

    fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
    fireEvent.click(screen.getByRole('menuitem', { name: 'Code' }));
    expect(screen.queryByTestId('artifact-preview-frame')).toBeNull();

    fireEvent.click(screen.getByRole('button', { name: 'Code' }));
    fireEvent.click(screen.getByRole('menuitem', { name: 'Preview' }));

    const remountedFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
    const postMessageSpy = vi.spyOn(remountedFrame.contentWindow!, 'postMessage');

    await waitFor(() => {
      const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
      expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
    });
    await waitFor(() => {
      const activations = srcDocActivationMessages(postMessageSpy.mock.calls);
      expect(activations.at(-1)?.html).toContain('data-od-selection-bridge');
      expect(activations.at(-1)?.html).toContain('Hero');
    });
  });

  it('uses the next file URL immediately when switching URL-loaded HTML previews', () => {
    const first = baseFile({
      name: 'first.html',
      path: 'first.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'First',
        entry: 'first.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    const second = {
      ...first,
      name: 'second.html',
      path: 'second.html',
      artifactManifest: {
        ...first.artifactManifest!,
        title: 'Second',
        entry: 'second.html',
      },
    };
    const observedCommittedSrcs: Array<string | null> = [];

    function Switcher() {
      const [file, setFile] = useState<ProjectFile>(first);
      const hostRef = useRef<HTMLDivElement | null>(null);
      useLayoutEffect(() => {
        observedCommittedSrcs.push(
          hostRef.current
            ?.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]')
            ?.getAttribute('src') ?? null,
        );
      });
      return (
        <div ref={hostRef}>
          <button type="button" onClick={() => setFile(second)}>Switch file</button>
          <FileViewer projectId="project-1" projectKind="prototype" file={file}
            liveHtml="<html><body>preview</body></html>"
          />
        </div>
      );
    }

    const { container } = render(<Switcher />);
    const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
    const initialFrame = getFrame();
    expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0');

    const observationsBeforeSwitch = observedCommittedSrcs.length;
    fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));

    const nextFrame = getFrame();
    expect(nextFrame).toBe(initialFrame);
    expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
      '/api/projects/project-1/raw/second.html?v=1710000000&r=0',
    );
    expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0');
  });

  it('allows downloads in the in-tab HTML presentation iframe', async () => {
    const file = baseFile({
      name: 'page.html',
      path: 'page.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'page.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    const { container } = render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file} liveHtml="<html><body>hi</body></html>" />,
    );

    fireEvent.click(screen.getByRole('button', { name: /present/i }));
    fireEvent.click(screen.getByRole('menuitem', { name: /in this tab/i }));

    await waitFor(() => {
      const frame = container.querySelector('.present-overlay iframe');
      expect(frame?.getAttribute('sandbox')).toBe('allow-scripts allow-downloads');
      expect(frame?.getAttribute('data-od-render-mode')).toBe('url-load');
    });
  });

  it('allows downloads in React component preview iframes', async () => {
    const file = baseFile({
      name: 'Card.jsx',
      path: 'Card.jsx',
      mime: 'text/jsx',
      kind: 'code',
      artifactManifest: {
        version: 1,
        kind: 'react-component',
        title: 'Card',
        entry: 'Card.jsx',
        renderer: 'react-component',
        exports: ['jsx', 'html', 'zip'],
      },
    });
    vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/raw/Card.jsx') {
        return new Response('export default function Card() { return <button>Download</button>; }');
      }
      return new Response('', { status: 404 });
    }));

    render(<FileViewer projectId="project-1" projectKind="prototype" file={file} />);

    const frame = await screen.findByTestId('react-component-preview-frame');
    expect(frame.getAttribute('sandbox')).toBe('allow-scripts allow-downloads');
  });

  it('points a .jsx module loaded by a sibling HTML to that entry, not the React error (issue #2744)', async () => {
    const file = baseFile({
      name: 'icons.jsx',
      path: 'icons.jsx',
      mime: 'text/jsx',
      kind: 'code',
      artifactManifest: {
        version: 1,
        kind: 'react-component',
        title: 'icons',
        entry: 'icons.jsx',
        renderer: 'react-component',
        exports: ['jsx'],
      },
    });
    vi.stubGlobal(
      'fetch',
      vi.fn(async (input: string | URL | Request) => {
        const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
        if (url === '/api/projects/project-1/files') {
          return new Response(
            JSON.stringify({
              files: [
                { name: 'icons.jsx', path: 'icons.jsx' },
                { name: 'backups.html', path: 'backups.html' },
              ],
            }),
          );
        }
        if (url === '/api/projects/project-1/raw/backups.html') {
          return new Response('<script type="text/babel" src="icons.jsx"></script>');
        }
        if (url === '/api/projects/project-1/raw/icons.jsx') {
          return new Response('window.I = { star: null };');
        }
        return new Response('', { status: 404 });
      }),
    );

    const onOpenFileReplacing = vi.fn();
    const { container } = render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={file}
        onOpenFileReplacing={onOpenFileReplacing}
      />,
    );

    // The module points at its HTML entry instead of rendering the React
    // runtime (which would throw "No React component export found").
    const link = await screen.findByRole('button', { name: /backups\.html/ });
    expect(screen.queryByTestId('react-component-preview-frame')).toBeNull();

    // The toolbar still offers a way to read the raw code: clicking the Code
    // tab swaps the pointer for the file's source. Issue #2744 follow-up.
    fireEvent.click(screen.getByRole('button', { name: /^code$/i }));
    expect(container.textContent).toContain('window.I');
    expect(screen.queryByRole('button', { name: /backups\.html/ })).toBeNull();

    // Back on Preview, clicking the entry opens the HTML page and closes the
    // dead-end module tab (icons.jsx) in one move.
    fireEvent.click(screen.getByRole('button', { name: /preview/i }));
    fireEvent.click(await screen.findByRole('button', { name: /backups\.html/ }));
    expect(onOpenFileReplacing).toHaveBeenCalledWith('backups.html', 'icons.jsx');
  });

  it('keeps decks on the srcDoc path so the deck postMessage bridge can run', () => {
    const file = baseFile({
      name: 'deck.html',
      path: 'deck.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'deck',
        title: 'Deck',
        entry: 'deck.html',
        renderer: 'deck-html',
        exports: ['html'],
      },
    });

    const markup = renderToStaticMarkup(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        isDeck
        liveHtml={'<html><body><section class="slide">one</section></body></html>'}
      />,
    );

    expect(markup).toContain('data-testid="artifact-preview-frame"');
    expect(markup).toContain('data-od-render-mode="srcdoc"');
    expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="true"');
    expect(markup).toContain('data-od-render-mode="url-load" data-od-active="false"');
    expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
  });

  it('falls back to srcDoc when the HTML body looks deck-shaped even without an isDeck hint', () => {
    const file = baseFile({
      name: 'inferred.html',
      path: 'inferred.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Inferred',
        entry: 'inferred.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    const markup = renderToStaticMarkup(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml={'<html><body><section class="slide">one</section><section class="slide">two</section></body></html>'}
      />,
    );

    expect(markup).toContain('data-od-render-mode="srcdoc"');
    expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="true"');
    expect(markup).toContain('data-od-render-mode="url-load" data-od-active="false"');
  });

  it('hides preview-only toolbar controls when switching an HTML deck to source view', async () => {
    const file = baseFile({
      name: 'deck.html',
      path: 'deck.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Deck',
        entry: 'deck.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    const { container } = render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={file}
        isDeck
        liveHtml={'<html><body><section class="slide">one</section><section class="slide">two</section></body></html>'}
      />,
    );

    expect(container.querySelector('.deck-nav')).toBeTruthy();
    expect(container.querySelector('.palette-tweaks-anchor')).toBeTruthy();
    expect(container.querySelector('.viewer-viewport-switcher')).toBeTruthy();

    fireEvent.click(container.querySelector('.viewer-mode-trigger')!);
    fireEvent.click(screen.getByRole('menuitem', { name: /code/i }));

    await waitFor(() => {
      expect(container.querySelector('.deck-nav')).toBeNull();
      expect(container.querySelector('.palette-tweaks-anchor')).toBeNull();
      expect(container.querySelector('.viewer-viewport-switcher')).toBeNull();
      expect(screen.getByTestId('manual-edit-mode-toggle')).toBeTruthy();
      expect(screen.queryByTestId('draw-overlay-toggle')).toBeNull();
      expect(screen.queryByTestId('palette-tweaks-toggle')).toBeNull();
      expect(screen.queryByRole('button', { name: /100%/ })).toBeNull();
      expect(screen.queryByRole('button', { name: /zoom out/i })).toBeNull();
      expect(screen.queryByRole('button', { name: /zoom in/i })).toBeNull();
    });
  });

  it('shows Cloudflare Pages as a deploy action without requiring a project name input', async () => {
    const file = baseFile({
      name: 'index.html',
      path: 'index.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'index.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));

    expect(screen.getByRole('menuitem', { name: /Deploy to Vercel/i })).toBeTruthy();
    fireEvent.click(screen.getByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));

    expect(await screen.findByRole('dialog')).toBeTruthy();
    expect(screen.getByText('Account ID')).toBeTruthy();
    expect(screen.getByText(/Pages Edit is required/i)).toBeTruthy();
    expect(screen.getByText(/Zone Read is required to list domains/i)).toBeTruthy();
    expect(screen.getByText(/DNS Edit is only needed when binding a custom domain/i)).toBeTruthy();
    expect(screen.queryByText(/Pages Read\/Write/i)).toBeNull();
    const subdomainInput = screen.getByLabelText('Subdomain prefix');
    const domainSelect = screen.getByLabelText('Domain');
    expect(Boolean(subdomainInput.compareDocumentPosition(domainSelect) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
    expect(screen.queryByText('Pages project name')).toBeNull();
    expect(screen.queryByText(/generates a Pages project name automatically/i)).toBeNull();
    expect(screen.queryByText(/project name is selected automatically/i)).toBeNull();
    expect(screen.queryByLabelText('Pages project name')).toBeNull();
  });

  it('nudges the share button once when an artifact becomes exportable', async () => {
    const file = baseFile({
      name: 'nudge.html',
      path: 'nudge.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Nudge',
        entry: 'nudge.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    render(
      <FileViewer
        projectId="project-nudge"
        projectKind="prototype"
        file={file}
        liveHtml="<html><body><h1>Ready</h1></body></html>"
      />,
    );

    const exportButton = screen.getByRole('button', { name: /share/i });
    await waitFor(() => {
      expect(exportButton.classList.contains('export-ready-nudge')).toBe(true);
    });

    fireEvent.click(exportButton);

    expect(exportButton.classList.contains('export-ready-nudge')).toBe(false);
  });

  it('nudges each exportable artifact once when the mounted viewer switches files', async () => {
    const firstFile = baseFile({
      name: 'nudge-first.html',
      path: 'nudge-first.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'First',
        entry: 'nudge-first.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    const secondFile = baseFile({
      name: 'nudge-second.html',
      path: 'nudge-second.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Second',
        entry: 'nudge-second.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    const { rerender } = render(
      <FileViewer
        projectId="project-nudge-switch"
        projectKind="prototype"
        file={firstFile}
        liveHtml="<html><body><h1>First</h1></body></html>"
      />,
    );

    const firstExportButton = screen.getByRole('button', { name: /share/i });
    await waitFor(() => {
      expect(firstExportButton.classList.contains('export-ready-nudge')).toBe(true);
    });
    fireEvent.click(firstExportButton);
    expect(firstExportButton.classList.contains('export-ready-nudge')).toBe(false);

    rerender(
      <FileViewer
        projectId="project-nudge-switch"
        projectKind="prototype"
        file={secondFile}
        liveHtml="<html><body><h1>Second</h1></body></html>"
      />,
    );

    const secondExportButton = screen.getByRole('button', { name: /share/i });
    await waitFor(() => {
      expect(secondExportButton.classList.contains('export-ready-nudge')).toBe(true);
    });
  });

  it('keeps the explicitly selected deploy provider when another provider already has a deployment', async () => {
    const file = baseFile({
      name: 'index.html',
      path: 'index.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'index.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/deployments') {
        return new Response(JSON.stringify({
          deployments: [
            {
              id: 'vercel-deploy',
              projectId: 'project-1',
              fileName: 'index.html',
              providerId: 'vercel-self',
              url: 'https://vercel.example',
              deploymentCount: 1,
              target: 'preview',
              status: 'ready',
              createdAt: 1,
              updatedAt: 2,
            },
          ],
        }), { status: 200 });
      }
      if (url === '/api/deploy/config?providerId=cloudflare-pages') {
        return new Response(JSON.stringify({
          providerId: 'cloudflare-pages',
          configured: true,
          tokenMask: 'saved-cloudflare-token',
          accountId: 'account-123',
        }), { status: 200 });
      }
      if (url === '/api/deploy/config?providerId=vercel-self') {
        return new Response(JSON.stringify({
          providerId: 'vercel-self',
          configured: true,
          tokenMask: 'saved-vercel-token',
        }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));
    fireEvent.click(await screen.findByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));

    const providerSelect = await screen.findByRole('combobox', { name: /Provider/i });
    await waitFor(() => {
      expect((providerSelect as HTMLSelectElement).value).toBe('cloudflare-pages');
    });

    const calledUrls = fetchMock.mock.calls.map(([input]) => (
      typeof input === 'string' ? input : input instanceof Request ? input.url : String(input)
    ));
    expect(calledUrls).toContain('/api/deploy/config?providerId=cloudflare-pages');
    expect(calledUrls).not.toContain('/api/deploy/config?providerId=vercel-self');
    expect((screen.getByLabelText(/Cloudflare API token/i) as HTMLInputElement).value).toBe('saved-cloudflare-token');
  });

  it('ignores stale deploy config loads after switching providers', async () => {
    const file = baseFile({
      name: 'index.html',
      path: 'index.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'index.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    const delayedCloudflareConfig = deferredResponse();
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/deployments') {
        return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
      }
      if (url === '/api/deploy/config?providerId=cloudflare-pages') {
        return delayedCloudflareConfig.promise;
      }
      if (url === '/api/deploy/config?providerId=vercel-self') {
        return new Response(JSON.stringify({
          providerId: 'vercel-self',
          configured: true,
          tokenMask: 'saved-vercel-token',
        }), { status: 200 });
      }
      if (url === '/api/deploy/cloudflare-pages/zones') {
        return new Response(JSON.stringify({
          zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
        }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));
    fireEvent.click(await screen.findByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));

    const providerSelect = await screen.findByRole('combobox', { name: /Provider/i });
    await waitFor(() => {
      expect((providerSelect as HTMLSelectElement).value).toBe('cloudflare-pages');
    });
    fireEvent.change(providerSelect, { target: { value: 'vercel-self' } });

    await waitFor(() => {
      expect((providerSelect as HTMLSelectElement).value).toBe('vercel-self');
    });
    expect((screen.getByLabelText(/Vercel token/i) as HTMLInputElement).value).toBe('saved-vercel-token');

    delayedCloudflareConfig.resolve(new Response(JSON.stringify({
      providerId: 'cloudflare-pages',
      configured: true,
      tokenMask: 'saved-cloudflare-token',
      accountId: 'account-123',
      cloudflarePages: {
        lastZoneId: 'zone-1',
        lastDomainPrefix: 'demo',
      },
    }), { status: 200 }));

    await waitFor(() => {
      expect((providerSelect as HTMLSelectElement).value).toBe('vercel-self');
      expect((screen.getByLabelText(/Vercel token/i) as HTMLInputElement).value).toBe('saved-vercel-token');
    });
    expect(screen.queryByLabelText(/Cloudflare API token/i)).toBeNull();
  });

  it('loads Cloudflare domains, sends the selected custom domain, and renders both links', async () => {
    const file = baseFile({
      name: 'index.html',
      path: 'index.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'index.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    let deployBody: any = null;
    const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      const method = init?.method || (input instanceof Request ? input.method : 'GET');
      if (url === '/api/projects/project-1/deployments') {
        return new Response(JSON.stringify({ deployments: [] }), { status: 200 });
      }
      if (url === '/api/deploy/config?providerId=cloudflare-pages') {
        return new Response(JSON.stringify({
          providerId: 'cloudflare-pages',
          configured: true,
          tokenMask: 'saved-cloudflare-token',
          teamId: '',
          teamSlug: '',
          accountId: 'account-123',
          target: 'preview',
        }), { status: 200 });
      }
      if (url === '/api/deploy/cloudflare-pages/zones') {
        return new Response(JSON.stringify({
          zones: [{ id: 'zone-1', name: 'example.com', status: 'active', type: 'full' }],
        }), { status: 200 });
      }
      if (url === '/api/deploy/config' && method === 'PUT') {
        const body = JSON.parse(String(init?.body ?? '{}'));
        return new Response(JSON.stringify({
          providerId: 'cloudflare-pages',
          configured: true,
          tokenMask: 'saved-cloudflare-token',
          teamId: '',
          teamSlug: '',
          accountId: body.accountId,
          cloudflarePages: body.cloudflarePages,
          target: 'preview',
        }), { status: 200 });
      }
      if (url === '/api/projects/project-1/deploy' && method === 'POST') {
        deployBody = JSON.parse(String(init?.body ?? '{}'));
        return new Response(JSON.stringify({
          id: 'cloudflare-deploy',
          projectId: 'project-1',
          fileName: 'index.html',
          providerId: 'cloudflare-pages',
          url: 'https://demo-pages.pages.dev',
          deploymentId: 'cf-dep-1',
          deploymentCount: 1,
          target: 'preview',
          status: 'ready',
          cloudflarePages: {
            projectName: 'demo-pages',
            pagesDev: {
              url: 'https://demo-pages.pages.dev',
              status: 'ready',
            },
            customDomain: {
              hostname: 'demo.example.com',
              url: 'https://demo.example.com',
              zoneId: 'zone-1',
              zoneName: 'example.com',
              domainPrefix: 'demo',
              status: 'ready',
              dnsStatus: 'created',
              domainStatus: 'active',
            },
          },
          createdAt: 1,
          updatedAt: 2,
        }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));
    fireEvent.click(await screen.findByRole('menuitem', { name: /Deploy to Cloudflare Pages/i }));

    const zoneSelect = await screen.findByRole('combobox', { name: /Domain/i });
    await waitFor(() => {
      expect((zoneSelect as HTMLSelectElement).value).toBe('zone-1');
    });
    fireEvent.change(screen.getByLabelText(/Subdomain prefix/i), { target: { value: 'demo' } });

    const deployButtons = screen.getAllByRole('button', { name: /Deploy to Cloudflare Pages/i });
    fireEvent.click(deployButtons[deployButtons.length - 1]!);

    const pagesDevLabel = await screen.findByText('pages.dev URL');
    const customDomainLabel = await screen.findByText('Custom domain');
    expect(customDomainLabel).toBeTruthy();
    expect(pagesDevLabel.closest('.deploy-result-block')).toBe(customDomainLabel.closest('.deploy-result-block'));
    expect(screen.getByText('https://demo-pages.pages.dev')).toBeTruthy();
    expect(screen.getByText('https://demo.example.com')).toBeTruthy();
    expect(deployBody).toMatchObject({
      fileName: 'index.html',
      providerId: 'cloudflare-pages',
      cloudflarePages: {
        zoneId: 'zone-1',
        zoneName: 'example.com',
        domainPrefix: 'demo',
      },
    });
  });

  it('shows separate copy links for existing Vercel and Cloudflare deployments', async () => {
    const file = baseFile({
      name: 'index.html',
      path: 'index.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'index.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/projects/project-1/deployments') {
        return new Response(JSON.stringify({
          deployments: [
            {
              id: 'vercel-deploy',
              projectId: 'project-1',
              fileName: 'index.html',
              providerId: 'vercel-self',
              url: 'https://vercel.example',
              deploymentCount: 1,
              target: 'preview',
              status: 'ready',
              createdAt: 1,
              updatedAt: 2,
            },
            {
              id: 'cloudflare-deploy',
              projectId: 'project-1',
              fileName: 'index.html',
              providerId: 'cloudflare-pages',
              url: 'https://cloudflare.pages.dev',
              deploymentCount: 1,
              target: 'preview',
              status: 'ready',
              createdAt: 1,
              updatedAt: 3,
            },
          ],
        }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    const writeText = vi.fn().mockResolvedValue(undefined);
    vi.stubGlobal('fetch', fetchMock);
    Object.defineProperty(navigator, 'clipboard', {
      configurable: true,
      value: { writeText },
    });

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));

    expect(await screen.findByRole('menuitem', { name: /Copy link · Vercel/i })).toBeTruthy();
    const cloudflareCopy = await screen.findByRole('menuitem', { name: /Copy link · Cloudflare Pages/i });
    fireEvent.click(cloudflareCopy);

    expect(writeText).toHaveBeenCalledWith('https://cloudflare.pages.dev');
  });

  it('shows one copy link when only one deployment provider has a URL', async () => {
    const file = baseFile({
      name: 'index.html',
      path: 'index.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Page',
        entry: 'index.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
      deployments: [
        {
          id: 'cloudflare-deploy',
          projectId: 'project-1',
          fileName: 'index.html',
          providerId: 'cloudflare-pages',
          url: 'https://cloudflare.pages.dev',
          deploymentCount: 1,
          target: 'preview',
          status: 'ready',
          createdAt: 1,
          updatedAt: 3,
        },
      ],
    }), { status: 200 })));

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));

    expect(await screen.findByRole('menuitem', { name: /Copy link · Cloudflare Pages/i })).toBeTruthy();
    expect(screen.queryByRole('menuitem', { name: /Copy link · Vercel/i })).toBeNull();
  });

  it('renders unsafe SVG source as escaped text instead of executable markup', () => {
    const file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' });
    const unsafeSource = [
      '<svg onload="alert(1)"><script>alert(2)</script><text>Logo</text></svg>',
      '<svg><![CDATA[<script>alert(3)</script>]]></svg>',
    ].join('\n');

    const markup = renderToStaticMarkup(
      <SvgViewer
        projectId="project-1"
        file={file}
        initialMode="source"
        initialSource={unsafeSource}
      />,
    );

    expect(markup).toContain('&lt;svg onload=&quot;alert(1)&quot;&gt;');
    expect(markup).toContain('&lt;script&gt;alert(2)&lt;/script&gt;');
    expect(markup).toContain('&lt;![CDATA[&lt;script&gt;alert(3)&lt;/script&gt;]]&gt;');
    expect(markup).not.toContain('<svg onload');
    expect(markup).not.toContain('<script>');
    expect(markup).not.toContain('<![CDATA[');
    expect(markup).not.toContain('dangerouslySetInnerHTML');
  });

  it('uses an in-app modal instead of window.prompt() when saving a template', async () => {
    saveTemplateMock.mockResolvedValueOnce({
      id: 'tpl_1',
      name: 'Landing Page',
      description: null,
      sourceProjectId: 'project-1',
      files: [],
      createdAt: Date.now(),
    });
    const promptSpy = vi.spyOn(window, 'prompt');
    const file = baseFile({
      name: 'landing-page.html',
      path: 'landing-page.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Landing Page',
        entry: 'landing-page.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={file}
        liveHtml="<html><body><h1>Hello</h1></body></html>"
      />,
    );

    fireEvent.click(screen.getByRole('button', { name: /share/i }));
    fireEvent.click(screen.getByRole('menuitem', { name: /save as template/i }));

    expect(screen.getByRole('dialog')).toBeTruthy();
    const nameInput = screen.getByLabelText(/template name/i) as HTMLInputElement;
    expect(nameInput.value).toBe('landing-page');
    fireEvent.change(nameInput, { target: { value: 'Landing Page' } });
    fireEvent.click(screen.getByRole('button', { name: /^save$/i }));

    await waitFor(() =>
      expect(saveTemplateMock).toHaveBeenCalledWith({
        name: 'Landing Page',
        description: undefined,
        sourceProjectId: 'project-1',
      }),
    );
    expect(promptSpy).not.toHaveBeenCalled();
    promptSpy.mockRestore();
  });
});

describe('FileViewer tweaks toolbar', () => {
  const t = (key: keyof Dict) => {
    const labels: Partial<Record<keyof Dict, string>> = {
      'chat.tabComments': 'Comments',
      'chat.comments.emptySaved': 'No saved comments.',
      'common.close': 'Close',
      'preview.showSidebar': 'Show Comments',
      'preview.hideSidebar': 'Hide Comments',
    };
    return labels[key] ?? key;
  };

  function htmlPreviewFile(): ProjectFile {
    return baseFile({
      name: 'preview.html',
      path: 'preview.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Preview',
        entry: 'preview.html',
        renderer: 'html',
        exports: ['html'],
      },
    });
  }

  it('renders the toolbar Draw entry alongside restored Comment and Inspect entries', () => {
    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    expect(screen.getByTestId('palette-tweaks-toggle')).toBeTruthy();
    expect(screen.getByTestId('board-mode-toggle')).toBeTruthy();
    expect(screen.getByTestId('inspect-mode-toggle')).toBeTruthy();
    expect(screen.getByTestId('draw-overlay-toggle')).toBeTruthy();
    expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
    expect(screen.queryByTestId('comment-mode-toggle')).toBeNull();
    expect(screen.queryByRole('button', { name: 'Pods' })).toBeNull();

    fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
    expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
    expect(screen.getByRole('button', { name: 'Click' })).toBeTruthy();

    fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
    expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
  });

  it('shows an inspect notice when a clicked child resolves to an annotated ancestor', async () => {
    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero"><h1>Hero</h1></main></body></html>'
      />,
    );

    const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
    fireEvent.click(screen.getByTestId('inspect-mode-toggle'));

    window.dispatchEvent(new MessageEvent('message', {
      source: frame.contentWindow,
      data: {
        type: 'od:comment-target',
        elementId: 'hero',
        selector: '[data-od-id="hero"]',
        label: 'main',
        text: 'Hero',
        style: {},
        clickedDescendant: {
          label: 'h1',
          text: 'Hero',
        },
      },
    }));

    const notice = await screen.findByTestId('inspect-ancestor-notice');
    expect(notice.textContent).toContain('You clicked h1');
    expect(notice.textContent).toContain('Editing main instead');
  });

  it('keeps the Draw bar open after queueing an annotation', () => {
    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
    const note = screen.getByPlaceholderText('Type anywhere to add a note');
    fireEvent.change(note, { target: { value: 'mark this' } });
    fireEvent.click(screen.getByRole('button', { name: 'Queue' }));

    expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();
    expect(screen.getAllByRole('button', { name: 'Draw' })[1]?.getAttribute('aria-pressed')).toBe('true');
    expect(screen.getByRole('button', { name: 'Click' }).getAttribute('aria-pressed')).toBe('false');

    fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
    expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
  });

  it('keeps the preloaded selection bridge mounted while the Draw bar switches to click mode', async () => {
    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).getAttribute('data-od-render-mode')).toBe('url-load');
    const inactiveSrcDocFrame = screen.getByTestId('artifact-preview-frame-srcdoc') as HTMLIFrameElement;
    const postMessageSpy = vi.spyOn(inactiveSrcDocFrame.contentWindow!, 'postMessage');
    fireEvent.click(screen.getByTestId('draw-overlay-toggle'));

    const frame = await waitFor(() => {
      const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
      expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
      expect(activeFrame.srcdoc).toContain('data-od-lazy-srcdoc-transport');
      return activeFrame;
    });
    await waitFor(() => {
      expect(srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html).toContain('data-od-selection-bridge');
    });
    const initialSrcDoc = frame.srcdoc;

    fireEvent.click(screen.getByRole('button', { name: 'Click' }));

    await waitFor(() => expect(screen.getByRole('button', { name: 'Click' }).getAttribute('aria-pressed')).toBe('true'));
    expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(initialSrcDoc);
  });

  it('lets Draw direct send emit a queued annotation while a task is running', async () => {
    const annotationSpy = vi.fn();
    window.addEventListener(ANNOTATION_EVENT, annotationSpy);

    render(
      <FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
        streaming
      />,
    );

    fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
    fireEvent.change(screen.getByPlaceholderText('Type anywhere to add a note'), {
      target: { value: 'mark this' },
    });

    const queue = screen.getByRole('button', { name: 'Queue' }) as HTMLButtonElement;
    expect(queue.disabled).toBe(false);
    const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement;
    expect(send.disabled).toBe(false);

    fireEvent.click(send);

    await waitFor(() => expect(annotationSpy).toHaveBeenCalledTimes(1));
    expect(annotationSpy.mock.calls[0]?.[0]).toMatchObject({
      detail: {
        action: 'send',
        note: 'mark this',
        filePath: 'preview.html',
      },
    });
    window.removeEventListener(ANNOTATION_EVENT, annotationSpy);
  });

  it('hides non-open saved comments from preview markers when the side panel is empty', () => {
    const resolvedComment: PreviewComment = {
      id: 'comment-applying',
      projectId: 'project-1',
      conversationId: 'conversation-1',
      filePath: 'preview.html',
      elementId: 'pin-applying',
      selector: '[data-od-pin="pin-applying"]',
      label: 'pin-applying',
      text: '',
      htmlHint: '',
      position: { x: 24, y: 32, width: 18, height: 18 },
      note: 'Already sent to Claude',
      status: 'applying',
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };

    render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
        previewComments={[resolvedComment]}
      />,
    );

    fireEvent.click(screen.getByTestId('board-mode-toggle'));

    expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
    expect(screen.queryByTestId('comment-saved-marker-pin-applying')).toBeNull();
    expect(screen.queryByText('Already sent to Claude')).toBeNull();
  });

  it('keeps the picker hint clear of the open comment side panel', () => {
    render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    fireEvent.click(screen.getByTestId('board-mode-toggle'));

    expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
    expect(screen.getByTestId('inspect-empty-hint-container').className).toContain(
      'comment-side-panel-open',
    );

    const closeButton = screen
      .getByTestId('comment-side-panel')
      .querySelector<HTMLButtonElement>('.comment-side-close');
    expect(closeButton).toBeTruthy();
    fireEvent.click(closeButton!);

    expect(screen.queryByTestId('comment-side-panel')).toBeNull();
    expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
    expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
  });

  it('keeps saved comment marker numbers aligned with the side panel order', () => {
    const olderComment: PreviewComment = {
      id: 'comment-older',
      projectId: 'project-1',
      conversationId: 'conversation-1',
      filePath: 'preview.html',
      elementId: 'pin-older',
      selector: '[data-od-pin="pin-older"]',
      label: 'pin-older',
      text: '',
      htmlHint: '',
      position: { x: 24, y: 32, width: 18, height: 18 },
      note: 'Older comment',
      status: 'open',
      createdAt: 10,
      updatedAt: 10,
    };
    const newerComment: PreviewComment = {
      ...olderComment,
      id: 'comment-newer',
      elementId: 'pin-newer',
      selector: '[data-od-pin="pin-newer"]',
      label: 'pin-newer',
      position: { x: 72, y: 32, width: 18, height: 18 },
      note: 'Newer comment',
      createdAt: 20,
      updatedAt: 20,
    };

    render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
        previewComments={[olderComment, newerComment]}
      />,
    );

    fireEvent.click(screen.getByTestId('board-mode-toggle'));

    expect(screen.getAllByTestId('comment-side-item')[0]?.textContent).toContain('Newer comment');
    expect(screen.getByTestId('comment-saved-marker-pin-newer').textContent).toContain('1');
    expect(screen.getByTestId('comment-saved-marker-pin-older').textContent).toContain('2');
  });

  it('does not preload non-open element comments into the picker composer', async () => {
    const applyingElementComment: PreviewComment = {
      id: 'comment-element-applying',
      projectId: 'project-1',
      conversationId: 'conversation-1',
      filePath: 'preview.html',
      elementId: 'hero',
      selector: '[data-od-id="hero"]',
      label: 'Hero',
      text: 'Hero',
      htmlHint: '<main data-od-id="hero">Hero</main>',
      position: { x: 8, y: 12, width: 120, height: 48 },
      note: 'Do not resurrect this note',
      status: 'applying',
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };

    render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
        previewComments={[applyingElementComment]}
      />,
    );

    const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
    fireEvent.click(screen.getByTestId('board-mode-toggle'));

    window.dispatchEvent(new MessageEvent('message', {
      source: frame.contentWindow,
      data: {
        type: 'od:comment-target',
        elementId: 'hero',
        selector: '[data-od-id="hero"]',
        label: 'Hero',
        text: 'Hero',
        position: { x: 8, y: 12, width: 120, height: 48 },
        htmlHint: '<main data-od-id="hero">Hero</main>',
      },
    }));

    const input = await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement;
    expect(input.value).toBe('');
    expect(screen.queryByText('Remove')).toBeNull();
    expect(screen.queryByText('Do not resurrect this note')).toBeNull();
  });

  it('closes an open saved-comment composer when that comment leaves the open state', async () => {
    const openComment: PreviewComment = {
      id: 'comment-status-transition',
      projectId: 'project-1',
      conversationId: 'conversation-1',
      filePath: 'preview.html',
      elementId: 'pin-transition',
      selector: '[data-od-pin="pin-transition"]',
      label: 'pin-transition',
      text: '',
      htmlHint: '',
      position: { x: 40, y: 52, width: 18, height: 18 },
      note: 'Do not recreate this stale comment',
      status: 'open',
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };

    const { rerender } = render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
        previewComments={[openComment]}
      />,
    );

    fireEvent.click(screen.getByTestId('board-mode-toggle'));
    fireEvent.click(screen.getByRole('button', { name: 'Open comment for pin-transition' }));

    expect((await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement).value)
      .toBe('Do not recreate this stale comment');

    rerender(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={htmlPreviewFile()}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
        previewComments={[{ ...openComment, status: 'applying' }]}
      />,
    );

    await waitFor(() => {
      expect(screen.queryByTestId('comment-popover-input')).toBeNull();
    });
    expect(screen.queryByTestId('comment-saved-marker-pin-transition')).toBeNull();
    expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
  });

  it('closes the comment side panel from the header close button', () => {
    const onCollapseChange = vi.fn();
    const onClose = vi.fn();

    function Harness() {
      const [collapsed, setCollapsed] = useState(false);
      const [open, setOpen] = useState(true);
      return open ? (
        <CommentSidePanel
          comments={[
            {
              id: 'comment-1',
              projectId: 'project-1',
              conversationId: 'conversation-1',
              filePath: 'preview.html',
              elementId: 'button.sso-btn',
              selector: '[data-od-id="button.sso-btn"]',
              label: 'button.sso-btn',
              text: 'GitHub',
              htmlHint: '<button>GitHub</button>',
              position: { x: 16, y: 24, width: 160, height: 48 },
              note: '不要github，换成微信',
              status: 'open',
              createdAt: Date.now(),
              updatedAt: Date.now(),
            },
          ]}
          selectedIds={new Set(['comment-1'])}
          collapsed={collapsed}
          onCollapsedChange={(next) => {
            onCollapseChange(next);
            setCollapsed(next);
          }}
          onClose={() => {
            onClose();
            setOpen(false);
          }}
          onToggleSelect={() => {}}
          onClearSelection={() => {}}
          onReply={() => {}}
          onSendSelected={() => {}}
          sending={false}
          t={t}
        />
      ) : null;
    }

    render(<Harness />);

    expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
    expect(screen.getByText('不要github，换成微信')).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: /close/i }));

    expect(onClose).toHaveBeenCalledOnce();
    expect(onCollapseChange).not.toHaveBeenCalled();
    expect(screen.queryByText('不要github，换成微信')).toBeNull();
    expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
    expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
  });

  // PR #1643 regression: the once-per-file guard that mirrors a `.twk-panel`
  // artifact's default-open state into the toolbar `tweaksMode` lives in a
  // message-event listener that previously had an empty deps array. The
  // handler therefore closed over the first-render `file.name`, so switching
  // to a second `.twk-panel` file left the guard comparing against the
  // stale captured name and never re-mirrored the new artifact's open state
  // back to ON. Surfaced by Siri-Ray in
  // https://github.com/nexu-io/open-design/pull/1643#discussion_r3266838151.
  it('mirrors __edit_mode_available default-open state for each switched-to .twk-panel file', async () => {
    function twkFile(name: string): ProjectFile {
      return baseFile({
        name,
        path: name,
        mime: 'text/html',
        kind: 'html',
        artifactManifest: {
          version: 1,
          kind: 'html',
          title: name,
          entry: name,
          renderer: 'html',
          exports: ['html'],
        },
      });
    }

    function Switcher() {
      const [file, setFile] = useState<ProjectFile>(twkFile('first.html'));
      return (
        <div>
          <button type="button" onClick={() => setFile(twkFile('second.html'))}>
            Switch file
          </button>
          <FileViewer
            projectId="project-1"
            projectKind="prototype"
            file={file}
            liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
          />
        </div>
      );
    }

    render(<Switcher />);

    const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
    const tweaksButton = () =>
      Array.from(document.querySelectorAll('button')).find(
        (b) => b.getAttribute('title') === 'Tweaks' || b.getAttribute('aria-label') === 'Tweaks',
      ) as HTMLButtonElement | undefined;

    // First file: artifact posts __edit_mode_available → toolbar starts ON.
    window.dispatchEvent(
      new MessageEvent('message', {
        source: firstFrame.contentWindow,
        data: { type: '__edit_mode_available' },
      }),
    );
    await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('true'));

    // User toggles OFF on first file.
    fireEvent.click(tweaksButton()!);
    await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('false'));

    // Switch to second file. The second artifact also mounts panel-visible
    // and emits __edit_mode_available. The toolbar must mirror that into ON
    // again — the bug was that the handler kept comparing against the first
    // file's name in a stale closure, so the second emission was treated as
    // a "second emission for the same file" and the OFF state stuck.
    fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
    const secondFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
    window.dispatchEvent(
      new MessageEvent('message', {
        source: secondFrame.contentWindow,
        data: { type: '__edit_mode_available' },
      }),
    );
    await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('true'));
  });

  // PR #1643 regression: Protocol A in `design-templates/tweaks/SKILL.md`
  // says the artifact MAY declare a default-closed panel via
  // `{ type: '__edit_mode_available', visible: false }`. The handler used
  // to unconditionally mirror availability into `tweaksMode = true`, so a
  // default-closed dynamic artifact would be force-opened by the next
  // `syncBridgeModes` posting `__activate_edit_mode`. The host must now
  // read `visible` and only flip to ON when the panel reports itself open
  // (or omits `visible` — back-compat shim for the common open-by-default
  // case). Surfaced by Siri-Ray in
  // https://github.com/nexu-io/open-design/pull/1643#discussion_r3269955351.
  it('respects __edit_mode_available { visible: false } for default-closed dynamic artifacts', async () => {
    const file = baseFile({
      name: 'closed.html',
      path: 'closed.html',
      mime: 'text/html',
      kind: 'html',
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'closed',
        entry: 'closed.html',
        renderer: 'html',
        exports: ['html'],
      },
    });

    render(
      <FileViewer
        projectId="project-1"
        projectKind="prototype"
        file={file}
        liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
      />,
    );

    const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
    const tweaksButton = () =>
      Array.from(document.querySelectorAll('button')).find(
        (b) => b.getAttribute('title') === 'Tweaks' || b.getAttribute('aria-label') === 'Tweaks',
      ) as HTMLButtonElement | undefined;

    // Artifact announces availability AND declares the panel is currently
    // closed. The toolbar must enable (panel exists) but stay OFF — opening
    // it without intent would override the artifact-declared default.
    window.dispatchEvent(
      new MessageEvent('message', {
        source: frame.contentWindow,
        data: { type: '__edit_mode_available', visible: false },
      }),
    );

    await waitFor(() => expect(tweaksButton()?.disabled).toBe(false));
    expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('false');
  });
});

describe('applyInspectOverridesToSource', () => {
  const base = `<!doctype html><html><head><title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
  const css = `[data-od-id="hero"] { color: #ff0000 !important }`;

  it('inserts the overrides block before </head>', () => {
    const next = applyInspectOverridesToSource(base, css);
    expect(next).toContain('<style data-od-inspect-overrides>');
    expect(next).toContain('color: #ff0000');
    expect(next.indexOf('<style data-od-inspect-overrides>')).toBeLessThan(next.indexOf('</head>'));
  });

  it('replaces an existing overrides block instead of duplicating', () => {
    const once = applyInspectOverridesToSource(base, css);
    const twice = applyInspectOverridesToSource(once, `[data-od-id="hero"] { color: #00ff00 !important }`);
    const matches = twice.match(/<style data-od-inspect-overrides>/g) ?? [];
    expect(matches).toHaveLength(1);
    expect(twice).toContain('color: #00ff00');
    expect(twice).not.toContain('color: #ff0000');
  });

  it('strips the overrides block when called with empty css', () => {
    const once = applyInspectOverridesToSource(base, css);
    const stripped = applyInspectOverridesToSource(once, '');
    expect(stripped).not.toContain('data-od-inspect-overrides');
  });

  it('handles fragments without an explicit <head>', () => {
    const next = applyInspectOverridesToSource('<main data-od-id="x">x</main>', css);
    expect(next).toContain('<style data-od-inspect-overrides>');
    expect(next.indexOf('<style data-od-inspect-overrides>')).toBeLessThan(next.indexOf('<main'));
  });

  // Regression for nexu-io/open-design#362: if a source file has more than
  // one inspect override block (manual edit, or an earlier buggy save), the
  // splicer must drop them all before inserting the new block. A non-global
  // regex would only strip the first, so save-then-reload could resurrect an
  // override the user just cleared.
  it('removes every existing overrides block, not just the first', () => {
    const dup = `<!doctype html><html><head>` +
      `<style data-od-inspect-overrides>[data-od-id="hero"] { color: #ff0000 !important }</style>` +
      `<style data-od-inspect-overrides>[data-od-id="hero"] { color: #00ff00 !important }</style>` +
      `<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
    const replaced = applyInspectOverridesToSource(dup, `[data-od-id="hero"] { color: #0000ff !important }`);
    const matches = replaced.match(/<style data-od-inspect-overrides>/g) ?? [];
    expect(matches).toHaveLength(1);
    expect(replaced).toContain('color: #0000ff');
    expect(replaced).not.toContain('color: #ff0000');
    expect(replaced).not.toContain('color: #00ff00');

    const cleared = applyInspectOverridesToSource(dup, '');
    expect(cleared).not.toContain('data-od-inspect-overrides');
  });

  // Regression for nexu-io/open-design#362: the splicer must be HTML-aware
  // when locating its own override block and the head insertion point.
  // Generated artifacts commonly carry inline scripts/styles that mention
  // `</head>` or `<style data-od-inspect-overrides>` as text, e.g. a
  // template literal that builds HTML at runtime or a CSS rule that
  // documents the override block. A regex-only splicer would happily
  // splice into the middle of the script body or strip the literal string,
  // corrupting user code on Save to source.
  it('ignores </head> literals inside inline <script> and <style>', () => {
    const sourceWithLiteral =
      `<!doctype html><html><head>` +
      // Script body contains a quoted "</head>" string that must NOT be
      // treated as the real head close.
      `<script>const tpl = "<head>\\n</head>";</script>` +
      `<style>/* sentinel: </head> appears in this CSS comment */</style>` +
      `<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
    const next = applyInspectOverridesToSource(sourceWithLiteral, css);
    // The override block must land exactly once, before the real </head>,
    // and after the inline <script> and <style> that contain `</head>`
    // text. Without HTML-aware scanning the regex would splice before the
    // first textual `</head>`, which sits inside the script body.
    const blockIdx = next.indexOf('<style data-od-inspect-overrides>');
    const realHeadEndIdx = next.indexOf('</head>', next.indexOf('<title>'));
    const scriptOpenIdx = next.indexOf('<script>');
    const scriptCloseIdx = next.indexOf('</script>');
    expect(blockIdx).toBeGreaterThan(-1);
    expect(realHeadEndIdx).toBeGreaterThan(-1);
    expect(scriptOpenIdx).toBeGreaterThan(-1);
    expect(scriptCloseIdx).toBeGreaterThan(-1);
    // Override block sits BEFORE the real </head>, AFTER the script body.
    expect(blockIdx).toBeLessThan(realHeadEndIdx);
    expect(blockIdx).toBeGreaterThan(scriptCloseIdx);
    // The script's `</head>` literal still survives in the output —
    // the splicer must not have hijacked it as the head insertion point.
    expect(next).toContain('const tpl = "<head>\\n</head>";');
    // The CSS comment's `</head>` token also survives untouched.
    expect(next).toContain('/* sentinel: </head> appears in this CSS comment */');
    // Only one override block in total.
    const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
    expect(blockMatches).toHaveLength(1);
  });

  it('ignores `<style data-od-inspect-overrides>` literals inside <script>', () => {
    // A sentinel string literal in an inline script that mentions the
    // override block by name. A regex-only splicer would strip the
    // literal as if it were a real block, mangling the script.
    const sourceWithLiteral =
      `<!doctype html><html><head>` +
      `<script>const banner = "<style data-od-inspect-overrides>color: red</style>";</script>` +
      `<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
    const next = applyInspectOverridesToSource(sourceWithLiteral, css);
    // The literal must survive verbatim inside the script body.
    expect(next).toContain('const banner = "<style data-od-inspect-overrides>color: red</style>";');
    // The output still gains exactly one real override block.
    const blockMatches = next.match(/<style data-od-inspect-overrides>\n\[data-od-id="hero"\]/g) ?? [];
    expect(blockMatches).toHaveLength(1);
    // Stripping with empty css must NOT touch the script literal.
    const stripped = applyInspectOverridesToSource(sourceWithLiteral, '');
    expect(stripped).toContain('const banner = "<style data-od-inspect-overrides>color: red</style>";');
    // The script-internal literal is the only mention of the marker after
    // stripping — the splicer must not have inserted or kept any real
    // override block.
    const allMatches = stripped.match(/data-od-inspect-overrides/g) ?? [];
    expect(allMatches).toHaveLength(1);
  });

  // Regression for nexu-io/open-design#362: the splicer must look at real
  // attribute names, not just substring-match the marker text against the
  // whole opening tag. A `\bdata-od-inspect-overrides\b` regex over the
  // full tag matches both a longer attribute name (`-note` suffix) and the
  // marker spelled inside another attribute's value, so a plain `<style>`
  // documenting the override block in a `title` tooltip or a sibling note
  // attribute would be mis-stripped on save and would have its inner CSS
  // mis-parsed as override rules on hydration.
  it('does not strip <style> blocks whose attribute name only PREFIXES the marker', () => {
    const css2 = `[data-od-id="hero"] { color: #00ffaa !important }`;
    const userBlock = `body { background: red !important }`;
    const sourceWithLongerName =
      `<!doctype html><html><head>` +
      // attribute is named data-od-inspect-overrides-note, NOT the marker.
      // The note shouldn't be treated as an Inspect-owned style block.
      `<style data-od-inspect-overrides-note="docs">${userBlock}</style>` +
      `<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
    const next = applyInspectOverridesToSource(sourceWithLongerName, css2);
    // The user's style with the longer attribute name must survive in the
    // output verbatim (with both the attribute and the body intact).
    expect(next).toContain('<style data-od-inspect-overrides-note="docs">');
    expect(next).toContain(userBlock);
    // Exactly one real override block lands before </head>.
    const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
    expect(blockMatches).toHaveLength(1);
    // Stripping with empty CSS still leaves the user's longer-name block
    // alone — there was no real override block to remove.
    const stripped = applyInspectOverridesToSource(sourceWithLongerName, '');
    expect(stripped).toContain('<style data-od-inspect-overrides-note="docs">');
    expect(stripped).toContain(userBlock);
    expect(stripped).not.toContain('<style data-od-inspect-overrides>');
  });

  it('does not strip <style> blocks that only mention the marker inside an attribute value', () => {
    const css2 = `[data-od-id="hero"] { color: #00ffaa !important }`;
    const userBlock = `body { background: red !important }`;
    const sourceWithMarkerInValue =
      `<!doctype html><html><head>` +
      // The literal text data-od-inspect-overrides appears as an attribute
      // VALUE on a normal <style title="..."> — there is no real override
      // marker here, so the splicer must keep the block.
      `<style title="data-od-inspect-overrides">${userBlock}</style>` +
      `<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
    const next = applyInspectOverridesToSource(sourceWithMarkerInValue, css2);
    expect(next).toContain('<style title="data-od-inspect-overrides">');
    expect(next).toContain(userBlock);
    const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
    expect(blockMatches).toHaveLength(1);
    const stripped = applyInspectOverridesToSource(sourceWithMarkerInValue, '');
    expect(stripped).toContain('<style title="data-od-inspect-overrides">');
    expect(stripped).toContain(userBlock);
    expect(stripped).not.toContain('<style data-od-inspect-overrides>');
  });

  it('still strips a real <style data-od-inspect-overrides> block with assigned value', () => {
    // The marker is allowed both as a boolean attribute and with an
    // assigned value (`<style data-od-inspect-overrides="">`). The splicer
    // must treat both as the override block, not just the boolean shape.
    const sourceWithValuedMarker =
      `<!doctype html><html><head>` +
      `<style data-od-inspect-overrides="">` +
      `[data-od-id="hero"] { color: #ff0000 !important }` +
      `</style>` +
      `<title>X</title></head><body></body></html>`;
    const stripped = applyInspectOverridesToSource(sourceWithValuedMarker, '');
    expect(stripped).not.toContain('data-od-inspect-overrides');
    expect(stripped).not.toContain('color: #ff0000');
  });

  it('ignores </head> inside <textarea> and <title> raw-text elements', () => {
    // <textarea> and <title> are escapable raw-text elements; their
    // contents are text, not markup, so a literal `</head>` inside them
    // must not be treated as a tag boundary.
    const sourceWithTextarea =
      `<!doctype html><html><head><title>Has </head> in title</title></head>` +
      `<body><textarea>literal </head> goes here</textarea>` +
      `<main data-od-id="hero">Hi</main></body></html>`;
    const next = applyInspectOverridesToSource(sourceWithTextarea, css);
    // Override block lands before the REAL </head>, which is after the
    // </title>'s close. The title-internal `</head>` must not be the
    // chosen insertion point.
    const blockIdx = next.indexOf('<style data-od-inspect-overrides>');
    const titleCloseIdx = next.indexOf('</title>');
    const realHeadCloseIdx = next.indexOf('</head>', titleCloseIdx);
    expect(blockIdx).toBeGreaterThan(titleCloseIdx);
    expect(blockIdx).toBeLessThan(realHeadCloseIdx);
    // Both literals survive untouched.
    expect(next).toContain('Has </head> in title');
    expect(next).toContain('literal </head> goes here');
  });
});

describe('serializeInspectOverrides', () => {
  it('emits validated declarations for legitimate overrides', () => {
    const out = serializeInspectOverrides({
      hero: { selector: '[data-od-id="hero"]', props: { color: '#ff0000', 'font-size': '18px' } },
    });
    expect(out).toContain('[data-od-id="hero"]');
    expect(out).toContain('color: #ff0000 !important');
    expect(out).toContain('font-size: 18px !important');
  });

  it('honours data-screen-label entries the bridge tagged that way', () => {
    const out = serializeInspectOverrides({
      hero: { selector: '[data-screen-label="hero"]', props: { color: '#0f0' } },
    });
    expect(out).toContain('[data-screen-label="hero"]');
    expect(out).not.toContain('[data-od-id="hero"]');
  });

  // Regression for nexu-io/open-design#362: standard deck slides ship as
  // `<section data-screen-label="01 Cover">`. The bridge keys overrides by
  // the raw label and posts a CSS.escape'd selector, so the host must
  // accept whitespace/leading-digit ids and detect the selector kind by
  // prefix instead of full equality. Otherwise the override is dropped
  // outright (or silently rewritten to `[data-od-id="..."]`) and reload
  // erases the user's edit.
  it('preserves data-screen-label values with whitespace and leading digits', () => {
    const out = serializeInspectOverrides({
      '01 Cover': {
        selector: '[data-screen-label="\\30 1\\20 Cover"]',
        props: { color: '#ff0000', 'font-size': '20px' },
      },
    });
    expect(out).toContain('[data-screen-label="01 Cover"]');
    expect(out).not.toContain('[data-od-id="01 Cover"]');
    expect(out).toContain('color: #ff0000 !important');
    expect(out).toContain('font-size: 20px !important');
  });

  it('rejects non-allow-listed properties', () => {
    const out = serializeInspectOverrides({
      hero: { selector: '[data-od-id="hero"]', props: { position: 'absolute', color: '#fff' } },
    });
    expect(out).not.toContain('position');
    expect(out).toContain('color: #fff !important');
  });

  it('drops values that try to break out of a `prop: value` declaration', () => {
    const out = serializeInspectOverrides({
      hero: {
        selector: '[data-od-id="hero"]',
        // semicolon, brace, angle bracket, and newline are all rejected.
        props: {
          color: 'red; background: url(x)',
          'font-size': '16px } [body] { color: red',
          'font-family': 'Arial</style><script>alert(1)</script>',
          'line-height': '1\n.evil',
        },
      },
    });
    expect(out).toBe('');
  });

  // The vulnerability we're regression-testing: artifact code rendered with
  // scripts enabled can call window.parent.postMessage({ type:
  // 'od:inspect-overrides', overrides, css: '</style><script>...</script>' })
  // — ev.source still matches iframe.contentWindow, so the host listener
  // accepts it. The fix is that the host re-derives CSS from the structured
  // `overrides` field under its own allow-list and ignores the inbound `css`
  // entirely. This test covers that the serializer never lets a forged
  // payload reach the persisted style block.
  it('refuses to surface a forged </style><script> payload', () => {
    const forged = {
      // Hostile selector string: re-derived from elementId, never trusted.
      hero: {
        selector: '} </style><script>alert(1)</script><style>{',
        props: { color: '#fff' },
      },
      // Hostile elementId: rejected outright by the safe-id check.
      '"></style><script>alert(2)</script>': {
        selector: '[data-od-id="x"]',
        props: { color: '#fff' },
      },
      // Hostile value: rejected by UNSAFE_VALUE.
      villain: {
        selector: '[data-od-id="villain"]',
        props: { color: '</style><script>alert(3)</script>' },
      },
    };
    const out = serializeInspectOverrides(forged);
    expect(out).not.toContain('</style>');
    expect(out).not.toContain('<script>');
    expect(out).not.toContain('alert(');
    // The legitimate-looking entry still serializes — but with a re-derived
    // selector, not the attacker-supplied one.
    expect(out).toContain('[data-od-id="hero"] { color: #fff !important }');
    expect(out).not.toContain('villain');

    // And the spliced source must not contain executable markup either,
    // even when the forged body is concatenated into a <style> block.
    const spliced = applyInspectOverridesToSource(
      '<!doctype html><html><head></head><body></body></html>',
      out,
    );
    expect(spliced).not.toContain('</style><script>');
    expect(spliced).not.toContain('alert(');
  });

  it('returns empty string for non-object payloads', () => {
    expect(serializeInspectOverrides(null)).toBe('');
    expect(serializeInspectOverrides(undefined)).toBe('');
    expect(serializeInspectOverrides('</style><script>alert(1)</script>')).toBe('');
    expect(serializeInspectOverrides(42)).toBe('');
  });
});

// Regression for nexu-io/open-design#362: the host owns the inspect override
// map authoritatively. Hydration parses the artifact source on load so an
// initial Save-to-source preserves prior rules even when the user edits a
// different element, and forging the iframe's od:inspect-overrides reply
// cannot inject overrides — the host never ingests it.
describe('parseInspectOverridesFromSource', () => {
  it('returns an empty map when the source has no override block', () => {
    expect(parseInspectOverridesFromSource('')).toEqual({});
    expect(parseInspectOverridesFromSource('<!doctype html><html><body>x</body></html>')).toEqual({});
  });

  it('parses an existing override block into the host map', () => {
    const source =
      `<!doctype html><html><head>` +
      `<style data-od-inspect-overrides>` +
      `[data-od-id="hero"] { color: #ff0000 !important; font-size: 18px !important }` +
      `\n[data-screen-label="01 Cover"] { background-color: #000 !important }` +
      `</style></head><body></body></html>`;
    const map = parseInspectOverridesFromSource(source);
    expect(map.hero?.props).toEqual({ color: '#ff0000', 'font-size': '18px' });
    expect(map.hero?.selector).toBe('[data-od-id="hero"]');
    expect(map['01 Cover']?.props).toEqual({ 'background-color': '#000' });
    expect(map['01 Cover']?.selector).toBe('[data-screen-label="01 Cover"]');
  });

  it('aggregates rules across multiple persisted blocks', () => {
    const source =
      `<style data-od-inspect-overrides>[data-od-id="a"] { color: #111 !important }</style>` +
      `<style data-od-inspect-overrides>[data-od-id="b"] { color: #222 !important }</style>`;
    const map = parseInspectOverridesFromSource(source);
    expect(Object.keys(map).sort()).toEqual(['a', 'b']);
  });

  it('drops disallowed properties and rules whose only declarations are unsafe', () => {
    const source =
      `<style data-od-inspect-overrides>` +
      `[data-od-id="hero"] { position: absolute !important; color: #fff !important }` +
      `[data-od-id="bad"] { background: red } ` +
      `</style>`;
    const map = parseInspectOverridesFromSource(source);
    expect(map.hero?.props).toEqual({ color: '#fff' });
    expect(map.bad).toBeUndefined();
  });

  it('refuses elementIds whose characters could break out of the attr value', () => {
    const hostile =
      `<style data-od-inspect-overrides>` +
      `[data-od-id="\"><script>alert(1)</script>"] { color: #fff !important }` +
      `</style>`;
    expect(parseInspectOverridesFromSource(hostile)).toEqual({});
  });

  it('ignores override-shaped text inside raw-text elements and HTML comments', () => {
    // A template literal in a <script>, a CSS comment in a sibling <style>, the
    // body of a <textarea> / <title>, and an HTML comment all contain text that
    // would match the override block regex. None of them are real persisted
    // overrides, so the host map must stay empty — otherwise useEffect would
    // seed phantom rules and a later Save-to-source would write CSS the user
    // never created.
    const phantomBlock =
      `<style data-od-inspect-overrides>` +
      `[data-od-id="hero"] { color: #ff0000 !important }` +
      `</style>`;
    const source =
      `<!doctype html><html><head>` +
      `<script>const tmpl = \`${phantomBlock}\`;</script>` +
      `<style>/* docs: ${phantomBlock} */</style>` +
      `<title>${phantomBlock}</title>` +
      `<!-- ${phantomBlock} -->` +
      `</head><body><textarea>${phantomBlock}</textarea></body></html>`;
    expect(parseInspectOverridesFromSource(source)).toEqual({});
  });

  // Regression for nexu-io/open-design#362: hydration must require an
  // actual `data-od-inspect-overrides` attribute name, not a boundary-only
  // substring match against the whole opening tag. Otherwise a sibling
  // attribute name with `-note` suffix or a tooltip whose value contains
  // the marker text would seed phantom overrides into the host map and
  // a later Save-to-source would persist CSS the artifact never had.
  it('does not seed phantom overrides from a longer attribute name', () => {
    const source =
      `<!doctype html><html><head>` +
      `<style data-od-inspect-overrides-note="docs">` +
      `[data-od-id="hero"] { color: #ff0000 !important }` +
      `</style></head><body></body></html>`;
    expect(parseInspectOverridesFromSource(source)).toEqual({});
  });

  it('does not seed phantom overrides when the marker text only appears in an attribute value', () => {
    const source =
      `<!doctype html><html><head>` +
      `<style title="data-od-inspect-overrides">` +
      `[data-od-id="hero"] { color: #ff0000 !important }` +
      `</style></head><body></body></html>`;
    expect(parseInspectOverridesFromSource(source)).toEqual({});
  });

  it('still parses a real override block when raw-text literals also mention one', () => {
    const phantomBlock =
      `<style data-od-inspect-overrides>` +
      `[data-od-id="phantom"] { color: #ff0000 !important }` +
      `</style>`;
    const source =
      `<!doctype html><html><head>` +
      `<script>const tmpl = \`${phantomBlock}\`;</script>` +
      `<style data-od-inspect-overrides>` +
      `[data-od-id="hero"] { color: #00ff00 !important }` +
      `</style>` +
      `</head><body></body></html>`;
    const map = parseInspectOverridesFromSource(source);
    expect(Object.keys(map)).toEqual(['hero']);
    expect(map.hero?.props).toEqual({ color: '#00ff00' });
  });
});

describe('updateInspectOverride', () => {
  const base: InspectOverrideMap = {
    hero: { selector: '[data-od-id="hero"]', props: { color: '#ff0000' } },
  };

  it('adds a new property to an existing entry', () => {
    const next = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '18px');
    expect(next).not.toBe(base);
    expect(next.hero?.props).toEqual({ color: '#ff0000', 'font-size': '18px' });
  });

  it('creates a new entry for a previously untouched element', () => {
    const next = updateInspectOverride(base, 'cta', '[data-od-id="cta"]', 'color', '#00ff00');
    expect(next.cta?.props).toEqual({ color: '#00ff00' });
    expect(next.hero?.props).toEqual({ color: '#ff0000' });
  });

  it('clears a single property when given an empty value', () => {
    const seeded = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '18px');
    const cleared = updateInspectOverride(seeded, 'hero', '[data-od-id="hero"]', 'font-size', '');
    expect(cleared.hero?.props).toEqual({ color: '#ff0000' });
  });

  it('drops the entry once the last property is cleared', () => {
    const cleared = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'color', '');
    expect(cleared.hero).toBeUndefined();
  });

  it('returns the same map reference when the change is a no-op', () => {
    const same = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'color', '#ff0000');
    expect(same).toBe(base);
    const noClear = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '');
    expect(noClear).toBe(base);
  });

  it('rejects properties off the host allow-list', () => {
    const ignored = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'position', 'absolute');
    expect(ignored).toBe(base);
  });

  it('rejects values that could break out of `prop: value`', () => {
    const ignored = updateInspectOverride(
      base,
      'hero',
      '[data-od-id="hero"]',
      'color',
      'red; background: url(x)',
    );
    expect(ignored).toBe(base);
  });

  it('rejects elementIds whose characters could break out of the attr value', () => {
    const ignored = updateInspectOverride(
      base,
      '"><script>alert(1)</script>',
      '[data-od-id="x"]',
      'color',
      '#fff',
    );
    expect(ignored).toBe(base);
  });
});

function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact {
  const artifact: LiveArtifact = {
    schemaVersion: 1,
    id: 'la_1',
    projectId: 'proj_1',
    title: 'Launch Metrics',
    slug: 'launch-metrics',
    status: 'active',
    pinned: false,
    preview: { type: 'html', entry: 'index.html' },
    refreshStatus: 'idle',
    createdAt: '2026-04-29T12:00:00.000Z',
    updatedAt: '2026-04-29T12:00:00.000Z',
    document: {
      format: 'html_template_v1',
      templatePath: 'template.html',
      generatedPreviewPath: 'index.html',
      dataPath: 'data.json',
      dataJson: { title: 'Launch Metrics' },
    },
  };
  return { ...artifact, ...overrides, document: overrides.document ?? artifact.document };
}

function baseLiveArtifactWorkspaceEntry(
  overrides: Partial<LiveArtifactWorkspaceEntry> = {},
): LiveArtifactWorkspaceEntry {
  const entry: LiveArtifactWorkspaceEntry = {
    kind: 'live-artifact',
    tabId: 'live:la_1',
    artifactId: 'la_1',
    projectId: 'proj_1',
    title: 'Launch Metrics',
    slug: 'launch-metrics',
    status: 'active',
    refreshStatus: 'idle',
    pinned: false,
    preview: { type: 'html', entry: 'index.html' },
    hasDocument: true,
    updatedAt: '2026-04-29T12:00:00.000Z',
  };
  return { ...entry, ...overrides };
}

describe('LiveArtifactViewer', () => {
  it('hides inactive live previews even when a device viewport sets display', () => {
    const css = readExpandedIndexCss();
    const rule = css.match(/\.live-artifact-preview-layer\.preview-viewport\[data-active='false'\]\s*\{[^}]+\}/)?.[0] ?? '';

    expect(rule).toContain('display: none;');
  });

  it('keeps the presentation exit button aligned with preview chrome spacing', () => {
    const css = readExpandedIndexCss();
    const rule = css.match(/\.present-exit\s*\{[^}]+\}/)?.[0] ?? '';

    expect(rule).toContain('top: calc(env(safe-area-inset-top, 0px) + 20px);');
    expect(rule).toContain('right: calc(env(safe-area-inset-right, 0px) + 20px);');
    expect(rule).toContain('display: inline-flex;');
    expect(rule).toContain('align-items: center;');
  });

  it('enters and exits in-tab presentation from the present menu', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /present/i })).toBeTruthy();
    });

    fireEvent.click(screen.getByRole('button', { name: /present/i }));
    fireEvent.click(screen.getByRole('menuitem', { name: /in this tab/i }));

    await waitFor(() => {
      expect(container.querySelector('.live-artifact-viewer.is-tab-present')).toBeTruthy();
    });
    expect(screen.getByRole('button', { name: /exit fullscreen/i })).toBeTruthy();

    fireEvent.click(screen.getByRole('button', { name: /exit fullscreen/i }));
    await waitFor(() => {
      expect(container.querySelector('.live-artifact-viewer.is-tab-present')).toBeNull();
    });
  });

  it('keeps in-tab presentation off when fullscreen request fails', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /present/i })).toBeTruthy();
    });

    const requestFullscreen = vi.fn(() => Promise.reject(new Error('denied')));
    const previewHost = container.querySelector('.viewer-body');
    expect(previewHost).toBeTruthy();
    Object.defineProperty(previewHost!, 'requestFullscreen', {
      configurable: true,
      value: requestFullscreen,
    });

    fireEvent.click(screen.getByRole('button', { name: /present/i }));
    fireEvent.click(screen.getByRole('menuitem', { name: /fullscreen/i }));

    await waitFor(() => {
      expect(requestFullscreen).toHaveBeenCalled();
    });
    expect(container.querySelector('.live-artifact-viewer.is-tab-present')).toBeNull();
    expect(screen.queryByRole('button', { name: /exit fullscreen/i })).toBeNull();
  });

  it('requests fullscreen without entering in-tab presentation when fullscreen succeeds', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /present/i })).toBeTruthy();
    });

    const requestFullscreen = vi.fn(() => Promise.resolve());
    const previewHost = container.querySelector('.viewer-body');
    expect(previewHost).toBeTruthy();
    Object.defineProperty(previewHost!, 'requestFullscreen', {
      configurable: true,
      value: requestFullscreen,
    });

    fireEvent.click(screen.getByRole('button', { name: /present/i }));
    fireEvent.click(screen.getByRole('menuitem', { name: /fullscreen/i }));

    await waitFor(() => {
      expect(requestFullscreen).toHaveBeenCalled();
    });
    expect(container.querySelector('.live-artifact-viewer.is-tab-present')).toBeNull();
    expect(screen.queryByRole('button', { name: /exit fullscreen/i })).toBeNull();
  });

  it('opens the rendered preview in a new tab from the present menu', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    const openMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
    vi.stubGlobal('open', openMock);

    render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /present/i })).toBeTruthy();
    });

    fireEvent.click(screen.getByRole('button', { name: /present/i }));
    fireEvent.click(screen.getByRole('menuitem', { name: /new tab/i }));

    expect(openMock).toHaveBeenCalledWith(
      '/api/live-artifacts/la_1/preview?projectId=proj_1',
      '_blank',
      'noopener,noreferrer',
    );
    expect(screen.queryByRole('button', { name: /exit fullscreen/i })).toBeNull();
  });

  it('renders the toolbar Open link as an external preview link', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    const openLink = await screen.findByRole('link', { name: /^open$/i });
    expect(openLink.getAttribute('href')).toBe('/api/live-artifacts/la_1/preview?projectId=proj_1');
    expect(openLink.getAttribute('target')).toBe('_blank');
    expect(openLink.getAttribute('rel')).toContain('noreferrer');
    expect(openLink.getAttribute('rel')).toContain('noopener');
    expect(openLink.getAttribute('tabindex')).not.toBe('-1');
  });

  it('takes the toolbar Open link out of the tab order outside preview mode', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    const openLink = await screen.findByRole('link', { name: /^open$/i });
    expect(openLink.getAttribute('tabindex')).not.toBe('-1');

    fireEvent.click(screen.getByRole('button', { name: /code/i }));

    await waitFor(() => {
      expect(container.querySelector('.ghost-link')?.getAttribute('tabindex')).toBe('-1');
    });
  });

  it('restores the toolbar Open link to the tab order when returning to preview mode', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    const { container } = render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    await screen.findByRole('link', { name: /^open$/i });

    fireEvent.click(screen.getByRole('button', { name: /code/i }));

    await waitFor(() => {
      expect(container.querySelector('.ghost-link')?.getAttribute('tabindex')).toBe('-1');
    });

    fireEvent.click(screen.getByRole('button', { name: /preview/i }));

    await waitFor(() => {
      expect(screen.getByRole('link', { name: /^open$/i }).getAttribute('tabindex')).not.toBe('-1');
    });
  });

  it('keeps the live preview iframe mounted across viewport and tab changes', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/code?projectId=proj_1&variant=template') {
        return new Response('<main>Preview</main>', { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    const frame = await screen.findByTestId('live-artifact-preview-frame');
    fireEvent.click(screen.getByRole('button', { name: /preview viewport/i }));
    fireEvent.click(screen.getByRole('option', { name: /mobile/i }));

    const previewLayer = frame.closest('.live-artifact-preview-layer');
    expect(previewLayer?.classList.contains('preview-viewport-mobile')).toBe(true);

    fireEvent.click(screen.getByRole('button', { name: /code/i }));

    await waitFor(() => {
      expect(previewLayer?.getAttribute('data-active')).toBe('false');
    });
    expect(screen.getByTestId('live-artifact-preview-frame')).toBe(frame);

    fireEvent.click(screen.getByRole('button', { name: /preview/i }));

    await waitFor(() => {
      expect(previewLayer?.getAttribute('data-active')).toBe('true');
    });
    expect(screen.getByTestId('live-artifact-preview-frame')).toBe(frame);
    expect(frame.getAttribute('src')).toBe('/api/live-artifacts/la_1/preview?projectId=proj_1&v=0');
  });

  it('closes the present menu on Escape without tearing down the viewer', async () => {
    const fetchMock = vi.fn(async (input: string | URL | Request) => {
      const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
      if (url === '/api/live-artifacts/la_1?projectId=proj_1') {
        return new Response(JSON.stringify({ artifact: baseLiveArtifact() }), { status: 200 });
      }
      if (url === '/api/live-artifacts/la_1/refreshes?projectId=proj_1') {
        return new Response(JSON.stringify({ refreshes: [] }), { status: 200 });
      }
      return new Response(JSON.stringify({}), { status: 404 });
    });
    vi.stubGlobal('fetch', fetchMock);

    render(
      <LiveArtifactViewer
        projectId="proj_1"
        liveArtifact={baseLiveArtifactWorkspaceEntry()}
      />,
    );

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /present/i })).toBeTruthy();
    });

    fireEvent.click(screen.getByRole('button', { name: /present/i }));
    expect(screen.getByRole('menuitem', { name: /new tab/i })).toBeTruthy();

    fireEvent.keyDown(document, { key: 'Escape' });

    await waitFor(() => {
      expect(screen.queryByRole('menuitem', { name: /new tab/i })).toBeNull();
    });
    expect(screen.getByRole('button', { name: /present/i })).toBeTruthy();
  });
});

describe('LiveArtifactRefreshHistoryPanel', () => {
  it('renders a human-readable status instead of raw JSON when no history exists', () => {
    const markup = renderToStaticMarkup(
      <LiveArtifactRefreshHistoryPanel
        liveArtifact={baseLiveArtifact({ refreshStatus: 'never' })}
        fallbackRefreshStatus="never"
        isRunning={false}
        sessionEvents={[]}
      />,
    );

    // Status badge with tone, not JSON
    expect(markup).toContain('live-artifact-refresh-panel');
    expect(markup).toContain('data-testid="live-artifact-refresh-status-badge"');
    expect(markup).toContain('Not refreshable');
    expect(markup).toContain('Last refreshed');
    expect(markup).toContain('Never');
    expect(markup).toContain('No refresh activity yet in this session');
    // Raw JSON is available but tucked inside a collapsed <details>, not exposed as the primary view.
    expect(markup).toContain('<details');
    expect(markup).toContain('Advanced debug metadata');
    const detailsIndex = markup.indexOf('<details');
    const rawJsonIndex = markup.search(/<pre class="viewer-source">\s*\{/);
    expect(detailsIndex).toBeGreaterThanOrEqual(0);
    expect(rawJsonIndex).toBeGreaterThan(detailsIndex);
  });

  it('surfaces running state and a session timeline with duration + source counts', () => {
    const now = Date.now();
    const markup = renderToStaticMarkup(
      <LiveArtifactRefreshHistoryPanel
        liveArtifact={baseLiveArtifact({
          refreshStatus: 'succeeded',
          lastRefreshedAt: new Date(now - 45_000).toISOString(),
        })}
        fallbackRefreshStatus="succeeded"
        isRunning
        sessionEvents={[
          { id: 1, phase: 'started', at: now - 5_000 },
          {
            id: 2,
            phase: 'succeeded',
            at: now - 1_200,
            durationMs: 3_800,
            refreshedSourceCount: 2,
          },
        ]}
      />,
    );

    // isRunning wins over persisted `succeeded`
    expect(markup).toContain('Refreshing');
    // Both timeline rows are present
    expect(markup).toContain('Started');
    expect(markup).toContain('Succeeded');
    // Source count + duration are humanized (3.8s), not raw ms
    expect(markup).toContain('2 sources updated');
    expect(markup).toContain('3.8s');
  });

  // Lefarcen review on PR #1300: the existing renderToStaticMarkup
  // assertions above can't prove that the panel actually routes its
  // strings through i18n, because the no-provider fallback returns
  // English no matter what locale the rest of the app is set to. This
  // test wraps the panel in `I18nProvider initial="zh-CN"` and pins
  // the Chinese rendering of the strings issue #1254 was filed for:
  // the badge descriptor, the hero label + empty state, the session
  // section header + hint, the empty-timeline copy, the persisted
  // section + its empty copy, started / succeeded event labels, the
  // pluralised source-count line, the document-source labels, and the
  // advanced debug summary. If a future change drops `t()` off any
  // of those callsites, this test catches it before the user sees
  // the mixed-language regression.
  it('renders Chinese strings end-to-end when wrapped in I18nProvider initial="zh-CN"', () => {
    const now = Date.now();
    const markup = renderToStaticMarkup(
      <I18nProvider initial="zh-CN">
        <LiveArtifactRefreshHistoryPanel
          liveArtifact={baseLiveArtifact({
            refreshStatus: 'succeeded',
            // Real lastRefreshedAt + non-empty session events so the
            // relative-time path also runs under zh-CN; the lefarcen
            // P1 review specifically called out that the formerly
            // hardcoded `Xs ago` / `Xm ago` strings would still leak
            // English under a Chinese UI without this.
            lastRefreshedAt: new Date(now - 45_000).toISOString(),
            document: {
              format: 'html_template_v1',
              templatePath: 'template.html',
              generatedPreviewPath: 'index.html',
              dataPath: 'data.json',
              dataJson: { title: 'Launch Metrics' },
              sourceJson: {
                type: 'connector_tool',
                toolName: 'design-files.list',
                input: {},
                refreshPermission: 'none',
                connector: {
                  connectorId: 'figma',
                  toolName: 'design-files.list',
                  accountLabel: 'figma:acct-1',
                },
              },
            },
          })}
          fallbackRefreshStatus="succeeded"
          isRunning={false}
          sessionEvents={[
            { id: 1, phase: 'started', at: now - 5_000 },
            {
              id: 2,
              phase: 'succeeded',
              at: now - 1_200,
              durationMs: 3_800,
              refreshedSourceCount: 1,
            },
          ]}
          persistedEvents={[]}
        />
      </I18nProvider>,
    );

    // Hero
    expect(markup).toContain('上次刷新');
    // Session activity section
    expect(markup).toContain('会话活动');
    expect(markup).toContain('本标签页打开期间观察到的事件');
    // Event labels + pluralised source count for n === 1
    expect(markup).toContain('已开始');
    expect(markup).toContain('已成功');
    expect(markup).toContain('已更新 1 个数据源');
    // Persisted history section + empty copy
    expect(markup).toContain('持久化刷新记录');
    expect(markup).toContain('尚无持久化的刷新记录。');
    // Document source section
    expect(markup).toContain('文档来源');
    expect(markup).toContain('已配置的数据源');
    expect(markup).toContain('类型');
    expect(markup).toContain('工具');
    expect(markup).toContain('连接器');
    // Advanced debug metadata
    expect(markup).toContain('高级调试元数据');
    // English label that previously leaked through must NOT appear
    // (mixed-language is exactly the regression issue #1254 filed for).
    expect(markup).not.toContain('Last refreshed');
    expect(markup).not.toContain('Session activity');
    expect(markup).not.toContain('Persisted refresh history');
    expect(markup).not.toContain('Document source');
    expect(markup).not.toContain('Advanced debug metadata');
    // Relative-time output must be Chinese, not English. The lefarcen
    // P1 review pointed out that formatRelativeTime was hardcoding
    // English units (`Xs ago`), so a 45s-old hero metric would still
    // read `45s ago` even with every label translated. Assert against
    // the Chinese past-tense suffix `前` and rule out the English
    // suffixes the legacy function emitted.
    expect(markup).toContain('前');
    expect(markup).not.toContain(' ago');
    expect(markup).not.toContain('from now');
    expect(markup).not.toMatch(/\b\d+s ago\b/);
    expect(markup).not.toMatch(/\b\d+m ago\b/);
  });

  it('renders the zh-CN empty hero ("从未") when lastRefreshedAt is missing', () => {
    const markup = renderToStaticMarkup(
      <I18nProvider initial="zh-CN">
        <LiveArtifactRefreshHistoryPanel
          liveArtifact={baseLiveArtifact({ refreshStatus: 'never', lastRefreshedAt: undefined })}
          fallbackRefreshStatus="never"
          isRunning={false}
          sessionEvents={[]}
        />
      </I18nProvider>,
    );

    expect(markup).toContain('上次刷新');
    expect(markup).toContain('从未');
    expect(markup).not.toContain('Last refreshed');
    expect(markup).not.toContain('>Never<');
  });
});
