diff --git a/apps/shipyard/.gitignore b/apps/shipyard/.gitignore new file mode 100644 index 000000000..cccfd3167 --- /dev/null +++ b/apps/shipyard/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# fumadocs +.source \ No newline at end of file diff --git a/apps/shipyard/README.md b/apps/shipyard/README.md new file mode 100644 index 000000000..723879805 --- /dev/null +++ b/apps/shipyard/README.md @@ -0,0 +1,27 @@ +# shipyard + +Shipyard's website and docs at [coss.com/shipyard](https://coss.com/shipyard): +the landing page, and the docs at `/shipyard/docs` on the same Fumadocs setup +as coss ui's. + +- `app/page.tsx`, `app/landing.css`: the landing page, as it was at + berthd.app. The harbour at the top is dithered on a canvas and comes alive + at night (`public/assets/harbour.js`, `hero-loop.js`); the chapters' loops + and line scenes load once the page has painted (`clips.js`, + `scenes.css`/`scenes.json`). `components/landing/runtime.tsx` brings them + in. The page keeps its own type (Inter, JetBrains Mono, in `fonts/`) and + tokens; the header, the rails and the docs are coss's. +- `content/docs/`: the docs, moved here from the `cosscom/shipyard` + repository's `docs/` folder. `components/docs/` are its own pieces + (callouts, the stage, the path of cards, screenshots in `public/shots/`, + diagrams and the line scenes); `lib/rehype-reference.ts` makes the + reference tables linkable. +- The app's downloads, the live demo (`berthd.app/demo/`), the box's + install line (`berthd.app/install`), the plugin listing and the + **Review in Shipyard** bounce page stay on berthd.app: they are built and + published from the `cosscom/shipyard` repository. + +```sh +bun install +bun run dev --filter=shipyard # http://localhost:4100/shipyard +``` diff --git a/apps/shipyard/app/api/raw/[...slug]/route.ts b/apps/shipyard/app/api/raw/[...slug]/route.ts new file mode 100644 index 000000000..b2eb6fbaf --- /dev/null +++ b/apps/shipyard/app/api/raw/[...slug]/route.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { type NextRequest, NextResponse } from "next/server"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ slug: string[] }> }, +) { + const { slug } = await params; + + // Construct the file path from the slug + // If we have ['docs', 'filename'], look in (root) directory + let filePath: string; + if (slug.length === 2 && slug[0] === "docs") { + filePath = path.join( + process.cwd(), + "content", + "docs", + "(root)", + slug[1] || "", + ); + } else { + filePath = path.join(process.cwd(), "content", ...slug); + } + + // Check for both .mdx and .md extensions + const mdxPath = filePath.endsWith(".mdx") ? filePath : `${filePath}.mdx`; + const mdPath = filePath.endsWith(".md") ? filePath : `${filePath}.md`; + + try { + let content: string; + + // Try .mdx first, then .md + try { + content = await fs.readFile(mdxPath, "utf-8"); + } catch { + content = await fs.readFile(mdPath, "utf-8"); + } + + // Return raw markdown with appropriate headers + return new NextResponse(content, { + headers: { + "Cache-Control": "public, max-age=3600", + "Content-Type": "text/plain; charset=utf-8", + }, + }); + } catch { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } +} diff --git a/apps/shipyard/app/apple-icon.png b/apps/shipyard/app/apple-icon.png new file mode 100644 index 000000000..4402aaa8e Binary files /dev/null and b/apps/shipyard/app/apple-icon.png differ diff --git a/apps/shipyard/app/docs.css b/apps/shipyard/app/docs.css new file mode 100644 index 000000000..1489493c2 --- /dev/null +++ b/apps/shipyard/app/docs.css @@ -0,0 +1,325 @@ +/* Shipyard's own pieces in the docs (components/docs): callouts, the stage + on the introduction, the numbered path of cards, screenshots, and the + reference tables lib/rehype-reference.ts makes linkable. The brand's rules: + one outline all round, never a coloured stripe; amber only for what needs + you. Tokens are coss ui's. */ + +:root { + --berth-amber: #c27a12; + --berth-fg-2: color-mix( + in srgb, + var(--foreground) 78%, + var(--muted-foreground) + ); + --berth-code-bg: var(--muted); + --berth-border-soft: color-mix(in srgb, var(--border) 64%, transparent); +} +.dark { + --berth-amber: #e8a33d; +} + +.berth-callout { + display: flex; + gap: 12px; + margin: 1.25rem 0; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--card); + font-size: 0.9rem; + line-height: 1.6; + color: var(--berth-fg-2); +} +.berth-callout[data-type="warning"] { + border-color: color-mix(in srgb, var(--berth-amber) 55%, var(--border)); +} +.berth-callout[data-type="error"] { + border-color: color-mix(in srgb, var(--destructive) 50%, var(--border)); +} +.berth-callout-icon { + flex: none; + width: 18px; + height: 18px; + margin-top: 2px; + color: var(--muted-foreground); +} +.berth-callout[data-type="warning"] .berth-callout-icon { + color: var(--berth-amber); +} +.berth-callout[data-type="error"] .berth-callout-icon { + color: var(--destructive); +} +.berth-callout-body { + min-width: 0; + display: grid; + gap: 4px; +} +.berth-callout-title { + margin: 0; + font-weight: 600; + color: var(--foreground); +} +.berth-callout-text > * { + margin: 0; +} +.berth-callout-text > * + * { + margin-top: 0.5em; +} +.berth-callout-text a { + color: var(--foreground); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--foreground) 35%, transparent); + text-underline-offset: 3px; +} +.berth-callout-text code { + font-size: 0.86em; + padding: 0.1em 0.32em; + border-radius: 5px; + background: var(--berth-code-bg); + border: 1px solid var(--berth-border-soft); + color: var(--foreground); + overflow-wrap: anywhere; +} +.berth-stage { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 24px; + margin: 8px 0 36px; + padding: 20px 20px 18px 22px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card); +} +.berth-stage-title { + margin: 0 0 10px; + font-size: 14px; + font-weight: 600; + color: var(--foreground); +} +.berth-stage-text { + font-size: 0.9375rem; + line-height: 1.6; +} +.berth-stage-text > :first-child { + margin-top: 0; +} +.berth-stage-text > :last-child { + margin-bottom: 0; +} +.berth-stage-text figure.shiki { + margin: 0 0 12px; + background: var(--background) !important; +} +.berth-stage-art { + align-self: end; + margin: 0 -6px -6px 0; +} +@media (max-width: 720px) { + .berth-stage { + grid-template-columns: minmax(0, 1fr); + padding: 16px; + } + .berth-stage-art { + order: -1; + justify-self: start; + margin: -4px 0 0 -10px; + } + .berth-stage-art svg { + width: 184px; + height: auto; + } +} +.berth-path { + counter-reset: berth-step; +} +.berth-path a[data-card] { + counter-increment: berth-step; + position: relative; + padding-left: 52px; +} +.berth-path a[data-card]::before { + content: counter(berth-step); + position: absolute; + left: 16px; + top: 15px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + font: 500 12px / 1 var(--font-mono); + color: var(--muted-foreground); +} +.berth-nw { + white-space: nowrap; +} +.berth-path a[data-card]::before { + top: 15px; +} +.berth-stage-text a { + color: var(--foreground); + font-weight: 500; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--foreground) 35%, transparent); + text-underline-offset: 3px; +} +.berth-stage-text :not(pre) > code { + font-size: 0.86em; + padding: 0.1em 0.34em; + border-radius: 5px; + background: var(--background); + border: 1px solid var(--berth-border-soft); + color: var(--foreground); +} +.berth-stage-text p { + color: var(--berth-fg-2); +} +.berth-stage-text figure.shiki { + --berth-code-ground: var(--background); +} +dl.berth-ref { + margin: 1.25rem 0 1.75rem; + border: 1px solid var(--border); + border-radius: 12px; +} +.berth-ref-item { + position: relative; + padding: 11px 40px 12px 16px; + scroll-margin-top: 7rem; + transition: background-color 0.15s; +} +.berth-ref-item + .berth-ref-item { + border-top: 1px solid var(--berth-border-soft); +} +.berth-ref-item:first-child { + border-radius: 11px 11px 0 0; +} +.berth-ref-item:last-child { + border-radius: 0 0 11px 11px; +} +.berth-ref-item:only-child { + border-radius: 11px; +} +.berth-ref-sig { + margin: 0; + font-weight: 400; + line-height: 1.55; +} +.berth-ref-sig code { + padding: 0; + border: 0; + background: none; + font-size: 13.5px; + font-weight: 500; + color: var(--foreground); +} +.berth-ref-desc { + margin: 3px 0 0; + padding: 0; + font-size: 0.875rem; + line-height: 1.6; + color: var(--berth-fg-2); +} +.berth-ref-desc > :first-child { + margin-top: 0; +} +a.berth-ref-anchor { + position: absolute; + top: 10px; + right: 10px; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 6px; + font: 500 13px / 1 var(--font-mono); + color: var(--muted-foreground); + text-decoration: none; + opacity: 0; + transition: + opacity 0.15s, + background-color 0.15s, + color 0.15s; +} +.berth-ref-item:hover a.berth-ref-anchor, +a.berth-ref-anchor:focus-visible, +.berth-ref-item:target a.berth-ref-anchor { + opacity: 1; +} +a.berth-ref-anchor:hover { + color: var(--foreground); + background: var(--accent); +} +@media (hover: none) { + a.berth-ref-anchor { + opacity: 0.6; + } +} +.berth-ref-item:target, +table.berth-fields tr:target > td { + background: var(--accent); +} +table.berth-fields tr { + scroll-margin-top: 7rem; +} +a.berth-field-link { + text-decoration: none; + font-weight: inherit; +} +a.berth-field-link:hover code { + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--foreground) 40%, transparent); + text-underline-offset: 3px; +} +#nd-page .berth-stage-text figure.shiki pre { + width: auto; + min-width: 0; + padding-right: 0; + white-space: pre-wrap; +} +#nd-page .berth-stage-text figure.shiki .line { + white-space: pre-wrap; + overflow-wrap: anywhere; + padding-left: calc(var(--padding-left) + 2ch); + text-indent: -2ch; +} +#nd-page .berth-stage-text figure.shiki .line > span { + display: inline-block; + max-width: 100%; + text-indent: 0; +} +#nd-page .berth-stage-text figure.shiki .line > span:only-child { + display: inline; +} +.berth-shot { + margin: 1.5rem 0 1.75rem; +} +.berth-shot img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--berth-code-bg); +} +.berth-shot-light, +.berth-shot-dark { + display: block; +} +.berth-shot-dark, +.dark .berth-shot-light { + display: none; +} +.dark .berth-shot-dark { + display: block; +} +.berth-shot-caption { + margin: 10px 2px 0; + max-width: 68ch; + font-size: 13.5px; + line-height: 1.55; + color: var(--muted-foreground); +} diff --git a/apps/shipyard/app/docs/[[...slug]]/page.tsx b/apps/shipyard/app/docs/[[...slug]]/page.tsx new file mode 100644 index 000000000..677b35f55 --- /dev/null +++ b/apps/shipyard/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,102 @@ +import { Card, CardFrame, CardPanel } from "@coss/ui/components/card"; +import { ScrollArea } from "@coss/ui/components/scroll-area"; +import { notFound } from "next/navigation"; +import { DocsCopyPage } from "@/components/docs-copy-page"; +import { DocsTableOfContents } from "@/components/docs-toc"; +import { SiteFooter } from "@/components/site-footer"; +import { source } from "@/lib/source"; +import { mdxComponents } from "@/mdx-components"; + +export const revalidate = false; +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata(props: { + params: Promise<{ slug?: string[] }>; +}) { + const params = await props.params; + const page = source.getPage(params.slug); + + if (!page) { + notFound(); + } + + const doc = page.data; + + if (!doc.title || !doc.description) { + notFound(); + } + + return { + description: doc.description, + title: `${doc.title} - Shipyard docs`, + }; +} + +export default async function Page(props: { + params: Promise<{ slug?: string[] }>; +}) { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) { + notFound(); + } + + const doc = page.data; + const rawContent = await page.data.getText("raw"); + const MDX = doc.body; + + return ( +
+
+ + + +
+
+
+
+

