diff --git a/periscope/src/frontend/src/app/opengraph-image.tsx b/periscope/src/frontend/src/app/opengraph-image.tsx
new file mode 100644
index 0000000..dbb010e
--- /dev/null
+++ b/periscope/src/frontend/src/app/opengraph-image.tsx
@@ -0,0 +1,76 @@
+import { ImageResponse } from "next/og";
+import { SITE_TAGLINE, SITE_URL } from "@/lib/site";
+
+export const alt = `Periscope — ${SITE_TAGLINE}`;
+export const size = { width: 1200, height: 630 };
+export const contentType = "image/png";
+
+const HOST = SITE_URL.replace(/^https?:\/\//, "");
+
+export default function OpenGraphImage() {
+ return new ImageResponse(
+ (
+
+
+
+
+
+
+
+
+
+
+
+ Periscope
+
+
+
+
+ Ship hardware that works the first time.
+
+
+ Datasheet-grounded schematic review. Catches the errors that would otherwise
+ surface at bring-up.
+
+
+
+
+ KiCad · Altium · OrCAD · Cadence · Siemens · EasyEDA · EAGLE
+
+
{HOST}
+
+
+ ),
+ { ...size },
+ );
+}
diff --git a/periscope/src/frontend/src/app/twitter-image.tsx b/periscope/src/frontend/src/app/twitter-image.tsx
new file mode 100644
index 0000000..76d5636
--- /dev/null
+++ b/periscope/src/frontend/src/app/twitter-image.tsx
@@ -0,0 +1 @@
+export { default, alt, size, contentType } from "./opengraph-image";
diff --git a/periscope/src/frontend/src/components/legal/changelog-timeline.tsx b/periscope/src/frontend/src/components/legal/changelog-timeline.tsx
new file mode 100644
index 0000000..67419be
--- /dev/null
+++ b/periscope/src/frontend/src/components/legal/changelog-timeline.tsx
@@ -0,0 +1,179 @@
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+import { PeriscopeMark } from "@/components/brand/periscope-mark";
+import { OPERATOR_NAME } from "@/lib/site";
+
+type Kind = "New" | "Improved" | "Fixed";
+
+type Release = {
+ version: string;
+ date: string;
+ title?: string;
+ description?: string;
+ items: { tag: Kind; text: string }[];
+};
+
+function parseReleases(md: string): Release[] {
+ const out: Release[] = [];
+ let current: Release | null = null;
+ let blurb: string[] = [];
+
+ const flush = () => {
+ if (!current) return;
+ const description = blurb.join(" ").trim();
+ if (description) current.description = description;
+ out.push(current);
+ current = null;
+ blurb = [];
+ };
+
+ for (const raw of md.split("\n")) {
+ const line = raw.trimEnd();
+ if (line.startsWith("## ")) {
+ flush();
+ const bits = line.slice(3).split(" — ");
+ current = {
+ version: bits[0]?.trim() ?? "",
+ date: bits[1]?.trim() ?? "",
+ title: bits[2]?.trim() || undefined,
+ items: [],
+ };
+ continue;
+ }
+ if (!current) continue;
+ if (line.startsWith("- ")) {
+ const rest = line.slice(2);
+ const tagged = rest.match(/^\[(New|Improved|Fixed)\]\s*(.+)$/);
+ if (tagged) current.items.push({ tag: tagged[1] as Kind, text: tagged[2] });
+ else current.items.push({ tag: "Improved", text: rest });
+ continue;
+ }
+ if (current.items.length === 0 && line.trim() && !line.startsWith("#")) {
+ blurb.push(line.trim());
+ }
+ }
+ flush();
+ return out;
+}
+
+function prettyDate(iso: string): string {
+ const d = new Date(`${iso}T00:00:00Z`);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+}
+
+const KIND_CLASS: Record = {
+ New: "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
+ Improved: "border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400",
+ Fixed: "border-orange-500/40 bg-orange-500/10 text-orange-600 dark:text-orange-400",
+};
+
+function KindPill({ tag }: { tag: Kind }) {
+ return (
+
+ {tag}
+
+ );
+}
+
+export function ChangelogTimeline({ content }: { content: string }) {
+ const releases = parseReleases(content);
+ return (
+
+
+
+
+
+ Back to home
+
+
+
+
Periscope
+
+
+
+
+
+
Changelog
+
What's new in Periscope.
+
+
+ {releases.map((entry, i) => {
+ const last = i === releases.length - 1;
+ return (
+
+
+ {!last && (
+
+ )}
+
+ {prettyDate(entry.date)}
+ {entry.version && (
+ <>
+
+ ·
+
+ v{entry.version}
+ >
+ )}
+
+ {entry.title && (
+ {entry.title}
+ )}
+ {entry.description && (
+
+ {entry.description}
+
+ )}
+
+ {entry.items.map((item, j) => (
+
+
+
+ {item.text}
+
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/periscope/src/frontend/src/components/legal/file-guide-page.tsx b/periscope/src/frontend/src/components/legal/file-guide-page.tsx
new file mode 100644
index 0000000..4b9f0e4
--- /dev/null
+++ b/periscope/src/frontend/src/components/legal/file-guide-page.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useMemo } from "react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { LegalPageShell, MarkdownContent } from "@/components/legal/legal-page";
+
+const EXPORT_H2 = "## Exporting from your EDA tool";
+
+type ToolTab = { name: string; body: string };
+
+function splitGuide(content: string): { before: string; tabs: ToolTab[]; after: string } {
+ const marker = `\n${EXPORT_H2}\n`;
+ const at = content.indexOf(marker);
+ if (at === -1) return { before: content, tabs: [], after: "" };
+
+ const before = content.slice(0, at).trimEnd();
+ const rest = content.slice(at + marker.length);
+ const next = rest.search(/\n## [^\n]/);
+ const exportBody = next === -1 ? rest : rest.slice(0, next);
+ const after = next === -1 ? "" : rest.slice(next).trimStart();
+
+ const chunks = exportBody.split(/\n### /);
+ const intro = chunks.shift()?.trim() ?? "";
+ const tabs: ToolTab[] = chunks.map((chunk) => {
+ const nl = chunk.indexOf("\n");
+ const name = (nl === -1 ? chunk : chunk.slice(0, nl)).trim();
+ const body = nl === -1 ? "" : chunk.slice(nl + 1).trim();
+ return { name, body };
+ });
+ return {
+ before: intro ? `${before}\n\n${EXPORT_H2}\n\n${intro}` : `${before}\n\n${EXPORT_H2}`,
+ tabs,
+ after,
+ };
+}
+
+function slug(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+export function FileGuidePage({ content }: { content: string }) {
+ const guide = useMemo(() => splitGuide(content), [content]);
+ if (guide.tabs.length === 0) {
+ return (
+
+ {content}
+
+ );
+ }
+ const first = slug(guide.tabs[0].name);
+ return (
+
+ {guide.before}
+
+
+ {guide.tabs.map((tab) => (
+
+ {tab.name}
+
+ ))}
+
+ {guide.tabs.map((tab) => (
+
+ {tab.body}
+
+ ))}
+
+ {guide.after ? {guide.after} : null}
+
+ );
+}
diff --git a/periscope/src/frontend/src/components/legal/legal-page.tsx b/periscope/src/frontend/src/components/legal/legal-page.tsx
new file mode 100644
index 0000000..26d8c89
--- /dev/null
+++ b/periscope/src/frontend/src/components/legal/legal-page.tsx
@@ -0,0 +1,147 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+import Markdown from "react-markdown";
+import { ArrowLeft } from "lucide-react";
+import { PeriscopeMark } from "@/components/brand/periscope-mark";
+import { OPERATOR_NAME } from "@/lib/site";
+
+function nodeText(node: ReactNode): string {
+ if (node == null || typeof node === "boolean") return "";
+ if (typeof node === "string" || typeof node === "number") return String(node);
+ if (Array.isArray(node)) return node.map(nodeText).join("");
+ if (typeof node === "object" && "props" in node) {
+ return nodeText((node as { props: { children?: ReactNode } }).props.children);
+ }
+ return "";
+}
+
+function headingId(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-");
+}
+
+export function MarkdownContent({ children }: { children: string }) {
+ return (
+ (
+ {children}
+ ),
+ h2: ({ children }) => (
+
+ {children}
+
+ ),
+ h3: ({ children }) => (
+
+ {children}
+
+ ),
+ p: ({ children }) => (
+ {children}
+ ),
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => (
+
+ {children}
+
+ ),
+ li: ({ children }) => {children} ,
+ strong: ({ children }) => (
+ {children}
+ ),
+ em: ({ children }) => {children} ,
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ code: ({ className, children, ...props }) => {
+ const text = nodeText(children);
+ const block = text.includes("\n") || Boolean(className);
+ if (block) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ pre: ({ children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {children}
+
+ );
+}
+
+export function LegalPageShell({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+ Back to home
+
+
+
+
Periscope
+
+
+
+
{children}
+
+
+ );
+}
+
+export function LegalPage({ content }: { content: string }) {
+ return (
+
+ {content}
+
+ );
+}
diff --git a/tests/test_periscope_frontend_legal_rewrite.py b/tests/test_periscope_frontend_legal_rewrite.py
new file mode 100644
index 0000000..6b6e9b3
--- /dev/null
+++ b/tests/test_periscope_frontend_legal_rewrite.py
@@ -0,0 +1,62 @@
+"""Legal shells and Open Graph images live under periscope/src."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "periscope" / "src" / "frontend" / "src"
+
+
+def _text(rel: str) -> str:
+ path = SRC / rel
+ assert path.is_file(), rel
+ return path.read_text(encoding="utf-8")
+
+
+def test_legal_and_og_are_src():
+ for rel in (
+ "components/legal/legal-page.tsx",
+ "components/legal/changelog-timeline.tsx",
+ "components/legal/file-guide-page.tsx",
+ "app/opengraph-image.tsx",
+ "app/twitter-image.tsx",
+ ):
+ head = _text(rel)[:400]
+ assert "Native Periscope overlay" not in head
+
+
+def test_legal_page_exports():
+ text = _text("components/legal/legal-page.tsx")
+ assert "export function MarkdownContent" in text
+ assert "export function LegalPageShell" in text
+ assert "export function LegalPage" in text
+ assert "OPERATOR_NAME" in text
+ assert "PeriscopeMark" in text
+
+
+def test_changelog_timeline_parses_tagged_items():
+ text = _text("components/legal/changelog-timeline.tsx")
+ assert "export function ChangelogTimeline" in text
+ assert "function parseReleases" in text
+ assert "New|Improved|Fixed" in text
+ assert "What's new in Periscope." in text
+
+
+def test_file_guide_splits_on_eda_export_heading():
+ text = _text("components/legal/file-guide-page.tsx")
+ assert "export function FileGuidePage" in text
+ assert "## Exporting from your EDA tool" in text
+ assert "function splitGuide" in text
+ assert 'from "@/components/ui/tabs"' in text
+
+
+def test_opengraph_contract():
+ og = _text("app/opengraph-image.tsx")
+ assert 'from "next/og"' in og
+ assert "export const size = { width: 1200, height: 630 }" in og
+ assert 'export const contentType = "image/png"' in og
+ assert "SITE_URL" in og
+ tw = _text("app/twitter-image.tsx")
+ assert 'from "./opengraph-image"' in tw
+ assert "export { default, alt, size, contentType }" in tw