Rewrite legal page shells and Open Graph images.

Overlay legal markdown chrome, changelog timeline, EDA file-guide tabs, and OG/Twitter images from periscope/src instead of inherited PinScope copies.
This commit is contained in:
2026-09-20 20:02:57 +02:00
parent a9ce71f17a
commit 93f6fad3e7
6 changed files with 543 additions and 0 deletions
@@ -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(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "72px",
background: "radial-gradient(at 30% 20%, #15233f 0%, #0a0a0a 55%, #050505 100%)",
color: "#fafafa",
fontFamily: "sans-serif",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<svg width="40" height="40" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0B1220" />
<rect x="13.2" y="11" width="5.6" height="14.2" rx="1.4" fill="#3B82F6" />
<rect x="13.2" y="6.4" width="11.4" height="5.4" rx="1.6" fill="#3B82F6" />
<circle cx="22.6" cy="9.1" r="4.05" fill="#0B1220" />
<circle cx="22.6" cy="9.1" r="3.15" stroke="#67E8F9" strokeWidth="1.15" />
<circle cx="22.6" cy="9.1" r="1.15" fill="#67E8F9" />
</svg>
<div style={{ fontSize: 36, fontWeight: 600, letterSpacing: "-0.01em" }}>
Periscope
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div
style={{
fontSize: 88,
fontWeight: 600,
lineHeight: 1.05,
letterSpacing: "-0.03em",
maxWidth: 980,
}}
>
Ship hardware that works the first time.
</div>
<div style={{ fontSize: 30, lineHeight: 1.3, color: "#a1a1aa", maxWidth: 880 }}>
Datasheet-grounded schematic review. Catches the errors that would otherwise
surface at bring-up.
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 22,
color: "#71717a",
borderTop: "1px solid #27272a",
paddingTop: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
KiCad · Altium · OrCAD · Cadence · Siemens · EasyEDA · EAGLE
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>{HOST}</div>
</div>
</div>
),
{ ...size },
);
}
@@ -0,0 +1 @@
export { default, alt, size, contentType } from "./opengraph-image";
@@ -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<Kind, string> = {
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 (
<span
className={`inline-flex h-5 w-[68px] flex-shrink-0 items-center justify-center rounded-full border px-2 text-[11px] font-medium ${KIND_CLASS[tag]}`}
>
{tag}
</span>
);
}
export function ChangelogTimeline({ content }: { content: string }) {
const releases = parseReleases(content);
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border/50">
<div className="mx-auto max-w-3xl px-6 py-6 flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to home
</Link>
<Link href="/" className="flex items-center gap-2">
<PeriscopeMark className="h-4 w-4" size={16} />
<span className="text-sm font-medium">Periscope</span>
</Link>
</div>
</header>
<main className="mx-auto max-w-3xl px-6 py-12">
<div className="mb-12">
<h1 className="text-3xl font-bold tracking-tight mb-2">Changelog</h1>
<p className="text-sm text-muted-foreground">What's new in Periscope.</p>
</div>
<ol className="relative">
{releases.map((entry, i) => {
const last = i === releases.length - 1;
return (
<li key={entry.version} className="relative pl-10 pb-12 last:pb-0">
<span
aria-hidden
className="absolute left-[5px] top-3 h-3 w-3 rounded-full border-2 border-border bg-background"
/>
{!last && (
<span
aria-hidden
className="absolute left-[10px] top-7 bottom-0 w-px bg-border/60"
/>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<span>{prettyDate(entry.date)}</span>
{entry.version && (
<>
<span aria-hidden className="text-muted-foreground/50">
·
</span>
<span className="font-mono text-xs">v{entry.version}</span>
</>
)}
</div>
{entry.title && (
<h2 className="text-2xl font-bold tracking-tight mb-2">{entry.title}</h2>
)}
{entry.description && (
<p className="text-sm text-muted-foreground leading-relaxed mb-5">
{entry.description}
</p>
)}
<ul className="space-y-2.5">
{entry.items.map((item, j) => (
<li key={j} className="flex items-start gap-3 text-sm">
<KindPill tag={item.tag} />
<span className="text-muted-foreground leading-relaxed pt-px">
{item.text}
</span>
</li>
))}
</ul>
</li>
);
})}
</ol>
</main>
<footer className="border-t border-border/50">
<div className="mx-auto max-w-3xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<Link href="/changelog" className="hover:text-foreground transition-colors">
Changelog
</Link>
<Link href="/privacy" className="hover:text-foreground transition-colors">
Privacy Policy
</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
<span>
&copy; {new Date().getFullYear()} {OPERATOR_NAME}
</span>
</div>
</footer>
</div>
);
}
@@ -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 (
<LegalPageShell>
<MarkdownContent>{content}</MarkdownContent>
</LegalPageShell>
);
}
const first = slug(guide.tabs[0].name);
return (
<LegalPageShell>
<MarkdownContent>{guide.before}</MarkdownContent>
<Tabs defaultValue={first} className="mb-10">
<TabsList className="flex h-auto w-full flex-wrap justify-start gap-1 bg-transparent p-0 mb-4 border-b border-border/50 rounded-none">
{guide.tabs.map((tab) => (
<TabsTrigger
key={slug(tab.name)}
value={slug(tab.name)}
className="rounded-md px-3 py-1.5 text-sm font-medium data-active:bg-muted data-active:text-foreground"
>
{tab.name}
</TabsTrigger>
))}
</TabsList>
{guide.tabs.map((tab) => (
<TabsContent key={slug(tab.name)} value={slug(tab.name)} className="pt-2">
<MarkdownContent>{tab.body}</MarkdownContent>
</TabsContent>
))}
</Tabs>
{guide.after ? <MarkdownContent>{guide.after}</MarkdownContent> : null}
</LegalPageShell>
);
}
@@ -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 (
<Markdown
components={{
h1: ({ children }) => (
<h1 className="text-3xl font-bold tracking-tight mb-2">{children}</h1>
),
h2: ({ children }) => (
<h2
id={headingId(nodeText(children))}
className="text-xl font-semibold mt-10 mb-4 border-b border-border/50 pb-2 scroll-mt-24"
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
id={headingId(nodeText(children))}
className="text-base font-semibold mt-6 mb-2 scroll-mt-24"
>
{children}
</h3>
),
p: ({ children }) => (
<p className="text-sm text-muted-foreground leading-relaxed mb-4">{children}</p>
),
ul: ({ children }) => (
<ul className="text-sm text-muted-foreground leading-relaxed mb-4 list-disc pl-6 space-y-1">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="text-sm text-muted-foreground leading-relaxed mb-4 list-decimal pl-6 space-y-1">
{children}
</ol>
),
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => (
<strong className="text-foreground font-medium">{children}</strong>
),
em: ({ children }) => <em className="text-muted-foreground">{children}</em>,
a: ({ href, children }) => (
<a href={href} className="text-blue-500 hover:underline">
{children}
</a>
),
code: ({ className, children, ...props }) => {
const text = nodeText(children);
const block = text.includes("\n") || Boolean(className);
if (block) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<code className="px-1 py-0.5 rounded bg-muted text-foreground text-[13px] font-mono">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="mb-4 p-3 rounded-md border border-border/60 bg-muted/40 text-[12px] font-mono leading-relaxed overflow-x-auto">
{children}
</pre>
),
}}
>
{children}
</Markdown>
);
}
export function LegalPageShell({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border/50">
<div className="mx-auto max-w-3xl px-6 py-6 flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to home
</Link>
<Link href="/" className="flex items-center gap-2">
<PeriscopeMark className="h-4 w-4" size={16} />
<span className="text-sm font-medium">Periscope</span>
</Link>
</div>
</header>
<main className="mx-auto max-w-3xl px-6 py-12">{children}</main>
<footer className="border-t border-border/50">
<div className="mx-auto max-w-3xl px-6 py-8 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<Link href="/changelog" className="hover:text-foreground transition-colors">
Changelog
</Link>
<Link href="/privacy" className="hover:text-foreground transition-colors">
Privacy Policy
</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
<span>
&copy; {new Date().getFullYear()} {OPERATOR_NAME}
</span>
</div>
</footer>
</div>
);
}
export function LegalPage({ content }: { content: string }) {
return (
<LegalPageShell>
<MarkdownContent>{content}</MarkdownContent>
</LegalPageShell>
);
}
@@ -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