From 4dbb97a79a64baad60734e2f1076104008405d9e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 30 Jul 2026 19:54:57 +0000 Subject: [PATCH] feat(web): put the one-line install in front of everyone The CLI is the product, and the way you get it was nowhere on the site. You had to already know the URL of a script served out of public/. Two placements, one command: - The homepage hero gets the loud version, directly under the lede and above the fold -- a bordered dark panel, the command at full size, Copy alongside. - Every page carries a compact version in the rail, between the brand and the nav. Present on arrival, never competing with navigation. Both come from renderInstallCommand() in one module. The homepage builds its HTML as a string and the rest of the site is JSX, which is precisely the shape that lets one copy of a command drift while the other stays right -- so there is one definition and SiteShell renders it rather than restating it. The command keeps its flags: `curl -fsSL`. Without -f, curl prints an HTTP error body and still exits 0, so a 404 gets piped into sh; without -L the install breaks the first time the URL redirects. This is the form install.sh already documents in its own header. Copy is one delegated listener on document for any [data-copy] button, mounted site-wide in the layout. Delegation because the two placements arrive by different rendering paths and a document listener does not care which; it also means the next copy button needs the attribute and no wiring. It falls back to a throwaway textarea + execCommand outside a secure context, where navigator.clipboard is simply undefined, so the button never no-ops silently. The contract tests pin the command, both placements, and that the clipboard payload equals the visible text -- a Copy button that hands over something other than what is on screen is worse than no button. They also read public/install.sh and assert it is #!/bin/sh and documents this exact command, so `| sh` cannot quietly become a lie. apps/logicsrc-web: 13 new tests pass, 46 total. The ontology-api contract file fails to resolve @logicsrc/validators, which it also does on a pristine origin/master -- unbuilt workspace package, unrelated to this change. Co-Authored-By: Claude Opus 5 --- .../contract/install-command.contract.test.ts | 84 +++++++++++ apps/logicsrc-web/src/app/layout.tsx | 3 + .../src/components/copy-buttons.tsx | 71 ++++++++++ .../src/components/site-shell.tsx | 4 + apps/logicsrc-web/src/lib/install-command.ts | 53 +++++++ apps/logicsrc-web/src/lib/page-markup.ts | 3 + apps/logicsrc-web/src/styles.css | 131 ++++++++++++++++++ 7 files changed, 349 insertions(+) create mode 100644 apps/logicsrc-web/contract/install-command.contract.test.ts create mode 100644 apps/logicsrc-web/src/components/copy-buttons.tsx create mode 100644 apps/logicsrc-web/src/lib/install-command.ts diff --git a/apps/logicsrc-web/contract/install-command.contract.test.ts b/apps/logicsrc-web/contract/install-command.contract.test.ts new file mode 100644 index 0000000..0dd9ac8 --- /dev/null +++ b/apps/logicsrc-web/contract/install-command.contract.test.ts @@ -0,0 +1,84 @@ +// The install command is the site's single most copy-pasted string, and it is +// rendered in two places by two different mechanisms -- the homepage builds +// HTML as a string, the rest of the site is JSX. That is exactly the shape that +// lets one copy drift while the other stays right, so these pin the command +// itself, both placements, and the flags that make piping to `sh` safe. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + INSTALL_COMMAND, + INSTALL_SCRIPT_PATH, + renderInstallCommand, +} from "../src/lib/install-command"; +import { renderPageMarkup } from "../src/lib/page-markup"; + +const repoRoot = join(__dirname, ".."); + +describe("the install command itself", () => { + it("is the exact one-liner", () => { + expect(INSTALL_COMMAND).toBe("curl -fsSL https://logicsrc.com/install.sh | sh"); + }); + + it("keeps the flags that make piping into a shell safe", () => { + // -f so an HTTP error page is never piped into sh, -L so a redirect does + // not silently truncate the install. Dropping either is the bug this pins. + expect(INSTALL_COMMAND).toMatch(/curl\b[^|]*-[a-zA-Z]*f/); + expect(INSTALL_COMMAND).toMatch(/curl\b[^|]*-[a-zA-Z]*L/); + }); + + it("points at a script that is actually published", () => { + // public/ is served at the site root, so this is the URL in the command. + const script = readFileSync(join(repoRoot, "public", INSTALL_SCRIPT_PATH), "utf8"); + expect(script.startsWith("#!/bin/sh")).toBe(true); + // The command says `| sh`; a bash shebang here would make that a lie. + expect(script).toContain(INSTALL_COMMAND); + }); +}); + +describe.each(["hero", "rail"] as const)("the %s placement", (variant) => { + const html = renderInstallCommand(variant); + + it("shows the command", () => { + expect(html).toContain(INSTALL_COMMAND); + }); + + it("offers a copy button carrying the same text that is on screen", () => { + expect(html).toContain(`data-copy="${INSTALL_COMMAND}"`); + // A button whose clipboard payload differs from the visible command is + // worse than no button, so the two are asserted against one constant. + const shown = html.match(/]*>([^<]+)<\/code>/)?.[1]; + const copied = html.match(/data-copy="([^"]+)"/)?.[1]; + expect(shown).toBe(copied); + }); + + it("is a real button, reachable by keyboard and labelled", () => { + expect(html).toContain('type="button"'); + expect(html).toContain('aria-label="Copy the install command"'); + }); +}); + +describe("placement on the site", () => { + const home = renderPageMarkup(); + + it("puts the loud version in the homepage hero", () => { + expect(home).toContain('class="install-cta"'); + // Above the fold means before the first content band, not merely present. + expect(home.indexOf("install-cta")).toBeLessThan(home.indexOf('class="band"')); + }); + + it("also carries the compact version in the chrome", () => { + expect(home).toContain('class="install-rail"'); + }); + + it("keeps the compact one out of the way -- inside the rail, above the nav", () => { + const rail = home.indexOf('class="install-rail"'); + expect(rail).toBeGreaterThan(home.indexOf('class="rail"')); + expect(rail).toBeLessThan(home.indexOf(" { + expect(home.split(INSTALL_COMMAND).length - 1).toBe(4); // 2 placements x (code + data-copy) + }); +}); diff --git a/apps/logicsrc-web/src/app/layout.tsx b/apps/logicsrc-web/src/app/layout.tsx index f026e74..becb010 100644 --- a/apps/logicsrc-web/src/app/layout.tsx +++ b/apps/logicsrc-web/src/app/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import "../styles.css"; import Script from "next/script"; import { FeedbackWidget } from "@profullstack/stack/feedback"; +import { CopyButtons } from "@/components/copy-buttons"; const SITE_URL = (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, ""); const DESCRIPTION = @@ -82,6 +83,8 @@ export default function RootLayout({ children }: { children: ReactNode }): React dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> {children} + {/* one delegated handler for every [data-copy] button, site-wide */} +