Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/public/brand/npm.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 33 additions & 4 deletions apps/web/public/llms.txt
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
26 changes: 26 additions & 0 deletions apps/web/src/components/cli-band/cli-band.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CliBand>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

/** Narrow viewport: the copy and the terminal card stack. */
export const Mobile: Story = {
parameters: {
viewport: { defaultViewport: "mobile1" },
},
};
120 changes: 120 additions & 0 deletions apps/web/src/components/cli-band/cli-band.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CliBand />);

expect(screen.getByText(CLI_INSTALL_COMMAND)).toBeTruthy();
expect(screen.getByText(CLI_NPM_INSTALL_COMMAND)).toBeTruthy();
});

it("gives every command its own copy control", () => {
render(<CliBand />);

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(<CliBand />);

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(<CliBand />);
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(<CliBand />);

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(<CliBand />);

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(<CliBand />);

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(<CliBand />);

expect(container.textContent).not.toMatch(/sketchi@/);
expect(container.textContent).not.toMatch(/\bv\d/);
expect(screen.getByText(/Requires Node\.js .* or newer\./)).toBeTruthy();
});
});
99 changes: 99 additions & 0 deletions apps/web/src/components/cli-band/cli-band.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="sk-section cli-band" id="cli">
<div className="sk-shell cli-band__inner">
<div className="cli-band__copy">
<h2 className="sk-section__title cli-band__title">
Or never leave the terminal.
</h2>
<p className="cli-band__lead">
<code>{CLI_PACKAGE_NAME}</code> is a real command-line tool, not a
wrapper around this website. It keeps your diagrams in{" "}
<code>~/.sketchi/diagrams</code>, does its authoring, export, and
recovery work offline, and speaks <code>--output json</code> on
every command so an agent can drive it.
</p>

<dl className="cli-band__points">
{cliCapabilities.map((capability) => (
<div className="cli-band__point" key={capability.title}>
<dt>{capability.title}</dt>
<dd>{capability.description}</dd>
</div>
))}
</dl>

<div className="cli-band__links">
<a className="sk-btn sk-btn--ghost cli-band__npm" href={npmUrl}>
<BrandIcon label="npm" size={18} src="/brand/npm.svg" />
View on npm
</a>
<a className="cli-band__docs-link" href={docsHref}>
Read the CLI docs →
</a>
</div>
</div>

<div className="cli-band__terminal">
<div className="cli-band__bar" aria-hidden="true">
<span className="cli-band__dots">
<i />
<i />
<i />
</span>
<span className="cli-band__shell">zsh</span>
</div>

<div className="cli-band__body">
{cliInstallOptions.map((option) => (
<div className="cli-band__step" key={option.command}>
<div className="code-snippet">
<pre className="docs-codeblock">
<code>{option.command}</code>
</pre>
<CopyButton label={option.label} value={option.command} />
</div>
<p className="cli-band__note">{option.detail}</p>
</div>
))}

<p className="cli-band__then">Then ask for a diagram:</p>
<div className="code-snippet">
<pre className="docs-codeblock">
<code>{CLI_EXAMPLE_COMMAND}</code>
</pre>
<CopyButton label="example command" value={CLI_EXAMPLE_COMMAND} />
</div>
<p className="cli-band__req">Requires {CLI_NODE_REQUIREMENT}.</p>
</div>
</div>
</div>
</section>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/components/cli-band/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { CliBand } from "./cli-band.js";
export type { CliBandProps } from "./cli-band.js";
10 changes: 10 additions & 0 deletions apps/web/src/components/cta-band/cta-band.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CtaBand />);

expect(
screen
.getByRole("link", { name: /install the CLI/ })
.getAttribute("href"),
).toBe("/#cli");
});
});
9 changes: 8 additions & 1 deletion apps/web/src/components/cta-band/cta-band.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className="cta-band">
Expand All @@ -29,6 +33,9 @@ export function CtaBand({
<a className="cta-band__link" href={agentsHref}>
or add it to your coding agent →
</a>
<a className="cta-band__link" href={cliHref}>
or install the CLI →
</a>
</div>
</div>
</div>
Expand Down
Loading
Loading