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:
@@ -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>
|
||||
© {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>
|
||||
© {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
|
||||
Reference in New Issue
Block a user