diff --git a/.cursor/rules/design-token-structure.mdc b/.cursor/rules/design-token-structure.mdc new file mode 100644 index 00000000..c10294b3 --- /dev/null +++ b/.cursor/rules/design-token-structure.mdc @@ -0,0 +1,145 @@ +--- +description: Design token and theme file structure for global.css and related theme files. Use when creating, editing, refactoring, or reviewing design tokens, CSS variables, Tailwind theme definitions, primitive palettes, semantic aliases, light and dark theme mappings, or global theme scaffolds. Preserve the canonical section order, standard token naming, and clear grouping for primitive palettes, typography, radius, semantic colours, and theme modes. Treat global.css as the source of truth that overrides base shadcn/ui styling when project tokens exist. +globs: + - "**/global.css" + - "**/*theme*.css" + - "**/*tokens*.css" +alwaysApply: false +--- + +# Design Token Structure + +## Core Principles + +- Follow the canonical global theme scaffold and preserve its section order. +- Keep the file easy to scan, extend, and review. +- Group related tokens into clear sections with consistent spacing between them. +- Prefer structure and readability over dense blocks of ungrouped variables. +- Treat the scaffold as a contract for file structure, not a loose suggestion. + +## Relationship to shadcn/ui + +- This global.css defines the authoritative design tokens for the application. +- These tokens intentionally override and extend the default shadcn/ui styling system. +- Do not rely on default shadcn colour, radius, typography, or surface values when project tokens exist. +- Always prefer project-defined tokens such as `--color-*`, `--radius-*`, and `--font-*` over library defaults. +- When adjusting UI styling, modify or extend tokens in global.css rather than hardcoding values in components. +- Treat shadcn components as primitives that consume this token system, not as the source of design decisions. +- The design system flows from tokens to components, not from components to tokens. + +## Language and Naming Conventions + +- Use British English in comments and explanatory prose. +- Preserve standard ecosystem token naming conventions for variables and aliases. +- Use `color`, not `colour`, in token and variable names. +- Use standard Tailwind and CSS token patterns such as `--color-*`, `--font-*`, and `--radius-*`. +- Do not mix naming styles within the same file. + +## Canonical Section Order + +Keep files in this order unless there is a strong reason not to: + +1. imports and plugins +2. custom variants +3. primitive palettes +4. theme aliases and design tokens +5. global defaults +6. light theme (`:root`) +7. dark theme (`.dark`) +8. base layer + +## Primitive Palettes + +- Put raw palette tokens in the primitive palette section. +- This section may contain neutral palettes, brand palettes, support palettes, or other raw colour families used by the design system. +- Use consistent palette families such as `--color--50` through `--color--950` when appropriate. +- Keep each palette family together and ordered by scale. +- Do not scatter primitive palette values across semantic sections. +- Do not use primitive palette tokens directly in components when semantic tokens exist. +- Do not assume any specific palette family unless the design system requires it. + +## Typography Tokens + +- Keep typography tokens together in their own subsection. +- Leave clear spacing around font declarations so new fonts are easy to add. +- Separate raw font-family tokens from semantic font aliases where relevant. +- Avoid duplicating tokens unless the duplication is intentional and documented. +- Prefer semantic font aliases such as `--font-sans` and `--font-mono` for application use. + +## Radius and Primitive Tokens + +- Keep radius tokens grouped together in a dedicated subsection. +- Keep primitive tokens such as base colours, fonts, radius, and shadows separate from semantic aliases. +- Maintain consistent ordering within each subsection. +- Extend token scales deliberately rather than ad hoc. + +## Semantic Aliases + +- Group semantic aliases by domain, for example surface, text, charts, sidebar, or interaction. +- Keep aliases mapped cleanly to semantic variables such as `--background`, `--primary`, and `--sidebar`. +- Make semantic alias blocks easy to scan by breaking them into logical subsections. +- Prefer semantic naming for application usage rather than direct palette references in components. + +## Light and Dark Theme Modes + +- Keep `:root` for light theme values and `.dark` for dark theme overrides. +- Preserve the same semantic token order across light and dark sections wherever possible. +- Keep corresponding light and dark tokens easy to compare. +- When adding a new semantic token, add it to both light and dark theme sections unless there is a deliberate reason not to. +- Mirror structure first, then vary values intentionally. + +## Formatting and Readability + +- Use section comments to break up major areas such as palettes, typography, radius, aliases, and theme modes. +- Leave deliberate blank lines between major sections and between meaningful subsections. +- Keep related declarations visually grouped. +- Avoid long unbroken walls of variables. +- Optimise for future extension as well as current readability. + +## Global Defaults and Base Layer + +- Keep non-token global rules such as body letter spacing in a dedicated global defaults section. +- Keep `@layer base` at the end of the file. +- Use semantic tokens such as `bg-background` and `text-foreground` in the base layer rather than hardcoded values. +- Use semantic font aliases such as `var(--font-sans)` in the base layer rather than hardcoded font-family values. + +## When Creating a New Token File + +- Start from the canonical scaffold rather than generating a token file from scratch. +- Populate the scaffold while preserving section order and grouping. +- Define primitive palettes first, then map them into semantic tokens. +- Keep the scaffold neutral unless the design system clearly requires specific palette families. +- Do not invent bespoke palette structures unless the project genuinely requires them. +- Keep the scaffold’s comments and annotations useful for future contributors. + +## When Refactoring Existing Files + +- Reorganise tokens into the canonical section order. +- Preserve the existing design intent while improving clarity and grouping. +- Normalise comments, spacing, and ordering. +- Remove accidental duplication and flag ambiguous token purpose when necessary. +- Convert messy or mixed structures into one coherent scaffold. + +## Usage in Components + +- Components should consume semantic tokens exposed by this file. +- Do not hardcode colour, radius, typography, or surface values in components when tokens already exist. +- Do not use primitive palette tokens directly in components unless there is a deliberate low-level reason. +- Prefer updating the token layer when the design system changes, rather than patching individual components. +- If shadcn defaults conflict with the project design system, the project token system should win. + +## Review Checklist + +Before finalising, check that: + +- primitive palettes live in one dedicated section +- typography tokens are grouped with enough space to extend them +- radius tokens are grouped separately from colours +- semantic aliases are grouped logically +- light and dark sections mirror each other clearly +- comments use British English +- token names use standard ecosystem spellings such as `color` +- project tokens override shadcn defaults consistently +- no hardcoded colours, radius, typography, or surface values remain where tokens exist +- shadcn components are using semantic tokens rather than raw values +- the file is easy to scan and extend diff --git a/.cursor/rules/ui-best-practices.mdc b/.cursor/rules/ui-best-practices.mdc new file mode 100644 index 00000000..6ec9d2e3 --- /dev/null +++ b/.cursor/rules/ui-best-practices.mdc @@ -0,0 +1,159 @@ +--- +description: UI layout, spacing, alignment, and component styling best practices for consistent, polished interfaces using Tailwind and shadcn/ui. Use when creating or updating UI components, layouts, or styling. +globs: + - "**/*.tsx" + - "**/*.jsx" + - "**/*.ts" + - "**/*.js" +alwaysApply: false +--- + +# UI Best Practices + +## Core Principles +- UI should feel visually balanced, consistent, and intentional. +- Elements should align cleanly and follow a clear spacing rhythm. +- Prefer consistency across components over one-off design decisions. +- Design decisions should scale across the system, not just solve one instance. + +--- + +## Layout and Alignment +- Align elements to a consistent axis wherever possible. +- Avoid visual misalignment between adjacent components (icons, buttons, text, dividers). +- Ensure interactive elements (buttons, inputs, icons) are vertically centred within their containers. +- Maintain consistent horizontal alignment across sections. + +--- + +## Spacing and Rhythm +- Use a consistent spacing scale (Tailwind spacing utilities). +- Ensure padding and margins feel proportional across the UI. +- Avoid elements touching dividers or edges without intentional spacing. +- Maintain consistent spacing between: + - sections + - headings and content + - controls and containers +- Prefer even spacing increments (e.g. 4, 6, 8) over arbitrary values. + +--- + +## Visual Hierarchy +- Establish a clear hierarchy between: + - page titles + - section headers + - labels + - body text +- Ensure typography scales logically across the UI. +- Avoid situations where less important elements appear more prominent than primary content. +- Icons, labels, and controls should feel visually balanced relative to surrounding text. + +--- + +## Icon and Control Consistency +- Use consistent icon sizes within the same UI context. +- Ensure icons align visually with text baselines or container centres. +- Avoid mixing drastically different icon sizes in the same row or toolbar. +- Maintain consistent sizing for similar controls (buttons, dropdowns, icon buttons). + +--- + +## Borders and Dividers +- Ensure borders and separators align across sections. +- Avoid offset or misaligned divider lines. +- Maintain consistent spacing above and below dividers. +- Use subtle, consistent border colours (via tokens). + +--- + +## Component Consistency +- Similar components should share: + - sizing + - spacing + - border radius + - interaction behaviour +- Avoid creating slightly different versions of the same component without reason. +- Prefer updating shared components over duplicating styles. + +--- + +## Design System Consumption +- Always consume semantic tokens from `global.css`. +- Treat `global.css` as the source of truth for colours, typography, radius, and surfaces. +- Do not hardcode colour, radius, typography, or surface values in component files when tokens exist. +- If shadcn defaults conflict with project tokens, project tokens win. +- Prefer Tailwind utility classes that reference tokens (e.g. `bg-background`, `text-foreground`, `border-border`, `ring-ring`). +- Do not use raw palette values directly in components unless intentionally required. + +--- + +## Component Variant Architecture +- Shared UI components should define reusable variants rather than one-off styles. +- Use a variant system such as `class-variance-authority (cva)` for components like: + - Button + - Badge + - Input + - Tabs + - Card +- Define variants such as: + - `primary` + - `secondary` + - `outline` + - `ghost` + - `destructive` + - `link` +- Variants should map to semantic tokens from `global.css`. + +### Example expectations +- `primary` → `bg-primary text-primary-foreground` +- `secondary` → `bg-secondary text-secondary-foreground` +- `outline` → `border border-border bg-background` +- `ghost` → `bg-transparent hover:bg-muted` +- `destructive` → `bg-destructive text-destructive-foreground` + +--- + +## Component Implementation Rules +- Component files (e.g. `button.tsx`) should: + - define variants + - define sizes + - define interaction states (hover, focus, disabled) + - centralise styling logic +- Avoid styling components at usage sites when a shared variant should exist. +- Prefer `asChild` pattern when working with links and buttons. + +--- + +## Interaction States +- Ensure hover, focus, active, and disabled states are defined consistently. +- Use token-based colours for states (e.g. `hover:bg-primary/90`). +- Ensure focus states are accessible and visible (e.g. `focus-visible:ring-ring`). + +--- + +## Accessibility and Feedback +- Maintain sufficient contrast between text and backgrounds. +- Ensure interactive elements have clear affordances. +- Use consistent focus and hover feedback across components. + +--- + +## When Refactoring UI +- Identify inconsistencies in spacing, alignment, and sizing. +- Normalise components to follow the shared system. +- Replace hardcoded values with semantic tokens. +- Consolidate duplicate patterns into shared components. + +--- + +## Review Checklist +Before finalising UI changes, check: +- spacing is consistent and balanced +- elements are aligned correctly +- icon sizes are consistent +- borders and dividers line up +- typography hierarchy is clear +- semantic tokens are used instead of hardcoded values +- shared components are used instead of duplicated styles +- component variants are defined where needed +- shadcn components follow the project design system diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index f0067e8b..936ad462 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -12,6 +12,6 @@ jobs: - name: Setup Biome uses: biomejs/setup-biome@v2 with: - version: latest + version: "2.4.12" - name: Run Biome run: biome ci . \ No newline at end of file diff --git a/api/src/app.ts b/api/src/app.ts index 6db633dd..c1e686a6 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -1,14 +1,12 @@ import express, { Router, text } from "express"; import morgan from "morgan"; - +import { ENV } from "./env"; import { notFound } from "./res"; import bundle from "./routes/bundle"; import preview from "./routes/preview"; import schema from "./routes/schema"; import githubWebhook from "./routes/webhooks.github"; -import { ENV } from "./env"; - const PORT = ENV.PORT; const app = express(); app.use(text()); diff --git a/api/src/bundler/index.ts b/api/src/bundler/index.ts index a428c4e3..6777e5ef 100644 --- a/api/src/bundler/index.ts +++ b/api/src/bundler/index.ts @@ -1,6 +1,5 @@ import { type Config, defaultConfig, parseConfig } from "../config"; import { getGitHubContents, getPullRequestMetadata } from "../utils/github"; -import { escapeHtml } from "../utils/sanitize"; import { replaceMoustacheVariables } from "../utils/variables"; import { BundlerError } from "./error"; import { parseMdx } from "./mdx"; @@ -202,7 +201,6 @@ export class Bundler { }; } catch (e) { console.error(e); - // @ts-ignore throw new BundlerError({ code: 500, name: ERROR_CODES.BUNDLE_ERROR, diff --git a/api/src/bundler/plugins/index.ts b/api/src/bundler/plugins/index.ts index b90d35d1..c557666c 100644 --- a/api/src/bundler/plugins/index.ts +++ b/api/src/bundler/plugins/index.ts @@ -3,16 +3,16 @@ import type { PluggableList } from "@mdx-js/mdx/lib/core"; import remarkComment from "remark-comment"; // Remark Plugins import remarkGfm from "remark-gfm"; +import remarkFixClassname from "./remark-class-names"; import remarkComponentCheck from "./remark-component-check"; import remarkUndeclaredVariables from "./remark-undeclared-variables"; -import remarkFixClassname from "./remark-class-names"; // import { remarkCodeHike } from '@code-hike/mdx'; // import { theme as codeHikeTheme } from './codeHikeTheme'; import { rehypeAccessibleEmojis } from "rehype-accessible-emojis"; -import rehypeUnwrapImages from "rehype-unwrap-images"; // Rehype Plugins import rehypeSlug from "rehype-slug"; +import rehypeUnwrapImages from "rehype-unwrap-images"; import rehypeCodeBlocks from "./rehype-code-blocks"; import rehypeInlineBadges from "./rehype-inline-badges"; diff --git a/api/src/bundler/plugins/rehype-inline-badges.ts b/api/src/bundler/plugins/rehype-inline-badges.ts index 892f15a6..35f3b577 100644 --- a/api/src/bundler/plugins/rehype-inline-badges.ts +++ b/api/src/bundler/plugins/rehype-inline-badges.ts @@ -3,30 +3,40 @@ * @typedef {import('mdast').Content} Content */ -import { isBadge } from "is-badge"; - +import type { Element, Node as HastNode } from "hast"; import type { Node } from "hast-util-heading-rank/lib"; +import { isBadge } from "is-badge"; import { visit } from "unist-util-visit"; +type ElementWithVisited = Element & { visited?: string }; + +function isElementWithVisited(node: HastNode): node is ElementWithVisited { + return node.type === "element"; +} + /** * Provides a list of heading elements in the AST. * @param options * @returns */ export default function rehypeInlineBadges(): (ast: Node) => void { - //@ts-ignore - function visitor(node: NodeWithChildren) { + function visitor(node: HastNode) { + if (!isElementWithVisited(node)) return; node.visited === "true"; - node.children[0].properties.style = "display: inline;"; + const child = node.children?.[0]; + if (child?.type === "element" && child.properties) { + child.properties.style = "display: inline;"; + } } return (ast: Node): void => { - //@ts-ignore visit(ast, containsBadge, visitor); }; } -//@ts-ignore -const containsBadge = (node) => - node.tagName === "a" && - node.children[0].tagName === "img" && - isBadge(node?.children[0]?.properties.src) && - node.visited !== "true"; + +const containsBadge = (node: HastNode): boolean => { + if (!isElementWithVisited(node) || node.tagName !== "a") return false; + const first = node.children?.[0]; + if (first?.type !== "element" || first.tagName !== "img") return false; + const src = first.properties?.src; + return typeof src === "string" && isBadge(src) && node.visited !== "true"; +}; diff --git a/api/src/bundler/plugins/remark-class-names.ts b/api/src/bundler/plugins/remark-class-names.ts index 23ed5179..be32b3cd 100644 --- a/api/src/bundler/plugins/remark-class-names.ts +++ b/api/src/bundler/plugins/remark-class-names.ts @@ -27,10 +27,12 @@ export default function remarkFixClassname(): (tree: Node) => void { node.attributes.push({ type: "mdxJsxAttribute", name: "className", - value: classAttr.value + value: classAttr.value, }); // Remove class attribute - node.attributes = node.attributes.filter((attr) => attr.name !== "class"); + node.attributes = node.attributes.filter( + (attr) => attr.name !== "class", + ); } }); @@ -47,11 +49,13 @@ export default function remarkFixClassname(): (tree: Node) => void { node.attributes.push({ type: "mdxJsxAttribute", name: "className", - value: classAttr.value + value: classAttr.value, }); // Remove class attribute - node.attributes = node.attributes.filter((attr) => attr.name !== "class"); + node.attributes = node.attributes.filter( + (attr) => attr.name !== "class", + ); } }); }; -} \ No newline at end of file +} diff --git a/api/src/bundler/plugins/remark-undeclared-variables.ts b/api/src/bundler/plugins/remark-undeclared-variables.ts index 6755d56c..26afd223 100644 --- a/api/src/bundler/plugins/remark-undeclared-variables.ts +++ b/api/src/bundler/plugins/remark-undeclared-variables.ts @@ -39,7 +39,7 @@ export default function remarkUndeclaredVariables(): (ast: Node) => void { if (node.value && !declared.includes(node.value)) { node.type = "text"; node.data = undefined; - node.value = `\{${node.value}\}`; + node.value = `{${node.value}}`; } } diff --git a/api/src/config/models/favicon.ts b/api/src/config/models/favicon.ts index 7f95f7ac..8283a25e 100644 --- a/api/src/config/models/favicon.ts +++ b/api/src/config/models/favicon.ts @@ -2,9 +2,7 @@ import { z } from "zod"; export default z .union([ - z - .string() - .min(1), // Support for a single path + z.string().min(1), // Support for a single path z.object({ light: z.string().optional(), dark: z.string().optional(), diff --git a/api/src/config/v1.schema.ts b/api/src/config/v1.schema.ts index cca68f91..705f45c1 100644 --- a/api/src/config/v1.schema.ts +++ b/api/src/config/v1.schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { ConfigSchema } from "./schema"; import type { Config, Sidebar } from "./schema"; +import { ConfigSchema } from "./schema"; const V1SidebarItem = z.tuple([ z.coerce.string(), @@ -172,11 +172,9 @@ export const V1ConfigSchema = z config.sidebar = v1.sidebar.map(transformSidebarItem); } else { const sidebar: Record = {}; - Object.entries(v1.sidebar).map((entry) => { - const locale = entry[0]; - const sidebarItems = entry[1]; + for (const [locale, sidebarItems] of Object.entries(v1.sidebar)) { sidebar[locale] = sidebarItems.map(transformSidebarItem); - }); + } config.sidebar = sidebar; } diff --git a/api/src/events/pull_request.closed.ts b/api/src/events/pull_request.closed.ts index 628c913d..bec533cf 100644 --- a/api/src/events/pull_request.closed.ts +++ b/api/src/events/pull_request.closed.ts @@ -18,35 +18,39 @@ export async function onPullRequestClosed( try { // Get all files in the docs directory - const { data: tree } = await octokit.request("GET /repos/{owner}/{repo}/git/trees/{tree_sha}", { - owner: repository.owner.login, - repo: repository.name, - tree_sha: pull_request.merge_commit_sha!, - }); + const { data: tree } = await octokit.request( + "GET /repos/{owner}/{repo}/git/trees/{tree_sha}", + { + owner: repository.owner.login, + repo: repository.name, + tree_sha: pull_request.merge_commit_sha!, + }, + ); // Filter for MDX files in the docs directory and create a map const docsMap = tree.tree - .filter(file => - file.path?.startsWith("docs/") && - file.path.endsWith(".mdx") + .filter( + (file) => file.path?.startsWith("docs/") && file.path.endsWith(".mdx"), ) - .reduce((acc, file) => { - if (file.path) { - acc[file.path] = { - path: file.path, - sha: file.sha, - url: file.url - }; - } - return acc; - }, {} as Record); + .reduce( + (acc, file) => { + if (file.path) { + acc[file.path] = { + path: file.path, + sha: file.sha, + url: file.url, + }; + } + return acc; + }, + {} as Record, + ); console.log("Found docs files:", docsMap); - + // You can now use this map for further processing // For example, store it in a database, trigger builds, etc. - } catch (error) { console.error("Error processing merged PR:", error); } -} \ No newline at end of file +} diff --git a/api/src/events/pull_request.opened.ts b/api/src/events/pull_request.opened.ts index a0173711..58930832 100644 --- a/api/src/events/pull_request.opened.ts +++ b/api/src/events/pull_request.opened.ts @@ -1,6 +1,5 @@ import type { EmitterWebhookEvent } from "@octokit/webhooks"; import { getDomains, getOctokitForInstallation } from "../octokit"; -import { createGitHubCheckRun } from "../utils/github"; export async function onPullRequestOpened( event: EmitterWebhookEvent<"pull_request.opened">, diff --git a/api/src/events/pull_request.synchronize.ts b/api/src/events/pull_request.synchronize.ts index 37e182d0..69dcadb2 100644 --- a/api/src/events/pull_request.synchronize.ts +++ b/api/src/events/pull_request.synchronize.ts @@ -1,5 +1,5 @@ import type { EmitterWebhookEvent } from "@octokit/webhooks"; -import { getDomains, getOctokitForInstallation } from "../octokit"; +import { getOctokitForInstallation } from "../octokit"; import { createGitHubCheckRun } from "../utils/github"; export async function onPullRequestSynchronize( diff --git a/api/src/routes/schema.ts b/api/src/routes/schema.ts index 6f22c8bd..92532c3c 100644 --- a/api/src/routes/schema.ts +++ b/api/src/routes/schema.ts @@ -3,7 +3,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { ConfigSchema } from "../config/schema"; export default async function schema( - req: Request, + _req: Request, res: Response, ): Promise { res.status(200); diff --git a/api/src/routes/webhooks.github.ts b/api/src/routes/webhooks.github.ts index f697c790..e41a2d2e 100644 --- a/api/src/routes/webhooks.github.ts +++ b/api/src/routes/webhooks.github.ts @@ -1,9 +1,8 @@ import { Webhooks } from "@octokit/webhooks"; import type { Request, Response } from "express"; -import { badRequest, ok } from "../res"; - import { onInstallation } from "../events/installation"; import { onPullRequestOpened } from "../events/pull_request.opened"; +import { badRequest, ok } from "../res"; // import { onPullRequestSynchronize } from "../events/pull_request.synchronize"; export default async function githubWebhook( diff --git a/api/src/types.ts b/api/src/types.ts index 3d78df42..43123c1c 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -1,3 +1,3 @@ export type { BundlerOutput } from "./bundler/index"; -export type { BundleResponse, BundleErrorResponse } from "./routes/bundle"; export type { SidebarGroup } from "./config/models/sidebar"; +export type { BundleErrorResponse, BundleResponse } from "./routes/bundle"; diff --git a/api/src/utils/github.ts b/api/src/utils/github.ts index f67aa27e..0b84e08e 100644 --- a/api/src/utils/github.ts +++ b/api/src/utils/github.ts @@ -208,7 +208,7 @@ export async function getPullRequestMetadata( `, owner: owner, repository: repository, - pullRequest: Number.parseInt(pullRequest), + pullRequest: Number.parseInt(pullRequest, 10), }), ); if (error || !response) { @@ -268,7 +268,7 @@ export async function createGitHubCheckRun( let hasErrors = false; - const ms = new Date().getTime(); + const ms = Date.now(); for await (const result of check(new Set(Object.keys(files)), getFileFn)) { if (result.type === "error") { hasErrors = true; @@ -276,7 +276,7 @@ export async function createGitHubCheckRun( results.push(result); } - const timer = new Date().getTime() - ms; + const timer = Date.now() - ms; const errors = results.map((result) => { const tag = result.type === "error" ? "[ERROR]" : "[WARN]"; diff --git a/api/src/utils/variables.ts b/api/src/utils/variables.ts index 451623eb..af025015 100644 --- a/api/src/utils/variables.ts +++ b/api/src/utils/variables.ts @@ -1,4 +1,5 @@ import get from "lodash.get"; + const VARIABLE_REGEX = /{{\s([a-zA-Z0-9_.]*)\s}}/gm; // Replaces an object of variables with their moustache values in a string diff --git a/biome.json b/biome.json index 00de628f..13aaea52 100644 --- a/biome.json +++ b/biome.json @@ -1,17 +1,22 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "files": { - "ignore": [ - "node_modules", - ".git", - ".vercel", - "dist/**", - "website/build/**", - "website/public/**" + "includes": [ + "**", + "!**/node_modules", + "!**/.git", + "!**/.vercel", + "!**/dist", + "!**/.next", + "!**/website/build", + "!**/website/public" ] }, - "organizeImports": { - "enabled": true + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "css": { + "parser": { + "tailwindDirectives": true + } }, "formatter": { "enabled": true, @@ -21,12 +26,24 @@ "enabled": true, "rules": { "recommended": true, + "a11y": { + "noStaticElementInteractions": "warn", + "useAriaPropsSupportedByRole": "warn", + "useFocusableInteractive": "warn", + "useSemanticElements": "warn" + }, "style": { "noNonNullAssertion": "off" }, "suspicious": { "noShadowRestrictedNames": "off", - "noArrayIndexKey": "off" + "noArrayIndexKey": "off", + "noUnknownAtRules": { + "level": "error", + "options": { + "ignore": ["tailwind"] + } + } }, "security": { "noDangerouslySetInnerHtml": "off" @@ -35,5 +52,17 @@ "noUselessFragments": "off" } } - } + }, + "overrides": [ + { + "includes": ["website/src/styles/global.css"], + "linter": { + "rules": { + "suspicious": { + "noShorthandPropertyOverrides": "off" + } + } + } + } + ] } diff --git a/bun.lockb b/bun.lockb index 18ecc7b6..f78018fa 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 89bca4f0..da151c6f 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ "typescript": "^5.5.3" }, "devDependencies": { - "@biomejs/biome": "1.8.3", + "@biomejs/biome": "2.4.12", "concurrently": "^7.0.0" }, - "workspaces": ["api", "website", "packages/*"], + "workspaces": [ + "api", + "website", + "packages/*" + ], "patchedDependencies": { "@remix-run/react@2.9.2": "patches/@remix-run%2Freact@2.9.2.patch" } diff --git a/packages/cli/package.json b/packages/cli/package.json index eb355497..203fbf33 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,7 +7,11 @@ "bin": { "@docs.page/cli": "dist/cli.js" }, - "keywords": ["docs.page", "documentation", "docs"], + "keywords": [ + "docs.page", + "documentation", + "docs" + ], "exports": { ".": { "import": "./dist/index.js", @@ -19,7 +23,11 @@ "watch": "npx tsup src/index.ts src/cli.ts --format esm --dts --watch", "prepublishOnly": "npm run build" }, - "files": ["dist", "LICENSE", "README.md"], + "files": [ + "dist", + "LICENSE", + "README.md" + ], "dependencies": { "@inquirer/prompts": "^5.3.8", "chalk": "^5.3.0", diff --git a/packages/cli/src/check/configuration.ts b/packages/cli/src/check/configuration.ts index 42466ecb..7405b4f1 100644 --- a/packages/cli/src/check/configuration.ts +++ b/packages/cli/src/check/configuration.ts @@ -1,8 +1,8 @@ import type { CheckResult, Routes } from "./types"; export function* checkConfiguration( - routes: Routes, - configuration: unknown, + _routes: Routes, + _configuration: unknown, ): Generator { // } diff --git a/packages/cli/src/check/index.ts b/packages/cli/src/check/index.ts index f411f672..e2fe0626 100644 --- a/packages/cli/src/check/index.ts +++ b/packages/cli/src/check/index.ts @@ -1,6 +1,7 @@ import { checkConfiguration } from "./configuration"; import { checkRelativeLinks } from "./relative-links"; import type { CheckResult, Route } from "./types"; + export type * from "./types"; export async function* check( diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 8b2f7384..37030c39 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -14,7 +14,7 @@ export function registerCheckCommand(program: Command) { "[path]", "Path to the relative directory to check. Defaults to the current directory.", ) - .action(async (input: unknown, o) => { + .action(async (input: unknown, _o) => { const relativePath = String(input || "."); const absolutePath = path.resolve(relativePath); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index fbc0713c..7b40ab4e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -9,7 +9,7 @@ export function registerInitCommand(program: Command) { .command("init") .description("Initializes new docs.page files") .argument("[path]", "Path to the relative directory to initilize in.") - .action(async (input: unknown, o) => { + .action(async (input: unknown, _o) => { const relativePath = String(input || "."); const absolutePath = path.resolve(relativePath); diff --git a/website/components.json b/website/components.json new file mode 100644 index 00000000..31b4a484 --- /dev/null +++ b/website/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/global.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "~/components", + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/website/next.config.mjs b/website/next.config.mjs index a8073d60..572aea81 100644 --- a/website/next.config.mjs +++ b/website/next.config.mjs @@ -3,6 +3,16 @@ import path from "node:path"; /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + images: { + dangerouslyAllowSVG: true, + remotePatterns: [ + { + protocol: "https", + hostname: "raw.githubusercontent.com", + pathname: "/**", + }, + ], + }, webpack: (config) => { // Allow transpiling TypeScript from an external package config.module.rules.push({ diff --git a/website/package.json b/website/package.json index c4133fd5..28ad19e5 100644 --- a/website/package.json +++ b/website/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@base-ui/react": "^1.3.0", "@docs.page/cli": "workspace:*", "@docsearch/js": "^3.6.1", "@headlessui/react": "^2.1.8", @@ -16,10 +17,12 @@ "@tanstack/react-query": "^5.56.2", "@vercel/functions": "^1.4.1", "@vercel/og": "^0.6.4", + "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", + "clsx": "^2.1.1", "color": "^4.2.3", "idb": "^8.0.0", - "lucide-react": "^0.445.0", + "lucide-react": "0.445.0", "mime-db": "^1.53.0", "mime-types": "^2.1.35", "next": "14.2.13", @@ -27,7 +30,9 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-medium-image-zoom": "^5.2.10", - "tailwind-merge": "^2.5.2" + "shadcn": "^4.2.0", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@tailwindcss/typography": "^0.5.15", diff --git a/website/public/_docs.page/assets/beautiful-by-design.png b/website/public/_docs.page/assets/beautiful-by-design.png index 23fe45f4..17f091d6 100644 Binary files a/website/public/_docs.page/assets/beautiful-by-design.png and b/website/public/_docs.page/assets/beautiful-by-design.png differ diff --git a/website/public/_docs.page/assets/collaborate-with-live-preview-light.png b/website/public/_docs.page/assets/collaborate-with-live-preview-light.png new file mode 100644 index 00000000..0a4eb359 Binary files /dev/null and b/website/public/_docs.page/assets/collaborate-with-live-preview-light.png differ diff --git a/website/public/_docs.page/assets/collaborate-with-live-preview.png b/website/public/_docs.page/assets/collaborate-with-live-preview.png index 878941c2..b47363f9 100644 Binary files a/website/public/_docs.page/assets/collaborate-with-live-preview.png and b/website/public/_docs.page/assets/collaborate-with-live-preview.png differ diff --git a/website/public/_docs.page/assets/community-logos-light.png b/website/public/_docs.page/assets/community-logos-light.png new file mode 100644 index 00000000..bdda87c4 Binary files /dev/null and b/website/public/_docs.page/assets/community-logos-light.png differ diff --git a/website/public/_docs.page/assets/community-logos.png b/website/public/_docs.page/assets/community-logos.png new file mode 100644 index 00000000..0121de3c Binary files /dev/null and b/website/public/_docs.page/assets/community-logos.png differ diff --git a/website/public/_docs.page/assets/cutomise-and-theme-light.png b/website/public/_docs.page/assets/cutomise-and-theme-light.png new file mode 100644 index 00000000..a96cbb76 Binary files /dev/null and b/website/public/_docs.page/assets/cutomise-and-theme-light.png differ diff --git a/website/public/_docs.page/assets/cutomise-and-theme.png b/website/public/_docs.page/assets/cutomise-and-theme.png new file mode 100644 index 00000000..0cf0ac25 Binary files /dev/null and b/website/public/_docs.page/assets/cutomise-and-theme.png differ diff --git a/website/public/_docs.page/assets/get-started/install/install.png b/website/public/_docs.page/assets/get-started/install/install.png new file mode 100644 index 00000000..62a3f779 Binary files /dev/null and b/website/public/_docs.page/assets/get-started/install/install.png differ diff --git a/website/public/_docs.page/assets/get-started/preview-docs.png b/website/public/_docs.page/assets/get-started/preview-docs.png new file mode 100644 index 00000000..fc02ec25 Binary files /dev/null and b/website/public/_docs.page/assets/get-started/preview-docs.png differ diff --git a/website/public/_docs.page/assets/get-started/terminal.png b/website/public/_docs.page/assets/get-started/terminal.png deleted file mode 100644 index 51735c8a..00000000 Binary files a/website/public/_docs.page/assets/get-started/terminal.png and /dev/null differ diff --git a/website/public/_docs.page/assets/large-icon-light.svg b/website/public/_docs.page/assets/large-icon-light.svg new file mode 100644 index 00000000..81966881 --- /dev/null +++ b/website/public/_docs.page/assets/large-icon-light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/public/_docs.page/assets/large-icon.png b/website/public/_docs.page/assets/large-icon.png new file mode 100644 index 00000000..67f070f9 --- /dev/null +++ b/website/public/_docs.page/assets/large-icon.png @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/public/_docs.page/assets/large-icon.svg b/website/public/_docs.page/assets/large-icon.svg new file mode 100644 index 00000000..57100625 --- /dev/null +++ b/website/public/_docs.page/assets/large-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/public/_docs.page/assets/logo-dark.svg b/website/public/_docs.page/assets/logo-dark.svg new file mode 100644 index 00000000..8a1fd177 --- /dev/null +++ b/website/public/_docs.page/assets/logo-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/public/_docs.page/assets/logo.svg b/website/public/_docs.page/assets/logo.svg new file mode 100644 index 00000000..8a1fd177 --- /dev/null +++ b/website/public/_docs.page/assets/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/public/_docs.page/assets/manage-docs-as-code-light.png b/website/public/_docs.page/assets/manage-docs-as-code-light.png new file mode 100644 index 00000000..a510a121 Binary files /dev/null and b/website/public/_docs.page/assets/manage-docs-as-code-light.png differ diff --git a/website/public/_docs.page/assets/manage-docs-as-code.png b/website/public/_docs.page/assets/manage-docs-as-code.png index 10523767..59438258 100644 Binary files a/website/public/_docs.page/assets/manage-docs-as-code.png and b/website/public/_docs.page/assets/manage-docs-as-code.png differ diff --git a/website/public/_docs.page/assets/polished-by-default-light.png b/website/public/_docs.page/assets/polished-by-default-light.png new file mode 100644 index 00000000..e83845b0 Binary files /dev/null and b/website/public/_docs.page/assets/polished-by-default-light.png differ diff --git a/website/public/_docs.page/assets/polished-by-default.png b/website/public/_docs.page/assets/polished-by-default.png new file mode 100644 index 00000000..7c4b27ae Binary files /dev/null and b/website/public/_docs.page/assets/polished-by-default.png differ diff --git a/website/public/_docs.page/assets/publish-instantly-light.png b/website/public/_docs.page/assets/publish-instantly-light.png new file mode 100644 index 00000000..b22aa79d Binary files /dev/null and b/website/public/_docs.page/assets/publish-instantly-light.png differ diff --git a/website/public/_docs.page/assets/publish-instantly.png b/website/public/_docs.page/assets/publish-instantly.png index 81e25575..875b7e67 100644 Binary files a/website/public/_docs.page/assets/publish-instantly.png and b/website/public/_docs.page/assets/publish-instantly.png differ diff --git a/website/public/_docs.page/logo.png b/website/public/_docs.page/logo.png deleted file mode 100644 index a6807581..00000000 Binary files a/website/public/_docs.page/logo.png and /dev/null differ diff --git a/website/public/_docs.page/logo.svg b/website/public/_docs.page/logo.svg new file mode 100644 index 00000000..8a1fd177 --- /dev/null +++ b/website/public/_docs.page/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/website/src/api.ts b/website/src/api.ts index 3a7454e6..98e39423 100644 --- a/website/src/api.ts +++ b/website/src/api.ts @@ -8,8 +8,8 @@ import { COMPONENTS } from "./components/Content"; import { getBuildHash } from "./env"; export type { - BundleResponse, BundleErrorResponse, + BundleResponse, BundlerOutput, SidebarGroup, }; @@ -62,7 +62,7 @@ type GetPreviewBundleArgs = { }; export async function getPreviewBundle( - args: GetPreviewBundleArgs + args: GetPreviewBundleArgs, ): Promise { const response = await fetch(`${API_URL}/preview`, { method: "POST", diff --git a/website/src/components/Button.tsx b/website/src/components/Button.tsx deleted file mode 100644 index 06d5e09c..00000000 --- a/website/src/components/Button.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { ChevronRightIcon } from "lucide-react"; -import Link from "next/link"; -import { type ComponentProps, cloneElement } from "react"; -import { cn } from "~/utils"; - -type Props = - | ({ - element: "a"; - href: string; - children: string; - cta?: boolean; - } & ComponentProps<"a">) - | ({ - element: "button"; - children: string; - cta?: boolean; - } & ComponentProps<"button">); - -export function Button({ className, cta, ...props }: Props) { - let el: React.ReactNode; - - if (props.element === "button") { - el = + + + + {menuOpen ? ( + + ) : null} + + ); } + +/** `data-theme` + `MARKETING_THEME_STORAGE_KEY`; uses `Button` `ghost` + `icon-lg`. */ +function MarketingHeaderThemeToggle() { + const [theme, setTheme] = useState<"light" | "dark" | null>(null); + + useEffect(() => { + const t = + document.documentElement.getAttribute("data-theme") === "dark" + ? "dark" + : "light"; + setTheme(t); + }, []); + + useEffect(() => { + if (theme === null) return; + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem(MARKETING_THEME_STORAGE_KEY, theme); + } catch { + /* ignore */ + } + }, [theme]); + + if (theme === null) { + return
; + } + + const isDark = theme === "dark"; + + return ( + + ); +} diff --git a/website/src/layouts/Site.tsx b/website/src/layouts/Site.tsx index d8783f0e..c05c3bba 100644 --- a/website/src/layouts/Site.tsx +++ b/website/src/layouts/Site.tsx @@ -1,4 +1,5 @@ import Head from "next/head"; +import { MARKETING_THEME_STORAGE_KEY } from "~/constants/links"; import { useInlineScript } from "~/hooks"; const title = "docs.page | Ship documentation, like you ship code"; @@ -8,10 +9,14 @@ const image = "https://docs.page/_docs.page/social-preview.png"; export function Site({ children }: { children: React.ReactNode }) { const scripts = useInlineScript(``); + var key = ${JSON.stringify(MARKETING_THEME_STORAGE_KEY)}; + var stored = null; + try { stored = localStorage.getItem(key); } catch (e) {} + var theme = stored === "light" || stored === "dark" + ? stored + : (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + document.documentElement.setAttribute("data-theme", theme); + })()`); return ( <> diff --git a/website/src/layouts/get-started/Card.tsx b/website/src/layouts/get-started/Card.tsx index 0646af6f..188778af 100644 --- a/website/src/layouts/get-started/Card.tsx +++ b/website/src/layouts/get-started/Card.tsx @@ -1,36 +1,78 @@ +import { cva } from "class-variance-authority"; +import type { ReactNode } from "react"; + +import { Card as UICard } from "~/components/ui/card"; +import { cn } from "~/lib/utils"; + +const getStartedCardSurface = cva( + "ring-0 !ring-0 border bg-transparent shadow-none border-marketing-step-badge-border/45 dark:border-marketing-step-badge-border-dark dark:!bg-marketing-step-badge-fill-dark", +); + +const stepBadgeVariants = cva( + "absolute -left-10 top-5 z-10 hidden size-9 items-center justify-center rounded-full border bg-background font-mono text-sm font-semibold text-foreground shadow-sm border-marketing-step-badge-border/45 dark:border-marketing-step-badge-border-dark dark:bg-marketing-step-badge-fill-dark lg:flex", +); + +const stepConnectorVariants = cva( + "pointer-events-none absolute -left-[1.375rem] top-[2.375rem] z-0 hidden h-[calc(100%+1.5rem)] w-px bg-marketing-step-rail/35 dark:bg-marketing-step-rail-dark lg:block", +); + type Props = { + /** When true, no connector segment is drawn below this step (line ends at this badge). */ + isLast?: boolean; step: number; title: string; description: string; - asset: React.ReactNode; - meta?: React.ReactNode; - children: React.ReactNode; + asset: ReactNode; + meta?: ReactNode; + children: ReactNode; }; +/** + * Light: no extra wash — the page is already `bg-background` + `.homepage-spot-grid`. + * A tinted fill here stacked with the inner grid (and we previously doubled the wash on the + * inner wrapper) read as a muddy “inner yellow”. Dark: solid panel like platform cards. + */ export function Card(props: Props) { return ( -
-
- {props.step} -
-
-
-

- {props.step}. - {props.title} -

-

{props.description}

- {props.meta ? ( -
{props.meta}
- ) : null} -
{props.asset}
-
{props.children}
-
-
-
{props.asset}
-
{props.children}
+
+ {!props.isLast ? ( +
+ ) : null} +
{props.step}
+ +
+
+

+ {props.step}. + {props.title} +

+

+ {props.description} +

+ {props.meta ? ( +
+ {props.meta} +
+ ) : null} +
{props.asset}
+
{props.children}
+
+
+ {props.asset} + {props.children} +
-
+
); } diff --git a/website/src/layouts/get-started/index.tsx b/website/src/layouts/get-started/index.tsx index a02331a3..a015bc2b 100644 --- a/website/src/layouts/get-started/index.tsx +++ b/website/src/layouts/get-started/index.tsx @@ -5,34 +5,43 @@ import { ExternalLinkIcon, EyeIcon, } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; -import { type ComponentProps, useEffect, useState } from "react"; -import { useInlineScript } from "~/hooks"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/ui/button"; +import { LINKS } from "~/constants/links"; import { Footer } from "~/layouts/Footer"; import { Header } from "~/layouts/Header"; -import { cn } from "~/utils"; import { Site } from "../Site"; import { Card } from "./Card"; +/** Desktop: intrinsic width + right-align with CTAs; mobile: full width of column. */ +const assetImgClassName = "h-auto w-full max-w-full lg:w-auto"; + export default function GetStartedRoute() { return ( -
-
-
-

- Publish docs in 4 steps -

-
-
- - - - +
+
+
+
+

+ Publish docs in{" "} + + four + {" "} + steps +

+
+ + + + +
-
-
-
+ +
+
); } @@ -56,26 +65,28 @@ function Install() { title="Install" description="Add docs.page to your project" asset={ - Terminal Command } meta={

Run the{" "} - + CLI init {" "} command in your project to add docs.page to your project.

} > - { navigator.clipboard.writeText("npx @docs.page/cli init"); setCopied(true); @@ -83,7 +94,10 @@ function Install() { > {copied ? ( <> - + Copied! ) : ( @@ -92,7 +106,7 @@ function Install() { Copy Command )} - + ); } @@ -104,32 +118,34 @@ function AddContent() { title="Add Content" description="Add markdown to a page" asset={ - Markdown } meta={

- + Write your documentation {" "} using Markdown, adding new pages to your `docs` directory.

} > - { - window.location.href = "https://use.docs.page/writing-content"; + window.location.href = `${LINKS.docs}/writing-content`; }} > Read Guide - + ); } @@ -141,10 +157,12 @@ function PreviewDocs() { title="Preview Docs" description="Preview your docs.page site" asset={ - Markdown } meta={ @@ -157,14 +175,17 @@ function PreviewDocs() {

} > - { window.location.href = "/preview"; }} > Local Preview - + ); } @@ -172,20 +193,23 @@ function PreviewDocs() { function PublishChanges() { const [input, setInput] = useState(""); - const match = input.match(/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)$/); + const match = input.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/); const isInvalid = !!input && !match; return ( } meta={ @@ -201,24 +225,22 @@ function PublishChanges() {
setInput(e.target.value)} /> -
+
{isInvalid ? "Enter a valid GitHub repository URL" : null}
- +
} > - { - window.location.href = "https://use.docs.page/publishing"; + window.location.href = `${LINKS.docs}/publishing`; }} > Visit Docs - +
); } - -function ActionButton({ className, ...props }: ComponentProps<"button">) { - return ( - +
+
+ {/* + md+: Icon column is `position:absolute; inset-y:0` inside this `relative` wrapper. + Wrapper height = in-flow copy column only, so the icon strip always matches that height + (avoids flex/grid % height bugs with table-cell too). + */} +
+
+

+ Start publishing{" "} + today +

+

+ Begin publishing your great documentation now. +

+
+ + Start publishing + + +
+
+ +
+
+ + +
+
+
); diff --git a/website/src/layouts/homepage/Demo.tsx b/website/src/layouts/homepage/Demo.tsx deleted file mode 100644 index a6e44994..00000000 --- a/website/src/layouts/homepage/Demo.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useRef } from "react"; -import { cn } from "~/utils"; - -export function Demo() { - const video = useRef(null); - - return ( -
-
- {/* biome-ignore lint/a11y/useMediaCaption: */} - -
-
- ); -} diff --git a/website/src/layouts/homepage/FeatureCell.tsx b/website/src/layouts/homepage/FeatureCell.tsx new file mode 100644 index 00000000..38b9b209 --- /dev/null +++ b/website/src/layouts/homepage/FeatureCell.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { useEffect, useState } from "react"; + +import { cn } from "~/utils"; + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function FeatureCell({ + icon, + title, + description, + className, + tabIndex = 0, + ...rest +}: { + icon: React.ReactNode; + title: string; + description: string; +} & ComponentProps<"div">) { + const [active, setActive] = useState(false); + const [titleShown, setTitleShown] = useState(""); + const [descShown, setDescShown] = useState(""); + /** `null` until client reads hover / motion media (avoids clearing touch layout on first paint). */ + const [typewriterOff, setTypewriterOff] = useState(null); + + useEffect(() => { + const mqHover = window.matchMedia("(hover: none)"); + const mqMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const sync = () => { + setTypewriterOff(mqHover.matches || mqMotion.matches); + }; + sync(); + mqHover.addEventListener("change", sync); + mqMotion.addEventListener("change", sync); + return () => { + mqHover.removeEventListener("change", sync); + mqMotion.removeEventListener("change", sync); + }; + }, []); + + useEffect(() => { + if (typewriterOff === null) return; + + if (typewriterOff) { + setTitleShown(title); + setDescShown(description); + return; + } + + if (!active) { + setTitleShown(""); + setDescShown(""); + return; + } + + let cancelled = false; + + (async () => { + setTitleShown(""); + setDescShown(""); + for (let i = 1; i <= title.length; i++) { + if (cancelled) return; + await delay(18); + if (cancelled) return; + setTitleShown(title.slice(0, i)); + } + for (let i = 1; i <= description.length; i++) { + if (cancelled) return; + await delay(11); + if (cancelled) return; + setDescShown(description.slice(0, i)); + } + })(); + + return () => { + cancelled = true; + }; + }, [active, typewriterOff, title, description]); + + const typingTitle = titleShown.length < title.length; + const typingDesc = + titleShown.length >= title.length && descShown.length < description.length; + const showCaret = + active && typewriterOff === false && (typingTitle || typingDesc); + + return ( + // biome-ignore lint/a11y/useSemanticElements: Marketing tile contains headings and overlay content; not representable as a single button or fieldset. +
setActive(true)} + onMouseLeave={() => setActive(false)} + onFocus={() => setActive(true)} + onBlur={() => setActive(false)} + > +
+
+
+ {icon} +
+
+
+ +
+
+

+ + {title} + + + + {titleShown} + {showCaret && typingTitle ? ( + + ) : null} + + +

+

+ + {description} + + + + {descShown} + {showCaret && typingDesc ? ( + + ) : null} + + +

+
+
+
+ ); +} diff --git a/website/src/layouts/homepage/Features.tsx b/website/src/layouts/homepage/Features.tsx index 13c350a7..54db5a10 100644 --- a/website/src/layouts/homepage/Features.tsx +++ b/website/src/layouts/homepage/Features.tsx @@ -3,119 +3,95 @@ import { ComponentIcon, EyeIcon, GithubIcon, - Grid2X2Icon, + GlobeIcon, Heading1Icon, PencilIcon, RefreshCcwDotIcon, SearchIcon, - SwatchBookIcon, } from "lucide-react"; -import type { ComponentProps } from "react"; -import { cn } from "~/utils"; -export function Features() { - return ( -
-

- - Features -

-

- Everything needed to -
- publish great documentation -

-

- Built to improve developer experience -

-
-
- } - title="Seamless GitHub Integration" - description="Source your docs directly from your GitHub repositories for easy updates." - className="border-b" - /> - } - title="Editing" - description="Editing workflow built into where you work." - className="border-b" - /> - } - title="Local Preview & Hot Reload" - description="See your changes instantly as you type, streamlining your workflow." - className="border-b" - /> - } - title="Markdown-Powered" - description="Write your documentation in the simple and intuitive Markdown format." - className="border-b" - /> - } - title="Pre-Built Components" - description="Add code blocks, alerts, tabs, videos, and more with ease." - className="border-b" - /> - } - title="Preview Deployments" - description="Review and share your changes before they go live." - className="border-b" - /> - } - title="Custom Domains & Themes" - description="Make your docs truly your own with a personalised domain, look and feel." - className="border-b" - /> - } - title="Powerful Search" - description="Help users find information quickly with configurable search functionality." - className="border-b" - /> - } - title="Documentation Analytics" - description="Understand what users are viewing using Google Analytics or Plausible." - className="border-b" - /> -
-
-
-
-
- ); -} +import { FeatureCell } from "./FeatureCell"; +import { FeaturesScrollStrip } from "./FeaturesScrollStrip"; -type FeatureCardProps = { +const features: { icon: React.ReactNode; title: string; description: string; -} & ComponentProps<"div">; +}[] = [ + { + icon: , + title: "Made for GitHub", + description: + "Source your docs from GitHub repositories with instant deploys.", + }, + { + icon: , + title: "Publish from your editor", + description: + "Write where you already work—your editor stays the source of truth.", + }, + { + icon: , + title: "Local preview & hot reload", + description: + "See every edit instantly with local preview and fast feedback.", + }, + { + icon: , + title: "Markdown powered", + description: + "Author in Markdown with components and shortcodes when you need more.", + }, + { + icon: , + title: "Pre-built components", + description: "Tabs, callouts, code blocks, and more—ready out of the box.", + }, + { + icon: , + title: "Shareable preview links", + description: "Share previews with reviewers before anything goes live.", + }, + { + icon: , + title: "Custom domains & themes", + description: "Use your own domain and tailor the look to match your brand.", + }, + { + icon: , + title: "Powerful search", + description: + "Help readers find answers fast with built-in search integrations.", + }, + { + icon: , + title: "Documentation analytics", + description: + "Understand what pages matter with Plausible or Google Analytics.", + }, +]; -function FeatureCard({ - icon, - title, - description, - className, - ...other -}: FeatureCardProps) { +export function Features() { return ( -
-
{icon}
-

{title}

-

{description}

-
+
+
+

+ Everything needed to publish
+ polished{" "} + documentation +

+

+ Built to improve developer experience from first commit to production. +

+
+ + +
+ {features.map((f) => ( + + ))} +
+
+
); } diff --git a/website/src/layouts/homepage/FeaturesScrollStrip.tsx b/website/src/layouts/homepage/FeaturesScrollStrip.tsx new file mode 100644 index 00000000..c5b3df78 --- /dev/null +++ b/website/src/layouts/homepage/FeaturesScrollStrip.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useIsomorphicLayoutEffect } from "~/lib/use-isomorphic-layout-effect"; +import { cn } from "~/lib/utils"; + +type ThumbState = { widthPct: number; leftPct: number }; + +const MIN_THUMB_PCT = 10; + +export function FeaturesScrollStrip({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + const scrollRef = useRef(null); + const trackRef = useRef(null); + const [thumb, setThumb] = useState({ + widthPct: 100, + leftPct: 0, + }); + const [overflow, setOverflow] = useState(false); + + const updateThumb = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + const maxScroll = scrollWidth - clientWidth; + const hasOverflow = maxScroll > 2; + setOverflow(hasOverflow); + if (!hasOverflow) { + setThumb({ widthPct: 100, leftPct: 0 }); + return; + } + const rawWidthPct = (clientWidth / scrollWidth) * 100; + const widthPct = Math.max(rawWidthPct, MIN_THUMB_PCT); + const maxLeft = 100 - widthPct; + const leftPct = maxScroll > 0 ? (scrollLeft / maxScroll) * maxLeft : 0; + setThumb({ widthPct, leftPct }); + }, []); + + useIsomorphicLayoutEffect(() => { + updateThumb(); + }, [updateThumb]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + updateThumb(); + el.addEventListener("scroll", updateThumb, { passive: true }); + const ro = new ResizeObserver(updateThumb); + ro.observe(el); + return () => { + el.removeEventListener("scroll", updateThumb); + ro.disconnect(); + }; + }, [updateThumb]); + + const onTrackPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + const scroll = scrollRef.current; + const track = trackRef.current; + if (!scroll || !track) return; + if ((e.target as HTMLElement).dataset.thumb === "true") return; + const rect = track.getBoundingClientRect(); + const x = e.clientX - rect.left; + const maxScroll = scroll.scrollWidth - scroll.clientWidth; + const ratio = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0; + scroll.scrollLeft = ratio * maxScroll; + }; + + const onThumbPointerDown = (e: React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + const scroll = scrollRef.current; + const track = trackRef.current; + if (!scroll || !track) return; + const startX = e.clientX; + const startScroll = scroll.scrollLeft; + const trackW = track.getBoundingClientRect().width; + const maxScroll = scroll.scrollWidth - scroll.clientWidth; + + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - startX; + scroll.scrollLeft = + trackW > 0 ? startScroll + (dx / trackW) * maxScroll : startScroll; + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return ( +
+
+ {children} +
+ {overflow ? ( +
+
+
+
+
+ ) : null} +
+ ); +} diff --git a/website/src/layouts/homepage/Hero.tsx b/website/src/layouts/homepage/Hero.tsx index d45e5117..1a8e2822 100644 --- a/website/src/layouts/homepage/Hero.tsx +++ b/website/src/layouts/homepage/Hero.tsx @@ -1,32 +1,119 @@ -import { Button } from "~/components/Button"; +import { ChevronRightIcon } from "lucide-react"; + +import { Link } from "~/components/Link"; +import { buttonVariants } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { LINKS } from "~/constants/links"; +import { cn } from "~/lib/utils"; + +import { platformCardVariants } from "./platformCardSurface"; export function Hero() { return ( -
-

- Ship documentation, -
like you ship code -

-

- Meet the docs as code platform made for open-source developers. -

-

- Publish beautiful online documentation instantly, -
from your code editor using markdown and a public GitHub - repository. -

-
-
- -
-
- +
+ + {/* Left edge of video column → full card height (matches homepage rail tone) */} +
+
+
+

+ Ship documentation, +
like you ship{" "} + code. +

+

+ Publish beautiful documentation instantly from your editor. + Markdown, GitHub, and a workflow that feels as natural as + committing code. +

+
+ +
+
+
+ +
+
+
+
+ +
+ + Start for free + + +
-
+
); } diff --git a/website/src/layouts/homepage/Platform.tsx b/website/src/layouts/homepage/Platform.tsx index ddbaf6c5..73b55915 100644 --- a/website/src/layouts/homepage/Platform.tsx +++ b/website/src/layouts/homepage/Platform.tsx @@ -1,136 +1,91 @@ -import { BookTextIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; -const ASSET_VERSION = 2; +import { PlatformFeatureCard } from "./PlatformFeatureCard"; + +const asset = (base: string) => ({ + light: `/_docs.page/assets/${base}-light.png`, + dark: `/_docs.page/assets/${base}.png`, +}); + +/** Single 1px edges: outer frame + right/bottom segments only (avoids doubled borders between tiles). */ +const platformGridTileShell = cn( + "md:col-span-2", + "border-b border-zinc-300 dark:border-zinc-700", + "last:border-b-0", + /* Tablet (md–1023): 2 columns — right edge on odd columns; even columns share the internal divider */ + "md:max-lg:border-r md:max-lg:[&:nth-child(2n)]:border-r-0", + /* Desktop (lg+): 3 columns */ + "lg:border-r lg:[&:nth-child(n+4)]:border-b-0 lg:[&:nth-child(3n)]:border-r-0", +); + +const platformGridCardClassName = "min-h-0 flex-1 border-0 !shadow-none"; + +const platformGridCell = cn( + platformGridTileShell, + "flex h-full min-h-0 flex-col", +); export function Platform() { return ( -
-

- - The documentation platform for open-source developers -

-

- Documentation, made simple -

-

- The easiest way to maintain open-source documentation -

-
-
- - - - - - - - - -
- - - +
+
+

+ Documentation made{" "} + simple +

+

+ The easiest way to maintain open-source documentation alongside your + codebase. +

+
+ +
+
+
+
-
-
-
- - - +
+ +
+
+
-
- + - - + description="Tailor colors, typography, and layout so documentation feels native to your product." + /> +
+
+
); } - -type PlatformCardProps = { - title: string; - description: string; - children?: React.ReactNode; -}; - -function PlatformCard(props: PlatformCardProps) { - return ( -
-
{props.children}
-
-

{props.title}

-

{props.description}

-
-
- ); -} - -function Manage() { - return ( - Manage Docs as Code - ); -} - -function BeautifulByDesign() { - return ( - Publish Instantly - ); -} - -function Preview() { - return ( - Publish Instantly - ); -} - -function Publish() { - return ( - Publish Instantly - ); -} - -function Customize() { - return ( - Publish Instantly - ); -} diff --git a/website/src/layouts/homepage/PlatformFeatureCard.tsx b/website/src/layouts/homepage/PlatformFeatureCard.tsx new file mode 100644 index 00000000..f250fb21 --- /dev/null +++ b/website/src/layouts/homepage/PlatformFeatureCard.tsx @@ -0,0 +1,91 @@ +import Image from "next/image"; + +import { Card, CardDescription, CardTitle } from "~/components/ui/card"; +import { cn } from "~/lib/utils"; + +import { platformCardVariants } from "./platformCardSurface"; + +/** `default` keeps marketing screenshots slightly narrower; `large` uses the full tile width. */ +const platformImageFrameWidthClass = { + default: + "mx-auto w-full max-w-[min(100%,14rem)] sm:max-w-[15rem] md:max-lg:max-w-[min(100%,17.5rem)] lg:max-w-[min(100%,18rem)]", + large: "w-full max-w-full", +} as const; + +/** Responsive hint for `next/image` fill — grid tile ≈ ⅓ of content width at lg. */ +const platformImageSizes = + "(min-width: 1024px) min(34vw, 28rem), (min-width: 768px) 45vw, 92vw"; + +export function PlatformFeatureCard(props: { + title: string; + description: string; + /** Public paths for theme-specific screenshots (`/_docs.page/assets/...`). */ + image: { light: string; dark: string }; + /** Wider frame for hero-style tiles; default keeps a slightly smaller cap. */ + imageSize?: keyof typeof platformImageFrameWidthClass; + /** Merged onto `CardTitle` (e.g. `whitespace-nowrap` for a single-line heading). */ + titleClassName?: string; + /** Merged onto the root `Card` (e.g. `border-0` when the parent grid supplies edges). */ + className?: string; +}) { + const frameWidthClass = + platformImageFrameWidthClass[props.imageSize ?? "default"]; + + return ( + +
+
+
+ + +
+
+
+ + {props.title} + + + {props.description} + +
+
+
+ ); +} diff --git a/website/src/layouts/homepage/Testimonials.tsx b/website/src/layouts/homepage/Testimonials.tsx deleted file mode 100644 index 1428e7de..00000000 --- a/website/src/layouts/homepage/Testimonials.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ShieldCheckIcon } from "lucide-react"; - -export function Testimonials() { - return ( -
-

- - Trusted by developers -

-

- See why developers use docs.page -

-

- Built for the open-source community -

-
TODO Testimonial grid
-
- ); -} diff --git a/website/src/layouts/homepage/index.tsx b/website/src/layouts/homepage/index.tsx index 3fd42cf1..7984163d 100644 --- a/website/src/layouts/homepage/index.tsx +++ b/website/src/layouts/homepage/index.tsx @@ -1,11 +1,8 @@ -import { HeroGradient } from "~/components/HeroGradient"; import { Footer } from "~/layouts/Footer"; import { Header } from "~/layouts/Header"; import { Site } from "~/layouts/Site"; -import { Affiliation } from "./Affiliation"; import { CallToAction } from "./CallToAction"; -import { Demo } from "./Demo"; import { Features } from "./Features"; import { Hero } from "./Hero"; import { Platform } from "./Platform"; @@ -13,16 +10,36 @@ import { Platform } from "./Platform"; export function Homepage() { return ( - -
- - - - - - {/* */} - -