diff --git a/src/app/(home)/layout.tsx b/src/app/(home)/layout.tsx index b8b904e..b9c8184 100644 --- a/src/app/(home)/layout.tsx +++ b/src/app/(home)/layout.tsx @@ -1,6 +1,6 @@ import { HomeLayout } from "fumadocs-ui/layouts/home"; -import { baseOptions } from "@/lib/layout.shared"; +import { homeOptions } from "@/lib/layout.shared"; export default function Layout({ children }: LayoutProps<"/">) { - return {children}; + return {children}; } diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 4dc322a..65a3cbc 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -3,83 +3,64 @@ import Image from "next/image"; import { ArrowRight } from "lucide-react"; import { repos } from "@/lib/source"; import { resolveProjectIcon } from "@/lib/icon"; -import { ProjectGrid } from "@/components/project-grid"; - -const LED_CLUSTERS = [ - { x: "16%", y: 140, size: 34, delay: "0s", duration: "4s" }, - { x: "74%", y: 95, size: 30, delay: "0.8s", duration: "6s" }, - { x: "84%", y: 205, size: 28, delay: "1.4s", duration: "5.2s" }, - { x: "8%", y: 265, size: 26, delay: "1.9s", duration: "4.2s" }, - { x: "26%", y: 310, size: 24, delay: "2.3s", duration: "4.6s" }, - { x: "90%", y: 360, size: 22, delay: "3.1s", duration: "5s" }, -]; +import { GradientRule } from "@/components/brand/gradient-rule"; +import { WordmarkField } from "@/components/brand/wordmark-field"; export default function HomePage() { return ( <>
-
-
- {LED_CLUSTERS.map((led) => ( -
- ))} + -
+
TrickFire Robotics -

- TrickFire Robotics -

-

- Documentation and guides for technologies and repositories Trickfire +

TrickFire Robotics

+

+ Documentation and guides for technologies and repositories TrickFire uses.

-
- +
+ +
+ +
+
{repos.map((repo) => ( - - - - - {resolveProjectIcon(repo.icon, "size-[1.15rem]")} + + {/* green reads as an outline at rest and fills on hover - + seven solid #00FE00 blocks at once is a lot of shouting */} + + {resolveProjectIcon(repo.icon, "size-4")} - + {repo.name} - + {repo.description && ( - + {repo.description} )} ))} - +
diff --git a/src/app/[repo]/[[...slug]]/page.tsx b/src/app/[repo]/[[...slug]]/page.tsx index 199bd74..66fbf85 100644 --- a/src/app/[repo]/[[...slug]]/page.tsx +++ b/src/app/[repo]/[[...slug]]/page.tsx @@ -1,10 +1,9 @@ import { firstPageUrl, getSource, repoIds } from "@/lib/source"; import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page"; import { notFound, redirect } from "next/navigation"; -import { getMDXComponents } from "@/components/mdx"; +import { Card, getMDXComponents } from "@/components/mdx"; import type { Metadata } from "next"; import { createRelativeLink } from "fumadocs-ui/mdx"; -import { Card } from "fumadocs-ui/components/card"; interface PageData { title: string; diff --git a/src/app/brand.css b/src/app/brand.css new file mode 100644 index 0000000..cecd855 --- /dev/null +++ b/src/app/brand.css @@ -0,0 +1,180 @@ +/* + * TrickFire Robotics brand layer - source of truth. + * + * Mirrors `website/src/_variables.scss` so the two codebases can't drift. Token + * names match that file one-for-one ($TrickFireGreen -> --tf-green, etc.) so + * porting either direction is a rename, not a redesign. + * + * Everything here traces to style-guide.pdf v1.0 (August 2025). Page refs are + * noted where a rule is prescriptive rather than a judgement call. + */ + +/* --- tokens ------------------------------------------------------------- */ + +:root { + /* palette - style guide p.8. These six are the entire sanctioned set. */ + --tf-green: #00fe00; /* TrickFire Green */ + --tf-pink: #e93cac; /* Accent Pink */ + --tf-eerie-black: #222222; /* Eerie Black */ + --tf-white: #ffffff; /* White - links, emphasis */ + --tf-dark-white: #cccccc; /* Dark White - body text */ + --tf-black: #000000; /* Black */ + + /* channel forms, for rgb(... / alpha). Tailwind v4 runs Lightning CSS, + which rewrites `color-mix(..., var(--x) N%, transparent)` into a legacy + fallback that drops the percentage - i.e. full-brightness #00fe00. + Plain `rgb( / )` is left alone and works everywhere. */ + --tf-green-rgb: 0 254 0; + --tf-pink-rgb: 233 60 172; + --tf-white-rgb: 255 255 255; + + /* shades and tints - style guide p.9 licenses these as palette extensions. + Shade = base + black, tint = base + white, tone = base + gray. */ + /* Three steps of Eerie Black give the page depth. The style guide's own + layout (p.12) is a near-black field with #222222 boxes sitting on it, so + the page floor goes darkest and surfaces lift off it. Without that gap + everything flattens into one sheet. */ + --tf-eerie-deep: #101010; /* page floor, two steps under Eerie Black */ + --tf-eerie-raise: #181818; /* in-prose surface, barely off the floor */ + --tf-eerie-shade: #1a1a1a; /* recessed surface - inputs */ + --tf-eerie-tint: #2e2e2e; /* hover surface, one step over */ + --tf-gray-tone: #a3a3a3; /* metadata text - 7:1 on Eerie Black */ + --tf-pink-tint: #f27ac7; /* warning, distinct from error's full pink */ + --tf-wordmark: #1f1f1f; /* background wordmark - style guide p.12 */ + + /* type - style guide p.7 */ + --tf-font-heading: var(--font-barlow-condensed), "Barlow Condensed", sans-serif; + --tf-font-body: var(--font-overpass), "Overpass", system-ui, sans-serif; + --tf-font-mono: var(--font-roboto-mono), "Roboto Mono", ui-monospace, monospace; + + /* The geometric rule is sharp corners, 1px solid white (p.12). At the + density of a marketing page that reads great; across a 2000-word doc + every box at full white turns into noise. So: full white where a box is + a deliberate accent, a dimmed white for the long-form surfaces that just + need an edge. Same rule, two volumes - p.1 licenses the looser reading. */ + --tf-border-strong: 1px solid var(--tf-white); + --tf-border: 1px solid rgb(var(--tf-white-rgb) / 0.2); + --tf-offset: 0.55rem; /* hard-shadow offset, scaled from BoxShadowImage */ + + /* Reading measure, ~75 characters of Overpass at the docs body size. + Deliberately rem and not ch: `ch` resolves against each element's own + font, so condensed Barlow headings would get a cap ~15% wider than the + paragraphs under them and the two would not line up. */ + --tf-measure: 45rem; + + /* Depth without a blurred ambient shadow: a small hard offset in black, + the same zero-blur idiom as BoxShadowImage, just quiet enough to sit + under every surface on a documentation page. */ + --tf-raise: 3px 3px 0 rgb(0 0 0 / 0.55); + + /* A flat fill reads as a sticker. A 1px lit top edge plus a shallow sheen + falling off down the face reads as a surface catching light from above - + this is the depth that went missing when the corners went square, not + the rounding. Both are white overlays, so they work on any base fill. */ + --tf-panel-sheen: linear-gradient(to bottom, rgb(var(--tf-white-rgb) / 0.035), transparent 55%); + --tf-panel-shadow: inset 0 1px 0 rgb(var(--tf-white-rgb) / 0.07), var(--tf-raise); +} + +/* --- brand primitives --------------------------------------------------- */ + +/* Green -> pink hairline. The website's GradientLine component, minus GSAP - + it wipes in on scroll via an animation-timeline instead of a JS observer. */ +.tf-rule { + display: block; + width: 100%; + height: 0.25rem; + border: none; + background-image: linear-gradient(to right, var(--tf-green), var(--tf-pink)); + transform-origin: left center; +} + +@supports (animation-timeline: view()) { + .tf-rule--reveal { + animation: tf-wipe-in linear both; + animation-timeline: view(); + animation-range: entry 10% cover 28%; + } +} + +@keyframes tf-wipe-in { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +/* Hard offset shadow, zero blur - the website's BoxShadowImage. This is the + brand's hover/depth idiom; ambient blurred shadows are not. */ +.tf-offset-frame { + border: var(--tf-border); + box-shadow: var(--tf-offset) var(--tf-offset) 0 var(--tf-green); +} + +.tf-offset-frame--pink { + box-shadow: var(--tf-offset) var(--tf-offset) 0 var(--tf-pink); +} + +/* Raised panel. Pair with `.tf-lift` for something interactive. */ +.tf-panel { + background-image: var(--tf-panel-sheen); + box-shadow: var(--tf-panel-shadow); +} + +/* Lift on hover: the panel slides out of its own shadow, which swaps to + green. The lit top edge is kept so the face stays lit while it moves. */ +.tf-lift { + transition: + transform 0.15s ease-out, + box-shadow 0.15s ease-out, + background-color 0.15s ease-out; +} + +.tf-lift:hover { + transform: translate(-3px, -3px); + box-shadow: + inset 0 1px 0 rgb(var(--tf-white-rgb) / 0.09), + 6px 6px 0 var(--tf-green); +} + +@media (prefers-reduced-motion: reduce) { + .tf-lift, + .tf-rule--reveal { + transition: none; + animation: none; + } + + .tf-lift:hover { + transform: none; + } +} + +/* RGB split, style guide p.12. Green up-left, pink down-right, black keyline + so the glyph edge survives both. Prominent elements only, per the guide. */ +.tf-glitch { + color: var(--tf-white); + text-shadow: + 1px 1px 0 var(--tf-black), + -1px -1px 0 var(--tf-black), + 1px -1px 0 var(--tf-black), + -1px 1px 0 var(--tf-black), + 4px 4px 0 var(--tf-pink), + -4px -4px 0 var(--tf-green); +} + +/* Background wordmark field - style guide p.12. Giant Barlow Condensed italic + bold TRICKFIRE, one letter of offset per row, barely above the surface. + Rows are rendered to overflow the container and clip at its edges, so the + field reads as full-bleed the way it does on the style guide's own page. */ +.tf-wordmark-field { + font-family: var(--tf-font-heading); + font-style: italic; + font-weight: 700; + font-size: clamp(3.5rem, 9vw, 5.8rem); + line-height: 0.92; + letter-spacing: 0.02em; + color: var(--tf-wordmark); + white-space: nowrap; + user-select: none; +} diff --git a/src/app/global.css b/src/app/global.css index 1c702d1..7d9cd33 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -1,27 +1,65 @@ @import "tailwindcss"; @import "fumadocs-ui/css/black.css"; @import "fumadocs-ui/css/preset.css"; +@import "./brand.css"; @theme inline { - --font-sans: var(--font-overpass), system-ui, sans-serif; - --font-mono: "Roboto Mono", "SFMono-Regular", ui-monospace, monospace; + --font-sans: var(--tf-font-body); + --font-mono: var(--tf-font-mono); + --font-heading: var(--tf-font-heading); +} + +/* Sharp corners, no rounding - style guide p.12. Flattening the radius scale + catches fumadocs' own utilities too: its generated CSS ships `@source + inline(...)` safelists, so every `rounded-*` class in the UI is compiled + against this theme. `rounded-full` is a static value in Tailwind v4 and + survives, which is what we want for avatars and pills. */ +@theme { + --radius-xs: 0px; + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; + --radius-xl: 0px; + --radius-2xl: 0px; + --radius-3xl: 0px; + --radius-4xl: 0px; } :root { - --tf-green: #00fe00; - --tf-green-rgb: 0 254 0; - --tf-pink: #e93cac; - --tf-purple: #a742f5; - --color-fd-idea: var(--tf-purple); - --tf-dot-rgb: 0 0 0; + --color-fd-idea: var(--tf-green); } .dark { - --tf-dot-rgb: 255 255 255; + --color-fd-background: var(--tf-eerie-deep); + --color-fd-foreground: var(--tf-white); + --color-fd-muted: var(--tf-eerie-black); + --color-fd-muted-foreground: var(--tf-gray-tone); + --color-fd-card: var(--tf-eerie-black); + --color-fd-card-foreground: var(--tf-white); + --color-fd-popover: var(--tf-eerie-black); + --color-fd-popover-foreground: var(--tf-white); + --color-fd-secondary: var(--tf-eerie-black); + --color-fd-secondary-foreground: var(--tf-white); + --color-fd-accent: var(--tf-eerie-tint); + --color-fd-accent-foreground: var(--tf-white); + + /* Dimmed white rather than the literal #FFFFFF of the text-box spec. + Full white on every surface of a long document reads as noise; at 20% + the edge still registers and the page stops competing with the prose. + Use `--tf-border-strong` where a box is a deliberate accent. */ + --color-fd-border: rgb(var(--tf-white-rgb) / 0.2); + --color-fd-primary: var(--tf-green); - --color-fd-primary-foreground: #06210a; + --color-fd-primary-foreground: var(--tf-black); --color-fd-ring: var(--tf-green); + + /* Callouts stay inside the sanctioned palette. Warning uses a pink tint + and error full pink so the pair is still distinguishable (p.9). */ + --color-fd-info: var(--tf-white); + --color-fd-success: var(--tf-green); + --color-fd-warning: var(--tf-pink-tint); --color-fd-error: var(--tf-pink); + --color-fd-idea: var(--tf-green); } html { @@ -34,26 +72,349 @@ html > body[data-scroll-locked] { } body { - font-family: var(--font-sans); + font-family: var(--tf-font-body); + background-color: var(--tf-eerie-deep); +} + +/* --- type ---------------------------------------------------------------- */ + +/* Barlow Condensed is the brand's header face (style guide p.7) and every + heading keeps it. All-caps, though, is reserved for the two landmark + levels: the page title and the major section breaks. h3 and below recur + every few paragraphs, and condensed caps at that frequency stops being a + hierarchy and becomes texture. Sentence-case Barlow is still unmistakably + the typeface, and it scans. */ +:is(h1, h2, h3, h4, h5, h6), +.tf-heading { + font-family: var(--tf-font-heading); + font-weight: 600; + color: var(--tf-white); + text-wrap: balance; } +:is(h1, h2), +.tf-heading { + text-transform: uppercase; + letter-spacing: 0.015em; +} + +/* --- the reading column -------------------------------------------------- */ + .prose { - --tw-prose-size: 0.9; + --tw-prose-size: 0.9375; + color: var(--tf-dark-white); + line-height: 1.7; +} + +.prose p { + text-wrap: pretty; +} + +/* Figures and tables are deliberately left uncapped, so code and data can use + the full column while prose holds a readable measure. */ +.prose > :is(p, ul, ol, blockquote, h1, h2, h3, h4, h5, h6) { + max-width: var(--tf-measure); +} + +/* A heading belongs to the text beneath it, so the space above it is several + times the space below. Rhythm is what gives a long page visible sections - + doing that job with rules and colour instead is what made this noisy. */ +/* Scoped to direct children throughout. Headings nested inside components - + accordion triggers, step titles - are UI labels that happen to be heading + elements for outline purposes, and they must not inherit section rhythm or + the document type scale. */ +.prose > :is(h2, h3, h4, h5, h6) { + margin-bottom: 0.6rem; +} + +.prose > h2 { + margin-top: 3.25rem; +} + +.prose > h3 { + margin-top: 2.25rem; +} + +.prose > :is(h4, h5, h6) { + margin-top: 1.75rem; +} + +/* The page title is rendered as a sibling of `.prose`, not inside it, so it + needs its own rule and its own copy of the measure cap. */ +article > h1 { + font-size: 1.95rem; + line-height: 1.15; + margin-bottom: 0.75rem; + max-width: var(--tf-measure); +} + +.prose > h2 { + font-size: clamp(1.65rem, 2.25vw, 1.85rem); + line-height: 1.15; + /* a plain hairline. The green-to-pink rule is a brand signature and it + belongs on the home page, not repeating down every docs page. */ + border-bottom: 1px solid rgb(var(--tf-white-rgb) / 0.1); + padding-bottom: 0.35rem; +} + +.prose > h3 { + font-size: clamp(1.35rem, 1.7vw, 1.5rem); + line-height: 1.25; +} + +.prose > h4 { + font-size: 1.2rem; + line-height: 1.3; +} + +/* Accordion headings are disclosure triggers, not document sections. Give + them the body face at UI size so their rows stay compact and readable. */ +.prose :is(h2, h3, h4, h5, h6)[data-accordion-value] { + font-family: var(--tf-font-body); + font-size: 0.9375rem; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + margin: 0; +} + +.prose :is(p, li) { + color: var(--tf-dark-white); +} + +/* White for emphasis and links - style guide p.8. The underline is a dimmed + green so links are findable without stippling the paragraph. */ +.prose strong { + color: var(--tf-white); + font-weight: 600; +} + +.prose a { + color: var(--tf-white); + text-decoration-color: rgb(var(--tf-green-rgb) / 0.45); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +.prose a:hover { + color: var(--tf-green); + text-decoration-color: var(--tf-green); +} + +/* Square bullets in dim green: the sharp-corner rule (p.12) applied at list + scale, and a brand accent that helps scanning rather than fighting it. */ +.prose ul { + list-style-type: square; +} + +.prose ul > li::marker { + color: rgb(var(--tf-green-rgb) / 0.55); +} + +.prose ol > li::marker { + color: var(--tf-gray-tone); + font-variant-numeric: tabular-nums; +} + +.prose blockquote { + border-inline-start: 2px solid rgb(var(--tf-green-rgb) / 0.5); + padding-inline-start: 1rem; + color: var(--tf-gray-tone); + font-style: normal; +} + +.prose :is(code, kbd, samp, pre) { + font-family: var(--tf-font-mono); +} + +/* Inline code: a quiet fill, no outline. A border on every inline token turns + a technical paragraph into a row of boxes. */ +.prose :not(pre) > code { + border: none; + border-radius: 0; + background-color: var(--tf-eerie-raise); + color: var(--tf-dark-white); + padding: 0.12em 0.36em; + font-size: 0.875em; + font-weight: 400; +} + +/* Code sits one shade off the page floor so it reads as a distinct surface + without becoming the loudest thing in view. + The background belongs on the figure, not the `pre`: the scroll container + between them carries its own vertical padding, so colouring the `pre` alone + paints a band that hugs the text and leaves a margin in the wrong colour. */ +.prose figure.shiki, +figure[data-rehype-pretty-code-figure] { + background-color: var(--tf-eerie-raise); +} + +.prose figure.shiki code { + line-height: 1.6; +} + +/* The filename bar separates from the code by a hairline alone. */ +.prose figure.shiki > div:first-child { + background-color: transparent; + border-color: rgb(var(--tf-white-rgb) / 0.1); +} + +/* Surfaces inside the reading column are held by a hairline - no sheen, no + drop shadow. The panel treatment stays on the home page, where popping off + the background is the point. Depth here comes from the shell, not from + every code block leaning toward the reader. */ +.prose figure.shiki, +.prose table, +.prose div[class*="border-l-4"] { + background-image: none; + box-shadow: none; +} + +.prose :is(figure.shiki, div[class*="border-l-4"]) { + border-color: rgb(var(--tf-white-rgb) / 0.1); +} + +.prose div[class*="border-l-4"] { + background-color: var(--tf-eerie-raise); +} + +/* Tables: horizontal rules only. Vertical gridlines double the line count for + no added meaning, which on a dark surface reads as a cage. */ +.prose table { + border: none; + border-collapse: collapse; + width: 100%; + background-color: transparent; +} + +.prose :is(th, td) { + border: none; + border-bottom: 1px solid rgb(var(--tf-white-rgb) / 0.08); + padding: 0.55rem 0.9rem; + text-align: start; +} + +.prose :is(th, td):first-child { + padding-inline-start: 0; +} + +.prose th { + font-family: var(--tf-font-heading); + text-transform: uppercase; + letter-spacing: 0.07em; + font-size: 0.8125rem; + font-weight: 600; + color: var(--tf-gray-tone); + background-color: transparent; + border-bottom-color: rgb(var(--tf-white-rgb) / 0.18); +} + +.prose tbody tr:last-child :is(th, td) { + border-bottom: none; } .tf-prompt { - color: var(--color-fd-muted-foreground); + color: var(--tf-gray-tone); user-select: none; } +/* --- chrome -------------------------------------------------------------- */ + +/* Marketing UI wants attention; documentation UI wants to disappear. Every + rule below is the quiet half of that split - the brand lives on the home + page, the headings and the accents, and gets out of the way of the prose. + The sidebar takes the page colour so it recedes into the shell instead of + sitting on top of it as a lighter slab. */ #nd-sidebar { - inset-inline-start: 0.625rem; - inset-block: 0.625rem; - border-radius: 0.75rem; - border-inline-end-width: 1px; - border-style: solid; - border-color: var(--color-fd-border); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + inset-inline-start: 0; + inset-block: 0; + border-radius: 0; + border-inline-end: 1px solid rgb(var(--tf-white-rgb) / 0.08); + background-color: var(--tf-eerie-deep); + box-shadow: none; +} + +#nd-sidebar a[href="/"] { + align-self: flex-start; +} + +/* Barlow Condensed is a display face. At nav sizes it reads as squished, and + bumping the size to compensate just makes a louder wall - which is what was + happening here. Nav lists get Overpass, the style guide's own UI/body face + (p.7); Barlow is kept for the folder headings, which are real headers. */ +#nd-sidebar, +#nd-sidebar a, +#nd-sidebar button, +#nd-toc, +#nd-toc a { + font-family: var(--tf-font-body); + letter-spacing: 0; + font-size: 0.875rem; + font-weight: 400; +} + +#nd-sidebar a { + color: var(--tf-gray-tone); +} + +#nd-sidebar a:hover { + color: var(--tf-dark-white); +} + +/* Folder headings keep the brand voice: Barlow, caps, wide tracking - but + small and dim, so they read as quiet dividers rather than announcements. */ +#nd-sidebar div.text-fd-muted-foreground, +#nd-sidebar p { + font-family: var(--tf-font-heading); + color: var(--tf-gray-tone); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.6875rem; + font-weight: 600; +} + +#nd-sidebar button { + font-size: 0.8125rem; +} + +/* Active page: a green marker rail and green text, not a solid green slab. + The old fill was the brightest thing on screen at all times, which is a lot + of weight for "you are here". */ +#nd-sidebar a[data-active="true"] { + background-color: rgb(var(--tf-green-rgb) / 0.08); + color: var(--tf-green); + box-shadow: inset 2px 0 0 var(--tf-green); + font-weight: 500; +} + +#nd-sidebar a[data-active="true"] svg { + color: var(--tf-green); +} + +#nd-nav { + border-bottom: 1px solid rgb(var(--tf-white-rgb) / 0.08); + background-color: var(--tf-eerie-deep); +} + +/* Project tabs: the website's active-link underline, gradient and all. */ +#nd-sidebar [data-active="true"]::after, +#nd-nav [data-active="true"]::after { + background-image: linear-gradient(to right, var(--tf-green), var(--tf-pink)); +} + +/* Table of contents: the quietest thing on the page until you need it. The + only signal is the active entry picking up green. */ +#nd-toc a { + font-size: 0.8125rem; + color: var(--tf-gray-tone); +} + +#nd-toc a:hover { + color: var(--tf-dark-white); +} + +#nd-toc a[data-active="true"] { + color: var(--tf-green); } #nd-docs-layout { @@ -78,115 +439,34 @@ body { padding-inline-start: 1.5625rem; } -#nd-sidebar a[href="/"] { - align-self: flex-start; - margin-top: -0.625rem; -} - -/* --- home page background layers --- */ - -/* Channel-list form of the brand colours. Tailwind v4 runs Lightning CSS, - which rewrites `color-mix(..., var(--x) N%, transparent)` into a legacy - fallback that drops the percentage - i.e. full-brightness #00fe00. Plain - `rgb( / )` is left alone and works everywhere. */ - -/* base dot matrix, masked so it dissolves downward instead of ending on a line */ -.tf-dot-grid { - background-image: radial-gradient( - circle at center, - rgb(var(--tf-dot-rgb) / 0.09) 1px, - transparent 1px - ); - background-size: 22px 22px; - /* solid through the hero, then fades out partway down the card grid so the - two sections sit on one surface instead of meeting at a border */ - -webkit-mask-image: radial-gradient( - ellipse 120% 1100px at 50% -100px, - #000 0%, - #000 35%, - transparent 80% - ); - mask-image: radial-gradient( - ellipse 120% 1100px at 50% -100px, - #000 0%, - #000 35%, - transparent 80% - ); -} - -.tf-hero-glow { - background: radial-gradient( - ellipse 70% 520px at 50% -60px, - rgb(var(--tf-green-rgb) / 0.13) 0%, - transparent 72% - ); -} - -/* lit dots: same grid, same origin as .tf-dot-grid, so they land exactly on - top of base dots - each instance masks itself down to a small cluster */ -.tf-led-cluster { - background-image: radial-gradient( - circle at center, - rgb(var(--tf-green-rgb) / 1) 1px, - transparent 1px - ); - background-size: 22px 22px; - animation: tf-led-pulse 4s ease-in-out infinite; -} - -@keyframes tf-led-pulse { - 0%, - 100% { - opacity: 0.12; - } - 50% { - opacity: 0.8; - } -} - -.tf-logo-glow { - filter: drop-shadow(0 0 24px rgb(var(--tf-green-rgb) / 0.45)); -} - -/* --- project card pointer spotlight --- */ - -/* 1px gradient ring, painted only on the border box */ -.tf-card-ring { - position: absolute; - inset: 0; - border-radius: inherit; - padding: 1px; - pointer-events: none; - background: radial-gradient( - 200px circle at var(--mx, -999px) var(--my, -999px), - rgb(var(--tf-green-rgb) / 0.6), - transparent 65% - ); - -webkit-mask: - linear-gradient(#000, #000) content-box, - linear-gradient(#000, #000); - mask: - linear-gradient(#000, #000) content-box, - linear-gradient(#000, #000); - -webkit-mask-composite: xor; - mask-composite: exclude; -} - -.tf-card-glow { - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - background: radial-gradient( - 240px circle at var(--mx, -999px) var(--my, -999px), - rgb(var(--tf-green-rgb) / 0.08), - transparent 70% - ); -} - -@media (prefers-reduced-motion: reduce) { - .tf-led-cluster { - animation: none; - opacity: 0.35; - } +/* Home nav: brand links left, search right. fumadocs stacks both into one + right-aligned cluster, so let that cluster spread and pull the link list + ahead of the search trigger. */ +#nd-home-layout #nd-nav div.flex-1 { + justify-content: space-between; +} + +#nd-home-layout #nd-nav div.flex-1 > ul { + order: -1; +} + +/* The slot for non-secondary nav links is unused but still carries `px-6`, + which would otherwise indent the icons by 48px. */ +#nd-home-layout #nd-nav ul:empty { + display: none; +} + +/* `rounded-full` is a static value in Tailwind v4, so it escapes the radius + scale override. That's wanted for genuine circles (avatars, the TOC + progress ring), but the search trigger is a box - square it off. */ +#nd-nav :is(button, a).rounded-full, +#nd-sidebar :is(button, a).rounded-full { + border-radius: 0; +} + +/* fumadocs ships popovers as `bg-fd-popover/60 backdrop-blur-lg`. Over a dark + page that reads as a smear rather than a panel, so force them opaque. */ +[class*="bg-fd-popover/"] { + background-color: var(--color-fd-popover); + backdrop-filter: none; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 14378b2..c946cec 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,15 +1,29 @@ import type { Metadata } from "next"; -import { Overpass } from "next/font/google"; -import { DevRibbon } from "@/components/dev-ribbon"; +import { Barlow_Condensed, Overpass, Roboto_Mono } from "next/font/google"; +import { DevBadge } from "@/components/dev-badge"; import { Provider } from "@/components/provider"; import { appName } from "@/lib/shared"; import "./global.css"; +// Style guide p.7: Barlow Condensed Semi Bold for headers, Overpass for body, +// Roboto Mono for code. 700 italic is only used by the background wordmark. +const barlowCondensed = Barlow_Condensed({ + subsets: ["latin"], + weight: ["600", "700"], + style: ["normal", "italic"], + variable: "--font-barlow-condensed", +}); + const overpass = Overpass({ subsets: ["latin"], variable: "--font-overpass", }); +const robotoMono = Roboto_Mono({ + subsets: ["latin"], + variable: "--font-roboto-mono", +}); + export const metadata: Metadata = { title: { default: appName, @@ -20,9 +34,13 @@ export const metadata: Metadata = { export default function Layout({ children }: LayoutProps<"/">) { return ( - + - + {children} diff --git a/src/components/brand/gradient-rule.tsx b/src/components/brand/gradient-rule.tsx new file mode 100644 index 0000000..770cdad --- /dev/null +++ b/src/components/brand/gradient-rule.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/cn"; + +interface GradientRuleProps { + /** Wipe the rule in from the left as it scrolls into view. Defaults to `true`. */ + reveal?: boolean; + className?: string; +} + +/** + * Green-to-pink hairline. The brand's section divider - it rules off every page + * of the style guide and underlines the active link in the website's navbar. + * + * Port of `website/src/components/GradientLine`, which drives the same wipe with + * GSAP ScrollTrigger. Here it runs on a scroll-driven CSS animation instead, so + * it costs no JS and degrades to a static rule where `animation-timeline` is + * unsupported. + */ +export function GradientRule({ reveal = true, className }: GradientRuleProps) { + return
; +} diff --git a/src/components/brand/offset-frame.tsx b/src/components/brand/offset-frame.tsx new file mode 100644 index 0000000..15c63ef --- /dev/null +++ b/src/components/brand/offset-frame.tsx @@ -0,0 +1,29 @@ +import { cn } from "@/lib/cn"; +import type { ComponentProps } from "react"; + +interface OffsetFrameProps extends ComponentProps<"div"> { + /** Shadow colour. Defaults to TrickFire Green. */ + accent?: "green" | "pink"; +} + +/** + * Hard offset shadow with no blur - the brand's depth idiom, lifted from + * `website/src/components/BoxShadowImage` and the framed rover photo on style + * guide p.12. + * + * Use this anywhere a soft ambient `box-shadow` would otherwise go. Blurred + * shadows read as generic material-design depth; the offset block reads as + * TrickFire. + */ +export function OffsetFrame({ accent = "green", className, ...props }: OffsetFrameProps) { + return ( +
+ ); +} diff --git a/src/components/brand/wordmark-field.tsx b/src/components/brand/wordmark-field.tsx new file mode 100644 index 0000000..171022c --- /dev/null +++ b/src/components/brand/wordmark-field.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/cn"; + +const WORDMARK = "TRICKFIRE"; + +// Both counts deliberately overrun the container, which clips them. Sizing +// them to fit would mean measuring at runtime; overrunning costs a few +// thousand characters of static text and can never leave a bald corner. +// +// At the 5.8rem clamp ceiling (92.8px) Barlow Condensed italic averages +// ~0.42em per glyph, so one character is ~39px wide and one line is ~85px +// tall. 16 repeats is 144 characters, about 5600px - past 5K. 27 rows is +// about 2300px, past any portrait display, and being a multiple of 9 keeps +// the one-letter-per-row offset on a whole cycle. +const REPEATS = 16; +const DEFAULT_ROWS = 27; + +interface WordmarkFieldProps { + /** Rows to render. Defaults to a count that overruns any viewport. */ + rows?: number; + className?: string; +} + +/** + * The background text field from style guide p.12: giant Barlow Condensed + * italic bold `TRICKFIRE`, repeated edge to edge, rotated by one letter each + * row so the columns never line up into a visible grid. + * + * This is the brand's way of filling an otherwise empty hero. It replaces the + * dot-matrix-plus-radial-glow treatment, which is generic to every current + * site template and appears nowhere in TrickFire's own material. + */ +export function WordmarkField({ rows = DEFAULT_ROWS, className }: WordmarkFieldProps) { + return ( +
+ {Array.from({ length: rows }, (_, row) => { + // shift the start by one letter per row, per the guide's example + const offset = row % WORDMARK.length; + const shifted = WORDMARK.slice(offset) + WORDMARK.slice(0, offset); + return ( +
+ {shifted.repeat(REPEATS)} +
+ ); + })} +
+ ); +} diff --git a/src/components/dev-badge.tsx b/src/components/dev-badge.tsx new file mode 100644 index 0000000..f831ab2 --- /dev/null +++ b/src/components/dev-badge.tsx @@ -0,0 +1,24 @@ +const MODES: Record = { + cli: { label: "CLI", className: "bg-fd-primary text-black" }, + site: { label: "SITE", className: "bg-[var(--tf-pink)] text-white" }, +}; + +/** + * Marks a local dev build. Deliberately tiny: a corner tab rather than the + * old diagonal ribbon, which sat on top of the sidebar logo and was the + * loudest thing on the page. Bottom-right stays clear of the Next.js + * devtools button (bottom-left) and the table of contents. + */ +export function DevBadge() { + const mode = MODES[process.env.DOCS_DEV_MODE ?? ""]; + if (!mode) return null; + + return ( + + {mode.label} DEV + + ); +} diff --git a/src/components/dev-ribbon.tsx b/src/components/dev-ribbon.tsx deleted file mode 100644 index ae60f2a..0000000 --- a/src/components/dev-ribbon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -const RIBBONS: Record = { - cli: { text: "CLI DEV", className: "bg-amber-500 text-amber-950" }, - site: { text: "SITE DEV", className: "bg-blue-500 text-blue-950" }, -}; - -export function DevRibbon() { - const ribbon = RIBBONS[process.env.DOCS_DEV_MODE ?? ""]; - if (!ribbon) return null; - - return ( -
- - {ribbon.text} - -
- ); -} diff --git a/src/components/mdx.tsx b/src/components/mdx.tsx index 92d3b3c..0abb050 100644 --- a/src/components/mdx.tsx +++ b/src/components/mdx.tsx @@ -1,4 +1,5 @@ import defaultMdxComponents from "fumadocs-ui/mdx"; +import Link from "fumadocs-core/link"; import { Step, Steps } from "fumadocs-ui/components/steps"; import { Tab as TabBase, Tabs as TabsBase } from "fumadocs-ui/components/tabs"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; @@ -9,7 +10,7 @@ import type { ComponentProps, CSSProperties, ReactNode } from "react"; import type { MDXComponents } from "mdx/types"; function Tabs({ className, ...props }: ComponentProps) { - return ; + return ; } function Tab({ className, ...props }: ComponentProps) { @@ -151,9 +152,12 @@ function Callout({ const iconEl = icon ?? calloutIcons[type]; return ( + // style guide p.12 text box: #222222 fill, solid 1px #FFFFFF, sharp + // corners. The semantic colour rides on a thick left edge so callouts + // stay recognisable without inventing a border colour off-palette.
{title ? ( <> -

+

{iconEl} {title}

@@ -185,6 +189,60 @@ function Callout({ ); } +interface CardProps { + /** Already resolved to a URL by the link remark plugin before it lands here. */ + href: string; + title: ReactNode; + icon?: ReactNode; + children?: ReactNode; + className?: string; +} + +/** + * Navigation card. + * + * fumadocs' default stacks the icon in a bordered box on its own row above a + * 14px title. Condensed Barlow at 14px is narrower than the 14px Overpass + * description beneath it, so the title ends up looking like the subordinate + * element, and the stacked rows make every card ~115px tall. + * + * Here the icon shares the title's row and the title carries the weight. + */ +export function Card({ href, title, icon, children, className }: CardProps) { + return ( + + {icon && {icon}} +

+ {title} +

+ {children && ( +
+ {children} +
+ )} + + ); +} + +function Cards({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + export function getMDXComponents(components?: MDXComponents) { return { ...defaultMdxComponents, @@ -195,6 +253,8 @@ export function getMDXComponents(components?: MDXComponents) { Accordion, Accordions, Callout, + Card, + Cards, Image, Icon, WindowsIcon, diff --git a/src/components/nav-title.tsx b/src/components/nav-title.tsx index 3dd392b..912d961 100644 --- a/src/components/nav-title.tsx +++ b/src/components/nav-title.tsx @@ -1,22 +1,32 @@ "use client"; -import Image from "next/image"; import Link from "next/link"; +import { House } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/cn"; // Fumadocs' docs-layout sidebar renders `nav.title` as a component // (`jsx(slots.navTitle, { className })`), so this has to be a component // reference, not an already-instantiated element - and it needs "use client" // so Next can pass the reference across the server -> client layout boundary. +// +// The wordmark was never drawn to hold up at 24px, so the slot takes a home +// icon instead, matching the sidebar's collapse toggle exactly: same ghost +// button, same `icon-sm` sizing, same muted foreground. export function NavTitle({ className }: { className?: string }) { return ( - - TrickFire Robotics + + ); } diff --git a/src/components/project-grid.tsx b/src/components/project-grid.tsx deleted file mode 100644 index ae8450d..0000000 --- a/src/components/project-grid.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; -import { useCallback, useEffect, useRef, type MouseEvent, type ReactNode } from "react"; - -function cards(grid: HTMLDivElement | null): HTMLElement[] { - return grid ? (Array.from(grid.children) as HTMLElement[]) : []; -} - -// Each card gets the pointer position in its *own* coordinate space, so the -// spotlight gradient fades out on its own for cards far from the cursor -// instead of needing a per-card hover check. -export function ProjectGrid({ children }: { children: ReactNode }) { - const ref = useRef(null); - const frame = useRef(0); - - useEffect(() => () => cancelAnimationFrame(frame.current), []); - - const onMove = useCallback(({ clientX, clientY }: MouseEvent) => { - if (frame.current) return; - frame.current = requestAnimationFrame(() => { - frame.current = 0; - for (const card of cards(ref.current)) { - const { left, top } = card.getBoundingClientRect(); - card.style.setProperty("--mx", `${clientX - left}px`); - card.style.setProperty("--my", `${clientY - top}px`); - } - }); - }, []); - - const onLeave = useCallback(() => { - for (const card of cards(ref.current)) { - card.style.removeProperty("--mx"); - card.style.removeProperty("--my"); - } - }, []); - - return ( -
- {children} -
- ); -} diff --git a/src/lib/layout.shared.tsx b/src/lib/layout.shared.tsx index eed0268..96feacd 100644 --- a/src/lib/layout.shared.tsx +++ b/src/lib/layout.shared.tsx @@ -31,6 +31,16 @@ export function baseOptions(): BaseLayoutProps { }; } +/** + * Home page options. The hero already carries the flame logo and the wordmark, + * so the nav drops its title slot entirely - a third mark in the same viewport + * is just repetition. Layout of what's left is handled in `global.css`. + */ +export function homeOptions(): BaseLayoutProps { + const { nav, ...rest } = baseOptions(); + return { ...rest, nav: { ...nav, title: undefined } }; +} + export function projectTabs(): LayoutTab[] { return repos.map((repo) => ({ url: `/${repo.id}`,