+ {doc.title} +

+ {doc.description && ( +

+ {doc.description} +

+ )} +
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ {doc.toc?.length ? : null} +
+ +
+
+ ); +} diff --git a/apps/shipyard/app/docs/layout.tsx b/apps/shipyard/app/docs/layout.tsx new file mode 100644 index 000000000..0da66e977 --- /dev/null +++ b/apps/shipyard/app/docs/layout.tsx @@ -0,0 +1,18 @@ +import { SidebarProvider } from "@coss/ui/components/sidebar"; +import { DocsSidebar } from "@/components/docs-sidebar"; +import { source } from "@/lib/source"; + +export default function DocsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + +
{children}
+
+
+ ); +} diff --git a/apps/shipyard/app/globals.css b/apps/shipyard/app/globals.css new file mode 100644 index 000000000..a2ca26cce --- /dev/null +++ b/apps/shipyard/app/globals.css @@ -0,0 +1,12 @@ +@import "@coss/ui/globals.css"; +@import "./landing.css"; +@import "./docs.css"; +@import "../components/docs/diagram.css"; +@import "../components/docs/scenes.css"; + +@layer base { + a:active, + button:active { + @apply opacity-72 md:opacity-100; + } +} diff --git a/apps/shipyard/app/icon.svg b/apps/shipyard/app/icon.svg new file mode 100644 index 000000000..df2be7ab9 --- /dev/null +++ b/apps/shipyard/app/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/shipyard/app/landing.css b/apps/shipyard/app/landing.css new file mode 100644 index 000000000..4d834ceeb --- /dev/null +++ b/apps/shipyard/app/landing.css @@ -0,0 +1,766 @@ +/* Shipyard's landing page, as it was at berthd.app (the repository's + site/styles.css), scoped to .shipyard-landing under coss.com's layout and + header. Its own type (Inter, JetBrains Mono) and tokens (Shipyard Light + and Shipyard Dark, by the site's theme class) and coss-ui's buttons. The + harbour at the top is the only picture that isn't a screenshot. The few + url()s here are under the site's base path (next.config.ts). */ + +.shipyard-landing { + --bg: #ffffff; + --card: #ffffff; + --muted-bg: #f4f4f6; + --border: #e6e6ea; + --input: #dcdce1; + --fg: #26272b; + --fg-2: #45474e; + --muted: #62646d; + --primary: #26272b; + --primary-fg: #ffffff; + --amber: #c27a12; + --ring: #8b8d96; + --sea: #eef1f6; + --blue: #3b82f6; + --green: #16a34a; + --shadow: + 0 1px 2px rgb(20 22 30 / 0.04), 0 24px 60px -28px rgb(20 22 30 / 0.28); + --sans: + var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", + sans-serif; + --mono: var(--font-jetbrains-mono), ui-monospace, "SF Mono", Menlo, monospace; + --band: clamp(340px, 58vh, 600px); + color-scheme: light; +} +/* Night: the system's dark mode, unless the page was put to day; or the + page put to night by "Close the laptop". */ +.dark .shipyard-landing { + /* coss.com's dark ground, so the page sits flush under its header. */ + --bg: #141414; + --card: #1f2024; + --muted-bg: #222327; + --border: #2c2d33; + --input: #34353c; + --fg: #e3e4e8; + --fg-2: #c0c2c9; + --muted: #9a9ca5; + --primary: #e3e4e8; + --primary-fg: #18191c; + --amber: #e8a33d; + --ring: #6b6d76; + --sea: #131824; + --green: #4ade80; + --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 30px 80px -30px rgb(0 0 0 / 0.75); + color-scheme: dark; +} +.shipyard-landing { + position: relative; + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px / 1.6 var(--sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +.shipyard-landing img, +.shipyard-landing svg, +.shipyard-landing canvas { + display: block; +} +.shipyard-landing a { + color: inherit; +} +.shipyard-landing :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: 6px; +} +.shipyard-landing h1, +.shipyard-landing h2 { + margin: 0; + font-weight: 600; + letter-spacing: -0.022em; + text-wrap: balance; +} +.shipyard-landing p { + margin: 0; +} +.shipyard-landing code { + font-family: var(--mono); +} +.shipyard-landing .wrap { + width: min(1040px, 100% - 32px); + margin-inline: auto; +} +/* coss-ui buttons: default (primary) and outline, size default. */ +.shipyard-landing .btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--input); + border-radius: 10px; + background: var(--card); + color: var(--fg); + font: 500 14px / 1 var(--sans); + text-decoration: none; + white-space: nowrap; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.05); + transition: + background-color 0.15s, + border-color 0.15s; +} +.shipyard-landing .btn:hover { + background: var(--muted-bg); +} +.shipyard-landing .btn.primary { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-fg); + box-shadow: + inset 0 1px rgb(255 255 255 / 0.16), + 0 1px 2px rgb(0 0 0 / 0.12); +} +.shipyard-landing .btn.primary:hover { + opacity: 0.9; +} +.shipyard-landing .btn svg { + width: 16px; + height: 16px; + margin-inline: -2px; +} +.shipyard-landing .actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} +.shipyard-landing .site-header-inner { + display: flex; + align-items: center; + gap: 8px; + height: 100%; + max-width: 1416px; + margin: 0 auto; + padding: 0 16px; +} +/* coss-ui's ghost button, size sm. */ +/* A hairline between the page's links and the repository's. */ +/* The theme toggle is an icon, as on coss.com; its wording is for readers. */ +@media (min-width: 640px) { + .shipyard-landing .site-header-inner { + padding: 0 24px; + } +} +.shipyard-landing .rule { + pointer-events: none; + max-width: 1416px; + margin: 0 auto; +} +.shipyard-landing .rule { + position: relative; + z-index: 7; + height: 0; + --mark-top: -3.5px; +} +.shipyard-landing .marks::before, +.shipyard-landing .marks::after { + content: ""; + position: absolute; + top: var(--mark-top); + width: 8px; + height: 8px; + border: 1px solid var(--border); + border-radius: 2px; + background: var(--bg); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.05); +} +.shipyard-landing .marks::before { + left: -15.5px; +} +.shipyard-landing .marks::after { + right: -15.5px; +} +/* Narrower than the header's width plus the rails, they'd sit at the window's + edge or past it. */ +@media (max-width: 1441px) { + .shipyard-landing .marks::before, + .shipyard-landing .marks::after { + display: none; + } +} +.shipyard-landing .chapter.ruled { + position: relative; +} +.shipyard-landing .chapter.ruled::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: color-mix(in srgb, var(--border) 64%, transparent); +} +/* Hero: the harbour band, then the pitch at its foot, where it dissolves. */ +.shipyard-landing .hero { + position: relative; + /* The whole page: the harbour and what's under it fill the window. */ + min-height: 100svh; + padding-bottom: 48px; +} +.shipyard-landing .harbour { + position: relative; + height: var(--band); + overflow: hidden; +} +.shipyard-landing .harbour picture, +.shipyard-landing .harbour img { + width: 100%; + height: 100%; +} +.shipyard-landing .harbour img { + object-fit: cover; + object-position: 50% 42%; + -webkit-mask-image: linear-gradient(black 50%, transparent); + mask-image: linear-gradient(black 50%, transparent); + transition: opacity 0.4s; +} +.shipyard-landing .harbour canvas { + position: absolute; + inset: 0; + image-rendering: pixelated; + opacity: 0; + transition: opacity 0.4s; +} +.shipyard-landing .harbour.drawn canvas { + opacity: 1; +} +.shipyard-landing .harbour.drawn img { + opacity: 0; +} +/* The loop, at night: over the drawn harbour in the same box (so nothing + moves), dissolving through the same dots (a mask from assets/hero-loop.js). + It brings its own beam, so the CSS one rests. */ +.shipyard-landing .loop { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.8s; + -webkit-mask: + var(--mask, linear-gradient(black 50%, transparent)) 0 0 / 100% 100% + no-repeat; + mask: + var(--mask, linear-gradient(black 50%, transparent)) 0 0 / 100% 100% + no-repeat; +} +/* The video itself is a pixel, under the canvas its frames are drawn on. */ +.shipyard-landing .loop video { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; +} +.shipyard-landing .loop canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.shipyard-landing .harbour.live .loop { + opacity: 1; +} +.shipyard-landing .harbour.live > canvas { + transition-delay: 0.8s; +} +.shipyard-landing .harbour.live > canvas, +.shipyard-landing .harbour.live .beam { + opacity: 0 !important; +} +.shipyard-landing .harbour .loop canvas { + opacity: 1; + image-rendering: auto; + transition: none; +} +.shipyard-landing .hero-copy { + position: relative; + width: min(720px, 100% - 32px); + margin: calc(var(--band) * -0.3) auto 0; + text-align: center; +} + +.shipyard-landing .hero h1 { + font-size: clamp(32px, 5.2vw, 54px); + line-height: 1.06; + text-shadow: + 0 0 10px var(--bg), + 0 0 24px var(--bg); +} +.shipyard-landing .lede { + max-width: 560px; + margin: 16px auto 24px; + color: var(--fg-2); + font-size: 17px; + text-shadow: 0 0 8px var(--bg); + text-wrap: balance; +} +.shipyard-landing .install { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + max-width: 100%; + margin: 18px auto 0; + padding: 4px 4px 4px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--muted-bg); + text-align: left; +} +.shipyard-landing .install-label { + flex: none; + color: var(--muted); + font-size: 12px; +} +.shipyard-landing .install code { + overflow-x: auto; + font-size: 13px; + white-space: nowrap; + scrollbar-width: none; +} +.shipyard-landing .copy { + flex: none; + height: 28px; + padding: 0 10px; + border: 1px solid var(--input); + border-radius: 8px; + background: var(--card); + color: var(--fg); + font: 500 12.5px var(--sans); + cursor: pointer; +} +.shipyard-landing .copy:hover { + background: var(--bg); +} +.shipyard-landing .note { + margin-top: 14px; + color: var(--muted); + font-size: 13px; +} +.shipyard-landing .note a { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--fg-2); + font-weight: 500; + text-decoration: none; +} +.shipyard-landing .note a:hover { + color: var(--fg); +} +.shipyard-landing .note svg { + width: 13px; + height: 13px; +} +/* The Linux app is an alpha: a quiet line under the Mac download. */ +.shipyard-landing .note.linux-alpha { + margin-top: 4px; + font-size: 12px; +} +.shipyard-landing .note.linux-alpha a { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--muted); +} +.shipyard-landing .kicker { + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + font: 500 13px / 1 var(--mono); +} +/* A segmented switch over several views. */ +.shipyard-landing .seg { + display: flex; + width: fit-content; + margin: 0 auto 18px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--muted-bg); +} +.shipyard-landing .seg button { + height: 30px; + padding: 0 14px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); + font: 500 13.5px var(--sans); + cursor: pointer; +} +.shipyard-landing .seg button:hover { + color: var(--fg); +} +.shipyard-landing .seg button[aria-pressed="true"] { + background: var(--card); + color: var(--fg); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.08), + 0 0 0 1px var(--border); +} +.shipyard-landing .stack { + display: grid; +} +.shipyard-landing .stack > [aria-hidden="true"] { + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s, + visibility 0s 0.3s; +} +.shipyard-landing .sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +@media (max-width: 640px) { + .shipyard-landing { + --band: clamp(300px, 52vh, 420px); + } + .shipyard-landing .hero-copy { + margin-top: calc(var(--band) * -0.22); + } + .shipyard-landing .harbour img { + object-position: 8% 42%; + } + .shipyard-landing .lede { + font-size: 16px; + } + .shipyard-landing .install-label { + display: none; + } + /* The install section is a scroll away, and the width is for the brand. */ +} +@media (prefers-reduced-motion: reduce) { + .shipyard-landing .harbour canvas, + .shipyard-landing .harbour img { + transition: none; + } +} +/* The lighthouse's beam: a wedge of halftone dots from the lamp, sweeping + slowly. Only transform moves; it rests when the page is hidden and for + reduced motion. Brighter at night. */ +.shipyard-landing .beam { + position: absolute; + z-index: 1; + width: min(70vw, 900px); + height: 220px; + margin-top: -110px; + transform-origin: 0 50%; + background: linear-gradient( + to right, + rgb(255 236 190 / 0.9), + rgb(255 236 190 / 0.35) 40%, + transparent 85% + ); + clip-path: polygon(0 48%, 100% 0, 100% 100%, 0 52%); + -webkit-mask: + radial-gradient(circle at 1px 1px, #000 0.8px, transparent 1px) 0 0 / 3px + 3px; + mask: + radial-gradient(circle at 1px 1px, #000 0.8px, transparent 1px) 0 0 / 3px + 3px; + opacity: 0; + pointer-events: none; + animation: sweep 11s ease-in-out infinite alternate; + transition: opacity 0.8s; +} +.shipyard-landing .harbour.drawn .beam { + opacity: 0.32; +} +.dark .shipyard-landing .harbour.drawn .beam { + opacity: 0.85; +} +@keyframes sweep { + from { + transform: rotate(-14deg); + } + to { + transform: rotate(10deg); + } +} +.shipyard-landing[data-page-hidden] .beam { + animation-play-state: paused; +} +/* "Close the laptop": puts the page to night, and says what keeps going. */ +/* Until the script shows it, the switch keeps its place, so the header + never moves. */ +@keyframes pulse { + 50% { + opacity: 0.35; + } +} +@media (prefers-reduced-motion: reduce) { + .shipyard-landing .beam { + animation: none; + transform: rotate(-4deg); + } +} + +@keyframes film-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } +} + +@media (prefers-reduced-motion: reduce) { + .shipyard-landing .loop, + .shipyard-landing .harbour.live > canvas { + transition: none; + } +} +/* Below the harbour: the film's story in six chapters. Each opens with one + of the app's line scenes (assets/scenes.css), its one amber light, then the + film's caption and a few words, and a short loop of the real app. */ +.shipyard-landing .chapter { + padding-top: clamp(72px, 10vw, 128px); +} +.shipyard-landing .chapter .wrap { + width: min(1120px, 100% - 32px); +} +/* The hero already fills the window; the first chapter follows closer. */ +.shipyard-landing #start { + padding-top: clamp(40px, 6vw, 72px); +} +.shipyard-landing .chapter-head { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 14px 64px; + align-items: end; + margin-bottom: clamp(28px, 4vw, 40px); +} +.shipyard-landing .chapter-mark { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 16px; + /* Room for what rises above the scene's box: a mast, a beam, a sun. */ + padding-top: 14px; + margin-bottom: 6px; +} +.shipyard-landing .chapter-mark.centered { + flex-direction: column; + gap: 10px; + margin-bottom: 18px; +} +/* A line scene's slot, its size kept until the drawing arrives. */ +.shipyard-landing .scene { + --muted-foreground: var(--muted); + --warning: var(--amber); + flex: none; + display: block; + width: 144px; + aspect-ratio: 160 / 52; +} +.shipyard-landing .scene.dawn { + width: 208px; +} +.shipyard-landing .scene svg { + width: 100%; + height: auto; +} +.shipyard-landing .chapter h2 { + font-size: clamp(30px, 4.2vw, 48px); + line-height: 1.05; +} +.shipyard-landing .chapter-head > p { + max-width: 470px; + color: var(--fg-2); + font-size: 16.5px; + text-wrap: pretty; +} +/* A loop: its poster sizes the frame; the video fades in over it once it + plays. The footage is the app in Shipyard Dark, so the frame is dark too. */ +.shipyard-landing .clip { + position: relative; + margin: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: #1d1e22; + box-shadow: var(--shadow); +} +.shipyard-landing .clip img { + width: 100%; + height: auto; +} +/* A poster not asked for yet keeps its frame, and shows no alt text. */ +.shipyard-landing .clip img[data-src] { + visibility: hidden; +} +.shipyard-landing .clip video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.4s; +} +.shipyard-landing .clip.playing video { + opacity: 1; +} +.shipyard-landing .views .seg { + margin-bottom: 16px; +} +.shipyard-landing .stack > .clip { + grid-area: 1 / 1; + transition: opacity 0.3s; +} +/* It keeps going: the night comes in through the harbour's dots, whatever + the theme, and goes out the same way. */ +.shipyard-landing .chapter.after-dark { + padding-top: clamp(56px, 7vw, 96px); +} +.shipyard-landing .night-in, +.shipyard-landing .night-out { + height: 64px; + background: #18191c; + -webkit-mask: url("/shipyard/assets/seam.svg") 0 0 / 16px 64px repeat-x; + mask: url("/shipyard/assets/seam.svg") 0 0 / 16px 64px repeat-x; +} +.shipyard-landing .night-out { + transform: scaleY(-1); +} +.shipyard-landing .night-body { + --bg: #18191c; + --card: #1f2024; + --muted-bg: #222327; + --border: #2c2d33; + --input: #34353c; + --fg: #e3e4e8; + --fg-2: #c0c2c9; + --muted: #9a9ca5; + --amber: #e8a33d; + --shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 30px 80px -30px rgb(0 0 0 / 0.75); + padding: clamp(40px, 6vw, 72px) 0 clamp(48px, 7vw, 88px); + background: var(--bg); + color: var(--fg); + color-scheme: dark; +} +.shipyard-landing .night-body .clip { + border-color: #2c2d33; +} +.shipyard-landing .boxes { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px 10px; + margin-top: 18px; + color: var(--fg-2); + font: 500 12.5px / 1 var(--mono); +} +.shipyard-landing .boxes span { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px 7px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card); +} +.shipyard-landing .boxes i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 8px rgb(74 222 128 / 0.6); + animation: pulse 2.6s ease-in-out infinite; +} +.shipyard-landing .boxes span + span i { + animation-delay: -1.3s; +} +.shipyard-landing[data-page-hidden] .boxes i { + animation-play-state: paused; +} +/* Install: the end, at dawn. */ +.shipyard-landing .install-ch { + padding-bottom: clamp(88px, 11vw, 136px); + text-align: center; +} +.shipyard-landing .lede-2 { + max-width: 460px; + margin: 14px auto 26px; + color: var(--fg-2); + font-size: 17px; + text-wrap: balance; +} +.shipyard-landing .ends { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px 24px; + margin-top: 26px; + font-size: 14.5px; +} +.shipyard-landing .ends a { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--fg-2); + font-weight: 500; + text-decoration: none; +} +.shipyard-landing .ends a:hover { + color: var(--fg); +} +.shipyard-landing .ends svg { + width: 14px; + height: 14px; +} +@media (max-width: 760px) { + .shipyard-landing .chapter-head { + grid-template-columns: 1fr; + gap: 12px; + } + .shipyard-landing .scene { + width: 120px; + } + .shipyard-landing .scene.dawn { + width: 168px; + } + /* On a phone, the frame closes in on the app's main pane. */ + .shipyard-landing .clip img, + .shipyard-landing .clip video { + aspect-ratio: 4 / 3; + height: auto; + object-fit: cover; + object-position: 64% 40%; + } + .shipyard-landing .clip video { + height: 100%; + } + .shipyard-landing .views .seg button { + padding: 0 10px; + font-size: 13px; + } +} +@media (prefers-reduced-motion: reduce) { + .shipyard-landing .boxes i { + animation: none; + } + .shipyard-landing .clip video, + .shipyard-landing .stack > .clip { + transition: none; + } +} +@media (max-width: 760px) { + .shipyard-landing .night-body .clip img, + .shipyard-landing .night-body .clip video { + object-position: 12% 50%; + } +} +/* A phone's header: the brand a touch smaller, and the links as icons + (styles for 640px and under, above). */ diff --git a/apps/shipyard/app/layout.tsx b/apps/shipyard/app/layout.tsx new file mode 100644 index 000000000..d5c721fd0 --- /dev/null +++ b/apps/shipyard/app/layout.tsx @@ -0,0 +1,53 @@ +import "./globals.css"; + +import { fontMono, fontSans } from "@coss/ui/fonts"; +import { ThemeProvider } from "@coss/ui/shared/theme-provider"; +import type { Metadata } from "next"; +import { SiteHeader } from "@/components/site-header"; +import { appConfig } from "@/lib/config"; +import { inter, jetbrainsMono } from "@/lib/fonts"; + +export const metadata: Metadata = { + description: appConfig.description, + metadataBase: new URL("https://coss.com"), + openGraph: { + description: appConfig.description, + siteName: "Shipyard", + title: "Shipyard - coding agents on your own boxes", + type: "website", + url: appConfig.url, + }, + title: { + default: "Shipyard - coding agents on your own boxes", + template: "%s - Shipyard", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
+