From bd1ceb326811bebf13fdfd7519165c819503bd2a Mon Sep 17 00:00:00 2001 From: anandpant <109482096+anandpant@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:11:35 +0000 Subject: [PATCH] feat(web): surface the sketchi CLI across the site (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The gap `sketchi` is published on npm and does real work, but `apps/web/src` mentioned `npm`, `npx`, `install.sh` or the CLI **zero** times — not the header, footer, homepage, docs or agents pages. A visitor to sketchi.app could not discover the CLI existed. This treats the terminal as a peer of the playground and the agent route. ## What's here - **`CliBand` homepage section** (`#cli`, after the feature grid) — a terminal card carrying both install commands copy-pasteable through the existing `CopyButton`, an example `generate` call, three honest capability claims, and a link to the package. - **Reachable from every page** — header nav (desktop + mobile sheet), footer Product column, footer npm package link, and a third route in the closing CTA. - **Docs** — a third path lane in the explainer plus a real `#cli` section: install, the command surface, and the agent-facing flags. - **`llms.txt`** — a CLI section so agents discover it the same way. - **npm mark** — pulled from our own library at `icons.sketchi.app/api/icons/npm.svg` into `public/brand/npm.svg`. ## Accuracy Every claim comes from `sketchi --help` and the published npm metadata. Both install commands were **run end-to-end against the published package** in an isolated HOME before shipping: the one-liner installed 0.3.0, wired zsh completions, and `create` / `list` / `export --format png` all produced real output. The install commands are pinned byte-for-byte in `cli-package.test.ts` so they cannot drift silently, and nothing names a version — npm shows the current one. ## Also fixed `.docs-section code` styled inline prose pills, which made commands inside a docs codeblock unreadable once docs actually had one. Caught in the browser pass, not by tests. ## Proof - `pnpm nx run-many -t typecheck,test,build` — green, 17 projects - `pnpm test:tools` — 64 pass (incl. the `workers.dev` public-surface guard) - `pnpm nx lint web`, `build-storybook` for `diagram-ui` and `web` — green - Component tests and Storybook stories for the new section, matching existing coverage; 57 web tests pass - Verified in a real browser at 1440px and 390px: copy controls, npm link, and the nav/footer entries from `/`, `/agents`, `/agents/codex`, `/docs` ## Review `codex review --base main` raised two valid P2 accuracy findings; both reproduced against the binary and are fixed in the second commit: 1. The lead claimed offline "for everything but generation" — `share` and `pull` are network commands too. 2. Docs and `llms.txt` claimed every command reads `--json` / `--file` — `list`, `show`, `export`, `restore` reject both. Scoped to create/edit/patch. ## Deliberately left out - No version number anywhere in copy. - Three docs lanes are **stacked, not side-by-side**: `.docs-article` is capped at `72ch`, so three columns would be ~200px each and could not hold the inline commands. - No hero button — the hero keeps its two-button conversion path; the CLI gets its own full section instead of crowding it. - Root README untouched, per the brief. ## Found, not fixed (pre-existing on `main`) The mobile header sheet has no background, so page content shows through the open menu. Identical on `main`; outside this slice. --- apps/web/public/brand/npm.svg | 1 + apps/web/public/llms.txt | 37 ++- .../components/cli-band/cli-band.stories.tsx | 26 ++ .../src/components/cli-band/cli-band.test.tsx | 120 +++++++++ apps/web/src/components/cli-band/cli-band.tsx | 99 ++++++++ apps/web/src/components/cli-band/index.ts | 2 + .../src/components/cta-band/cta-band.test.tsx | 10 + apps/web/src/components/cta-band/cta-band.tsx | 9 +- .../components/docs-view/docs-view.test.tsx | 57 +++++ .../src/components/docs-view/docs-view.tsx | 140 ++++++++++- .../marketing-home/marketing-home.test.tsx | 12 + .../marketing-home/marketing-home.tsx | 2 + .../site-footer/site-footer.test.tsx | 16 ++ .../components/site-footer/site-footer.tsx | 13 + .../site-header/site-header.test.tsx | 21 ++ .../components/site-header/site-header.tsx | 1 + apps/web/src/lib/cli-package.test.ts | 43 ++++ apps/web/src/lib/cli-package.ts | 83 ++++++ apps/web/src/routes/docs.tsx | 2 +- apps/web/src/styles/app.css | 236 +++++++++++++++++- 20 files changed, 919 insertions(+), 11 deletions(-) create mode 100644 apps/web/public/brand/npm.svg create mode 100644 apps/web/src/components/cli-band/cli-band.stories.tsx create mode 100644 apps/web/src/components/cli-band/cli-band.test.tsx create mode 100644 apps/web/src/components/cli-band/cli-band.tsx create mode 100644 apps/web/src/components/cli-band/index.ts create mode 100644 apps/web/src/lib/cli-package.test.ts create mode 100644 apps/web/src/lib/cli-package.ts diff --git a/apps/web/public/brand/npm.svg b/apps/web/public/brand/npm.svg new file mode 100644 index 00000000..c7b56e47 --- /dev/null +++ b/apps/web/public/brand/npm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 6f7de057..15750494 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -1,8 +1,9 @@ # Sketchi Sketchi turns a plain-language prompt into a clean, editable diagram, brand -logos included. It is usable by people in the browser and by software agents -over MCP. Source is MIT-licensed at https://github.com/shpitdev/sketchi. +logos included. It is usable by people in the browser, by software agents over +MCP, and from a terminal through the sketchi CLI. Source is MIT-licensed at +https://github.com/shpitdev/sketchi. ## Surfaces @@ -25,7 +26,35 @@ The server is named sketchi-code-mode and exposes three tools: Per-agent setup instructions live at https://sketchi.app/agents, with pages for Claude Code, Codex, OpenCode, and Antigravity. +## CLI + +https://www.npmjs.com/package/sketchi — the published `sketchi` command-line +tool. Requires Node.js 24.13.0 or newer. Install with either: + + curl -fsSL https://raw.githubusercontent.com/shpitdev/sketchi/main/install.sh | sh + npm install -g sketchi + +Diagram records live under ~/.sketchi/diagrams. Every command accepts +--output json for a stable result envelope. Input is command-specific: create, +edit, and patch take their JSON through --json or --file (--file - reads +stdin); generate takes --prompt; pull takes --link. Other commands accept +neither --json nor --file. `export --dest -` streams raw artifact bytes to +stdout, with status on stderr. + +- generate: one prompt to a validated diagram. One credential-free HTTPS + request; no key, token, account, or login. +- create, edit, patch: author and revise from canonical JSON, or apply semantic + style and layout operations. Fully offline. +- show, list, restore: inspect a record, list the local store, recover a + retained revision. Fully offline. +- export: write PNG or Excalidraw. Deterministic and local; never starts a + browser or uses the network. +- share, pull: publish an encrypted excalidraw.com snapshot link and pull + browser edits back. One HTTPS request each. A share URL is a bearer secret. + +Install and command details: https://sketchi.app/docs#cli + ## Docs -https://sketchi.app/docs — how generation works, supported diagram types, and -the open-source layout. +https://sketchi.app/docs — how generation works, the CLI, supported diagram +types, and the open-source layout. diff --git a/apps/web/src/components/cli-band/cli-band.stories.tsx b/apps/web/src/components/cli-band/cli-band.stories.tsx new file mode 100644 index 00000000..21dd7efb --- /dev/null +++ b/apps/web/src/components/cli-band/cli-band.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { CliBand } from "./cli-band"; +import "../../styles/app.css"; + +const meta = { + title: "Web/Components/CliBand", + component: CliBand, + parameters: { + layout: "fullscreen", + }, + tags: ["test"], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +/** Narrow viewport: the copy and the terminal card stack. */ +export const Mobile: Story = { + parameters: { + viewport: { defaultViewport: "mobile1" }, + }, +}; diff --git a/apps/web/src/components/cli-band/cli-band.test.tsx b/apps/web/src/components/cli-band/cli-band.test.tsx new file mode 100644 index 00000000..8f1e3ddf --- /dev/null +++ b/apps/web/src/components/cli-band/cli-band.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CliBand } from "./cli-band"; +import { + CLI_INSTALL_COMMAND, + CLI_NPM_INSTALL_COMMAND, + CLI_NPM_URL, +} from "../../lib/cli-package"; + +describe("CliBand", () => { + it("publishes both verified install commands verbatim", () => { + render(); + + expect(screen.getByText(CLI_INSTALL_COMMAND)).toBeTruthy(); + expect(screen.getByText(CLI_NPM_INSTALL_COMMAND)).toBeTruthy(); + }); + + it("gives every command its own copy control", () => { + render(); + + expect( + screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")), + ).toEqual([ + "Copy install script", + "Copy npm install command", + "Copy example command", + ]); + }); + + /** + * The copy control is the whole point of the section: what lands on the + * clipboard has to be the exact command, not the wrapped text the reader + * sees. Headless Chrome blocks clipboard writes outright, so the payload is + * pinned here rather than in the browser pass. + */ + it("copies the exact install command, not the wrapped rendering", async () => { + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } }); + + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Copy install script" }), + ); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(CLI_INSTALL_COMMAND); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Copy npm install command" }), + ); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(CLI_NPM_INSTALL_COMMAND); + }); + + vi.unstubAllGlobals(); + }); + + /** + * `generate`, `share`, and `pull` all make HTTPS requests. Only the other + * seven commands are offline, so a blanket "offline except generation" claim + * is false — and a wrong privacy claim is worse than a vague one. + */ + it("does not overstate what works offline", () => { + const { container } = render(); + const text = container.textContent ?? ""; + + expect(text).not.toMatch(/offline for everything but generation/); + expect( + screen.getByText( + "Create, patch, show, edit, list, export, and restore never touch the network or a model.", + ), + ).toBeTruthy(); + }); + + it("links to the real npm package page with the npm mark", () => { + render(); + + const npmLink = screen.getByRole("link", { name: /View on npm/ }); + + expect(npmLink.getAttribute("href")).toBe(CLI_NPM_URL); + expect(CLI_NPM_URL).toBe("https://www.npmjs.com/package/sketchi"); + expect(screen.getByRole("img", { name: "npm" }).getAttribute("src")).toBe( + "/brand/npm.svg", + ); + }); + + it("links to the CLI docs section", () => { + render(); + + expect( + screen + .getByRole("link", { name: /Read the CLI docs/ }) + .getAttribute("href"), + ).toBe("/docs#cli"); + }); + + it("is anchored so navigation and footer entries can reach it", () => { + const { container } = render(); + + expect(container.querySelector("#cli")).toBeTruthy(); + }); + + /** + * A package version in copy goes stale the moment we publish, and a pinned + * install command installs yesterday's CLI. npm already shows the current + * version, so neither the copy nor the commands may name one. The Node + * engine floor is a different thing: it only moves when we choose to raise it. + */ + it("pins no package version", () => { + const { container } = render(); + + expect(container.textContent).not.toMatch(/sketchi@/); + expect(container.textContent).not.toMatch(/\bv\d/); + expect(screen.getByText(/Requires Node\.js .* or newer\./)).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/cli-band/cli-band.tsx b/apps/web/src/components/cli-band/cli-band.tsx new file mode 100644 index 00000000..c667cfb5 --- /dev/null +++ b/apps/web/src/components/cli-band/cli-band.tsx @@ -0,0 +1,99 @@ +import { BrandIcon } from "../brand-icon/index.js"; +import { CopyButton } from "../copy-button/index.js"; +import { + CLI_EXAMPLE_COMMAND, + CLI_NODE_REQUIREMENT, + CLI_NPM_URL, + CLI_PACKAGE_NAME, + cliCapabilities, + cliInstallOptions, +} from "../../lib/cli-package"; + +export interface CliBandProps { + /** Link to the published npm package. */ + npmUrl?: string; + /** Where the fuller CLI writeup lives. */ + docsHref?: string; +} + +/** + * The third way in. The playground and the agent route both get a full section + * on this page; the terminal is a peer of both, so it gets one too — the + * install command is the section, copy-pasteable, not a link to go find it. + */ +export function CliBand({ + docsHref = "/docs#cli", + npmUrl = CLI_NPM_URL, +}: CliBandProps) { + return ( +
+
+
+

+ Or never leave the terminal. +

+

+ {CLI_PACKAGE_NAME} is a real command-line tool, not a + wrapper around this website. It keeps your diagrams in{" "} + ~/.sketchi/diagrams, does its authoring, export, and + recovery work offline, and speaks --output json on + every command so an agent can drive it. +

+ +
+ {cliCapabilities.map((capability) => ( +
+
{capability.title}
+
{capability.description}
+
+ ))} +
+ + +
+ +
+ + +
+ {cliInstallOptions.map((option) => ( +
+
+
+                    {option.command}
+                  
+ +
+

{option.detail}

+
+ ))} + +

Then ask for a diagram:

+
+
+                {CLI_EXAMPLE_COMMAND}
+              
+ +
+

Requires {CLI_NODE_REQUIREMENT}.

+
+
+
+
+ ); +} diff --git a/apps/web/src/components/cli-band/index.ts b/apps/web/src/components/cli-band/index.ts new file mode 100644 index 00000000..cd2ece2c --- /dev/null +++ b/apps/web/src/components/cli-band/index.ts @@ -0,0 +1,2 @@ +export { CliBand } from "./cli-band.js"; +export type { CliBandProps } from "./cli-band.js"; diff --git a/apps/web/src/components/cta-band/cta-band.test.tsx b/apps/web/src/components/cta-band/cta-band.test.tsx index fcd7058a..9f828865 100644 --- a/apps/web/src/components/cta-band/cta-band.test.tsx +++ b/apps/web/src/components/cta-band/cta-band.test.tsx @@ -19,4 +19,14 @@ describe("CtaBand", () => { screen.getByRole("link", { name: /add it to your coding agent/ }), ).toHaveProperty("href", "http://localhost:3000/agents"); }); + + it("offers the CLI as the third closing route", () => { + render(); + + expect( + screen + .getByRole("link", { name: /install the CLI/ }) + .getAttribute("href"), + ).toBe("/#cli"); + }); }); diff --git a/apps/web/src/components/cta-band/cta-band.tsx b/apps/web/src/components/cta-band/cta-band.tsx index 7af068f9..158ff20c 100644 --- a/apps/web/src/components/cta-band/cta-band.tsx +++ b/apps/web/src/components/cta-band/cta-band.tsx @@ -5,15 +5,19 @@ export interface CtaBandProps { playgroundHref?: string; /** Link to the agent setup hub. */ agentsHref?: string; + /** Link to the CLI section. */ + cliHref?: string; } /** * Closing invitation — a warm, blank sketch sheet echoing the hero board. - * One clear next step; the agent path stays a quiet secondary link. + * One clear next step, with the agent and terminal paths as quiet secondary + * links for readers the browser is not the answer for. */ export function CtaBand({ playgroundHref = DEFAULT_WEB_SURFACE_URLS.playground, agentsHref = "/agents", + cliHref = "/#cli", }: CtaBandProps) { return (
@@ -29,6 +33,9 @@ export function CtaBand({ or add it to your coding agent → + + or install the CLI → + diff --git a/apps/web/src/components/docs-view/docs-view.test.tsx b/apps/web/src/components/docs-view/docs-view.test.tsx index fb1130d1..45c21f85 100644 --- a/apps/web/src/components/docs-view/docs-view.test.tsx +++ b/apps/web/src/components/docs-view/docs-view.test.tsx @@ -2,6 +2,10 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { DocsView } from "./docs-view"; +import { + CLI_INSTALL_COMMAND, + CLI_NPM_INSTALL_COMMAND, +} from "../../lib/cli-package"; describe("DocsView", () => { it("renders the docs heading, section nav, and sections", () => { @@ -25,6 +29,59 @@ describe("DocsView", () => { ).toBeNull(); }); + it("documents the CLI as the third path, with copyable install commands", () => { + render(); + + expect(screen.getByRole("heading", { name: "Three paths" })).toBeTruthy(); + expect(screen.getByText("The terminal")).toBeTruthy(); + expect(screen.getByRole("heading", { name: "The CLI" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "CLI" }).getAttribute("href")).toBe( + "#cli", + ); + expect(screen.getByText(CLI_INSTALL_COMMAND)).toBeTruthy(); + expect(screen.getByText(CLI_NPM_INSTALL_COMMAND)).toBeTruthy(); + expect( + screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")), + ).toEqual(["Copy install script", "Copy npm install command"]); + expect( + screen + .getByRole("link", { name: /npmjs\.com\/package\/sketchi/ }) + .getAttribute("href"), + ).toBe("https://www.npmjs.com/package/sketchi"); + }); + + /** + * The docs describe only commands the binary actually has. `sketchi --help` + * is the source of truth for this list. + */ + it("names only real subcommands", () => { + const { container } = render(); + + const realCommands = [ + "generate", + "create", + "edit", + "patch", + "show", + "list", + "restore", + "export", + "share", + "pull", + ]; + + const named = [ + ...container.querySelectorAll("#cli .docs-defs dt code"), + ].map((element) => element.textContent); + + expect(named.length).toBeGreaterThan(0); + for (const command of named) { + expect(realCommands).toContain(command); + } + }); + it("links to the live read-only example and the public source", () => { render(); diff --git a/apps/web/src/components/docs-view/docs-view.tsx b/apps/web/src/components/docs-view/docs-view.tsx index 31299a9c..26042944 100644 --- a/apps/web/src/components/docs-view/docs-view.tsx +++ b/apps/web/src/components/docs-view/docs-view.tsx @@ -1,3 +1,11 @@ +import { BrandIcon } from "../brand-icon/index.js"; +import { CopyButton } from "../copy-button/index.js"; +import { + CLI_NODE_REQUIREMENT, + CLI_NPM_URL, + CLI_PACKAGE_NAME, + cliInstallOptions, +} from "../../lib/cli-package"; import { DEFAULT_WEB_SURFACE_URLS, type WebSurfaceUrls, @@ -16,6 +24,7 @@ export interface DocsViewProps { const defaultNav: readonly DocsNavEntry[] = [ { href: "#how", label: "How it works" }, + { href: "#cli", label: "CLI" }, { href: "#open-source", label: "Open source" }, { href: "#diagram-types", label: "Diagram types" }, { href: "#faq", label: "FAQ" }, @@ -47,19 +56,19 @@ export function DocsView({

How Sketchi works

Sketchi turns a plain-language prompt into a clean, editable diagram, - brand logos and all. You can use it two ways. + brand logos and all. You can use it three ways.

-

Two paths

+

Three paths

You describe it. Sketchi structures and validates the request before drawing, so the result stays consistent, not a best-guess sketch. - What differs between the two paths is where that happens and who + What differs between the three paths is where that happens and who keeps the file.

-
+

You, in the playground

    @@ -86,6 +95,22 @@ export function DocsView({ agents page.

+ +
+

The terminal

+
    +
  1. + Run sketchi generate --prompt "…". +
  2. +
  3. Sketchi validates it and commits a local record.
  4. +
  5. Export PNG or Excalidraw offline.
  6. +
+

+ Your machine keeps the file, under{" "} + ~/.sketchi/diagrams. Install it{" "} + below. +

+
@@ -98,6 +123,106 @@ export function DocsView({
+
+

The CLI

+

+ {CLI_PACKAGE_NAME} is published on npm and authors + diagrams from your shell. Records live under{" "} + ~/.sketchi/diagrams with the canonical document, the + Excalidraw artifact, and every prior revision, so nothing you make + is trapped in a browser tab. +

+ +

Install

+ {cliInstallOptions.map((option) => ( +
+
+
+                  {option.command}
+                
+ +
+

{option.detail}

+
+ ))} +

Requires {CLI_NODE_REQUIREMENT}.

+ +

What the commands do

+
+
+
+ generate +
+
+ Turn one prompt into a validated diagram. Makes a single + credential-free HTTPS request — no key, token, account, or + login. +
+
+
+
+ create, edit, patch +
+
+ Author and revise from canonical JSON, or apply semantic style + and layout operations. Fully offline. +
+
+
+
+ show, list, restore +
+
+ Inspect a record, list the local store, and recover any retained + revision. Fully offline. +
+
+
+
+ export +
+
+ Write PNG or Excalidraw. PNG rendering is deterministic and + local; it never starts a browser or uses the network. +
+
+
+
+ share, pull +
+
+ Publish an encrypted excalidraw.com snapshot link, then pull + browser edits back into the record. One HTTPS request each. A + share URL is a bearer secret: anyone with the full link can + decrypt it, and Sketchi cannot revoke it. +
+
+
+ +
+ For agents + + Every command takes --output json for a stable result + envelope. create, edit, and{" "} + patch take their JSON noninteractively through{" "} + --json or --file; generate{" "} + takes --prompt; and export --dest -{" "} + streams raw bytes to stdout. Shell completions ship for zsh, bash, + and fish. + +
+ + +
+

Open source

@@ -155,6 +280,13 @@ export function DocsView({

Can I export?
Yes. Take it to your docs, slides, or pull request.
+
+
Is there a CLI?
+
+ Yes — {CLI_PACKAGE_NAME} on npm. See{" "} + the CLI section for install and commands. +
+
diff --git a/apps/web/src/components/marketing-home/marketing-home.test.tsx b/apps/web/src/components/marketing-home/marketing-home.test.tsx index 807cb13f..5c8e5958 100644 --- a/apps/web/src/components/marketing-home/marketing-home.test.tsx +++ b/apps/web/src/components/marketing-home/marketing-home.test.tsx @@ -25,6 +25,18 @@ describe("MarketingHome", () => { ).toBeTruthy(); }); + it("features the CLI on the homepage with a reachable anchor", () => { + const { container } = render(); + + expect( + screen.getByRole("heading", { name: "Or never leave the terminal." }), + ).toBeTruthy(); + expect(container.querySelector("#cli")).toBeTruthy(); + expect( + screen.getByRole("link", { name: /View on npm/ }).getAttribute("href"), + ).toBe("https://www.npmjs.com/package/sketchi"); + }); + it("uses configured surface URLs", () => { render( + diff --git a/apps/web/src/components/site-footer/site-footer.test.tsx b/apps/web/src/components/site-footer/site-footer.test.tsx index 7a9a7ed6..b3f3db89 100644 --- a/apps/web/src/components/site-footer/site-footer.test.tsx +++ b/apps/web/src/components/site-footer/site-footer.test.tsx @@ -39,6 +39,22 @@ describe("SiteFooter", () => { ).toBe("https://sketchi.app/llms.txt"); }); + it("lists the CLI as a product and links the npm package with its mark", () => { + render(); + + expect(screen.getByRole("link", { name: "CLI" }).getAttribute("href")).toBe( + "/#cli", + ); + + const npmLink = screen.getByRole("link", { name: /npm package/ }); + expect(npmLink.getAttribute("href")).toBe( + "https://www.npmjs.com/package/sketchi", + ); + expect(npmLink.querySelector("img")?.getAttribute("src")).toBe( + "/brand/npm.svg", + ); + }); + it("uses configured surface links", () => { render( Icons +
  • + CLI +
  • Agents
  • @@ -92,6 +99,12 @@ export function SiteFooter({
  • How it works
  • +
  • + + + npm package + +
  • GitHub
  • diff --git a/apps/web/src/components/site-header/site-header.test.tsx b/apps/web/src/components/site-header/site-header.test.tsx index caade465..41bd9e6b 100644 --- a/apps/web/src/components/site-header/site-header.test.tsx +++ b/apps/web/src/components/site-header/site-header.test.tsx @@ -44,6 +44,27 @@ describe("SiteHeader", () => { ).toBe("https://sketchi-studio-pr-42.dimethyl.workers.dev"); }); + /** + * The CLI is a peer of the playground and the agent route, so it has to be + * reachable from the nav on every page, in the desktop bar and the mobile + * sheet alike. + */ + it("offers the CLI in both the desktop nav and the mobile sheet", () => { + render(); + + expect(screen.getByRole("link", { name: "CLI" }).getAttribute("href")).toBe( + "/#cli", + ); + + fireEvent.click(screen.getByRole("button", { name: "Toggle menu" })); + + expect( + screen + .getAllByRole("link", { name: "CLI" }) + .map((link) => link.getAttribute("href")), + ).toEqual(["/#cli", "/#cli"]); + }); + it("toggles the mobile menu", () => { render(); diff --git a/apps/web/src/components/site-header/site-header.tsx b/apps/web/src/components/site-header/site-header.tsx index bd56cc71..e9c1c27b 100644 --- a/apps/web/src/components/site-header/site-header.tsx +++ b/apps/web/src/components/site-header/site-header.tsx @@ -19,6 +19,7 @@ export interface SiteHeaderProps { const defaultNavItems: readonly SiteHeaderNavItem[] = [ { href: "/#product", label: "Product" }, + { href: "/#cli", label: "CLI" }, { href: "/agents", label: "Agents" }, { href: "/docs", label: "Docs" }, ]; diff --git a/apps/web/src/lib/cli-package.test.ts b/apps/web/src/lib/cli-package.test.ts new file mode 100644 index 00000000..bf8c3e52 --- /dev/null +++ b/apps/web/src/lib/cli-package.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + CLI_INSTALL_COMMAND, + CLI_NPM_INSTALL_COMMAND, + CLI_NPM_URL, + CLI_PACKAGE_NAME, + cliInstallOptions, +} from "./cli-package"; + +/** + * A wrong install line on the homepage is worse than no install line: the + * reader blames the product, not the copy. Both commands below were run + * end-to-end against the published package before they shipped, so they are + * pinned here byte-for-byte — changing one should require deliberately updating + * this test, and re-running it for real. + */ +describe("CLI package facts", () => { + it("publishes the verified installer one-liner", () => { + expect(CLI_INSTALL_COMMAND).toBe( + "curl -fsSL https://raw.githubusercontent.com/shpitdev/sketchi/main/install.sh | sh", + ); + }); + + it("publishes the verified npm install command", () => { + expect(CLI_NPM_INSTALL_COMMAND).toBe("npm install -g sketchi"); + }); + + it("uses the public package name, never the workspace name", () => { + expect(CLI_PACKAGE_NAME).toBe("sketchi"); + expect(CLI_NPM_URL).toBe("https://www.npmjs.com/package/sketchi"); + + for (const option of cliInstallOptions) { + expect(option.command).not.toContain("@sketchi/cli"); + } + }); + + it("pins no version, so the copy cannot go stale", () => { + for (const option of cliInstallOptions) { + expect(option.command).not.toContain("sketchi@"); + } + }); +}); diff --git a/apps/web/src/lib/cli-package.ts b/apps/web/src/lib/cli-package.ts new file mode 100644 index 00000000..214c7d8f --- /dev/null +++ b/apps/web/src/lib/cli-package.ts @@ -0,0 +1,83 @@ +/** + * Facts about the published `sketchi` CLI, in one place so the homepage, the + * docs page, and the footer cannot drift from each other or from the package. + * + * Every claim here is taken from the CLI's own command surface (`sketchi + * --help`) and its published npm metadata. Do not add a capability that the + * binary does not have, and do not hard-code a version: npm already shows the + * current one, and a number in copy goes stale the next time we publish. + */ + +/** Public package name. Never the workspace name `@sketchi/cli`. */ +export const CLI_PACKAGE_NAME = "sketchi"; + +export const CLI_NPM_URL = `https://www.npmjs.com/package/${CLI_PACKAGE_NAME}`; + +/** + * The installer resolves the published package, then writes completions for a + * detected zsh, bash, or fish shell. Verified against the live script. + */ +export const CLI_INSTALL_COMMAND = + "curl -fsSL https://raw.githubusercontent.com/shpitdev/sketchi/main/install.sh | sh"; + +/** The plain npm path, for people who would rather not pipe a script to a shell. */ +export const CLI_NPM_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE_NAME}`; + +/** From the published package's `engines.node`. */ +export const CLI_NODE_REQUIREMENT = "Node.js 24.13.0 or newer"; + +/** One command worth showing as proof that it does real work. */ +export const CLI_EXAMPLE_COMMAND = + 'sketchi generate --prompt "Map our release approval flow"'; + +export interface CliInstallOption { + /** The shell command itself. */ + command: string; + /** What this path gets you, beyond the binary. */ + detail: string; + /** Accessible label for the copy control. */ + label: string; +} + +export const cliInstallOptions: readonly CliInstallOption[] = [ + { + command: CLI_INSTALL_COMMAND, + detail: + "Installs the package and sets up completions for zsh, bash, or fish.", + label: "install script", + }, + { + command: CLI_NPM_INSTALL_COMMAND, + detail: + "Straight from npm, if you would rather configure completions yourself.", + label: "npm install command", + }, +]; + +export interface CliCapability { + description: string; + title: string; +} + +/** + * Three honest capability claims. Each maps to real subcommands: the offline + * seven (`create`, `patch`, `show`, `edit`, `list`, `export`, `restore`), the + * credential-free `generate`, and the local PNG/Excalidraw exporter. + */ +export const cliCapabilities: readonly CliCapability[] = [ + { + description: + "One prompt, one HTTPS request, a validated diagram on disk. No API key, token, or account.", + title: "Generate without signing in", + }, + { + description: + "Create, patch, show, edit, list, export, and restore never touch the network or a model.", + title: "Seven commands work offline", + }, + { + description: + "Render PNG or Excalidraw locally, deterministically, without starting a browser.", + title: "Export from the terminal", + }, +]; diff --git a/apps/web/src/routes/docs.tsx b/apps/web/src/routes/docs.tsx index 1cfd08ed..3dcc97f9 100644 --- a/apps/web/src/routes/docs.tsx +++ b/apps/web/src/routes/docs.tsx @@ -12,7 +12,7 @@ export const Route = createFileRoute("/docs")({ pageMeta({ title: "Docs - Sketchi", description: - "How Sketchi works: turn a plain-language prompt into a clean, editable diagram, in the playground or inside your coding agent.", + "How Sketchi works: turn a plain-language prompt into a clean, editable diagram, in the playground, inside your coding agent, or from the sketchi CLI.", path: "/docs", }), loader: () => getWebSurfaceUrls(), diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 09dab95e..64ebc497 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -633,6 +633,161 @@ max-width: 31ch; } +/* ===================================================================== */ +/* CLI band — the terminal as a peer of the playground and the agent */ +/* route, so the install command is the section rather than a link to it. */ +/* ===================================================================== */ +.cli-band__inner { + align-items: start; + display: grid; + gap: 44px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); +} + +.cli-band__title { + max-width: 18ch; +} + +.cli-band__lead { + color: var(--ink-2); + font-size: 17px; + line-height: 1.6; + margin: 16px 0 0; + max-width: 46ch; +} + +.cli-band__lead code, +.cli-band__point dd code { + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 0.88em; + padding: 1px 5px; +} + +.cli-band__points { + display: grid; + gap: 16px; + margin: 26px 0 0; +} + +.cli-band__point dt { + color: var(--ink); + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; +} + +.cli-band__point dd { + color: var(--ink-2); + font-size: 14.5px; + line-height: 1.6; + margin: 3px 0 0; + max-width: 44ch; +} + +.cli-band__links { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + margin-top: 30px; +} + +/* The npm mark is a solid red tile; round it a touch so it sits in the button + like the other brand tiles rather than as a hard square. */ +.cli-band__npm .brand-icon__img { + border-radius: 3px; + flex: none; +} + +.cli-band__docs-link { + color: var(--accent-ink); + font-size: 15px; + font-weight: 700; + text-decoration: none; +} + +.cli-band__docs-link:hover { + text-decoration: underline; +} + +/* Terminal card: same chrome as the hero board, dark body like a codeblock. */ +.cli-band__terminal { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-lg); + box-shadow: var(--shadow-md); + min-width: 0; + overflow: hidden; +} + +.cli-band__bar { + align-items: center; + background: color-mix(in srgb, var(--card) 86%, var(--paper)); + border-bottom: 1px solid var(--line); + display: flex; + gap: 10px; + justify-content: space-between; + padding: 11px 15px; +} + +.cli-band__dots { + display: inline-flex; + gap: 6px; +} + +.cli-band__dots i { + background: var(--line-2); + block-size: 9px; + border-radius: 999px; + inline-size: 9px; +} + +.cli-band__dots i:first-child { + background: var(--accent); +} + +.cli-band__shell { + color: var(--ink-3); + font-family: var(--font-mono); + font-size: 11.5px; + letter-spacing: 0.03em; +} + +.cli-band__body { + padding: 18px 18px 20px; +} + +.cli-band__body .docs-codeblock { + margin: 0; +} + +.cli-band__note { + color: var(--ink-3); + font-size: 13px; + line-height: 1.5; + margin: 8px 0 0; +} + +.cli-band__step + .cli-band__step { + margin-top: 18px; +} + +.cli-band__then { + color: var(--ink-2); + font-size: 14.5px; + font-weight: 600; + margin: 24px 0 10px; +} + +.cli-band__req { + color: var(--ink-3); + font-size: 13px; + margin: 12px 0 0; +} + /* ===================================================================== */ /* Icon wall */ /* ===================================================================== */ @@ -895,6 +1050,18 @@ padding: 1px 6px; } +/* The inline pill above is for `code` in running prose. Inside a codeblock the + block already owns the surface, so a pale pill on dark ink renders the + command unreadable — the block's own styling wins. */ +.docs-section .docs-codeblock code { + background: none; + border: 0; + border-radius: 0; + color: inherit; + font-size: inherit; + padding: 0; +} + .docs-callout { background: var(--accent-wash); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); @@ -932,6 +1099,35 @@ color: #b6ad97; } +/* Docs CLI section — subheads, install steps, and the npm package link */ +.docs-h3 { + font-family: var(--font-display); + font-size: 17px; + margin: 26px 0 0; +} + +.docs-install .docs-codeblock { + margin-bottom: 6px; +} + +.docs-install__note { + color: var(--ink-3) !important; + font-size: 13px; + line-height: 1.5; + margin: 0 0 14px !important; +} + +.docs-npm-link { + align-items: center; + display: inline-flex; + gap: 7px; +} + +.docs-npm-link .brand-icon__img { + border-radius: 3px; + flex: none; +} + .docs-defs { border: 1px solid var(--line); border-radius: var(--r-md); @@ -963,7 +1159,7 @@ margin: 0; } -/* Two-path explainer */ +/* Path explainer */ .docs-paths { display: grid; gap: 14px; @@ -971,6 +1167,18 @@ margin: 18px 0; } +/* The article column is capped at 72ch, so three side-by-side lanes would be + ~200px each — too narrow for the inline commands they carry. Stack them: the + lanes stay adjacent and identically shaped, so they still read as a + comparison, and each one gets the width its content needs. */ +.docs-paths--three { + grid-template-columns: minmax(0, 1fr); +} + +.docs-paths--three .docs-lane__steps { + padding-left: 20px; +} + .docs-lane { background: var(--card); border: 1px solid var(--line); @@ -1000,6 +1208,10 @@ --lane-accent: var(--blueprint); } +.docs-lane--cli { + --lane-accent: var(--ok); +} + .docs-lane__tag { color: var(--ink) !important; font-family: var(--font-display); @@ -1365,6 +1577,17 @@ transition: color 0.16s var(--ease); } +.site-footer__npm { + align-items: center; + display: inline-flex; + gap: 7px; +} + +.site-footer__npm .brand-icon__img { + border-radius: 3px; + flex: none; +} + .site-footer__col a:hover { color: #fff; } @@ -1433,6 +1656,17 @@ grid-template-columns: 1fr 1fr; } + .cli-band__inner { + gap: 32px; + grid-template-columns: 1fr; + } + + .cli-band__title, + .cli-band__lead, + .cli-band__point dd { + max-width: none; + } + .agent-hero__inner, .agent-detail__layout { grid-template-columns: 1fr;