Skip to content

Commit f41abbd

Browse files
ralyodioclaude
andcommitted
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 <noreply@anthropic.com>
1 parent d28f20b commit f41abbd

7 files changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// The install command is the site's single most copy-pasted string, and it is
2+
// rendered in two places by two different mechanisms -- the homepage builds
3+
// HTML as a string, the rest of the site is JSX. That is exactly the shape that
4+
// lets one copy drift while the other stays right, so these pin the command
5+
// itself, both placements, and the flags that make piping to `sh` safe.
6+
import { readFileSync } from "node:fs";
7+
import { join } from "node:path";
8+
import { describe, expect, it } from "vitest";
9+
10+
import {
11+
INSTALL_COMMAND,
12+
INSTALL_SCRIPT_PATH,
13+
renderInstallCommand,
14+
} from "../src/lib/install-command";
15+
import { renderPageMarkup } from "../src/lib/page-markup";
16+
17+
const repoRoot = join(__dirname, "..");
18+
19+
describe("the install command itself", () => {
20+
it("is the exact one-liner", () => {
21+
expect(INSTALL_COMMAND).toBe("curl -fsSL https://logicsrc.com/install.sh | sh");
22+
});
23+
24+
it("keeps the flags that make piping into a shell safe", () => {
25+
// -f so an HTTP error page is never piped into sh, -L so a redirect does
26+
// not silently truncate the install. Dropping either is the bug this pins.
27+
expect(INSTALL_COMMAND).toMatch(/curl\b[^|]*-[a-zA-Z]*f/);
28+
expect(INSTALL_COMMAND).toMatch(/curl\b[^|]*-[a-zA-Z]*L/);
29+
});
30+
31+
it("points at a script that is actually published", () => {
32+
// public/ is served at the site root, so this is the URL in the command.
33+
const script = readFileSync(join(repoRoot, "public", INSTALL_SCRIPT_PATH), "utf8");
34+
expect(script.startsWith("#!/bin/sh")).toBe(true);
35+
// The command says `| sh`; a bash shebang here would make that a lie.
36+
expect(script).toContain(INSTALL_COMMAND);
37+
});
38+
});
39+
40+
describe.each(["hero", "rail"] as const)("the %s placement", (variant) => {
41+
const html = renderInstallCommand(variant);
42+
43+
it("shows the command", () => {
44+
expect(html).toContain(INSTALL_COMMAND);
45+
});
46+
47+
it("offers a copy button carrying the same text that is on screen", () => {
48+
expect(html).toContain(`data-copy="${INSTALL_COMMAND}"`);
49+
// A button whose clipboard payload differs from the visible command is
50+
// worse than no button, so the two are asserted against one constant.
51+
const shown = html.match(/<code[^>]*>([^<]+)<\/code>/)?.[1];
52+
const copied = html.match(/data-copy="([^"]+)"/)?.[1];
53+
expect(shown).toBe(copied);
54+
});
55+
56+
it("is a real button, reachable by keyboard and labelled", () => {
57+
expect(html).toContain('type="button"');
58+
expect(html).toContain('aria-label="Copy the install command"');
59+
});
60+
});
61+
62+
describe("placement on the site", () => {
63+
const home = renderPageMarkup();
64+
65+
it("puts the loud version in the homepage hero", () => {
66+
expect(home).toContain('class="install-cta"');
67+
// Above the fold means before the first content band, not merely present.
68+
expect(home.indexOf("install-cta")).toBeLessThan(home.indexOf('class="band"'));
69+
});
70+
71+
it("also carries the compact version in the chrome", () => {
72+
expect(home).toContain('class="install-rail"');
73+
});
74+
75+
it("keeps the compact one out of the way -- inside the rail, above the nav", () => {
76+
const rail = home.indexOf('class="install-rail"');
77+
expect(rail).toBeGreaterThan(home.indexOf('class="rail"'));
78+
expect(rail).toBeLessThan(home.indexOf("<nav"));
79+
});
80+
81+
it("renders the command twice and no more", () => {
82+
expect(home.split(INSTALL_COMMAND).length - 1).toBe(4); // 2 placements x (code + data-copy)
83+
});
84+
});

apps/logicsrc-web/src/app/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
33
import "../styles.css";
44
import Script from "next/script";
55
import { FeedbackWidget } from "@profullstack/stack/feedback";
6+
import { CopyButtons } from "@/components/copy-buttons";
67

78
const SITE_URL = (process.env.PUBLIC_URL ?? "https://logicsrc.com").replace(/\/$/, "");
89
const DESCRIPTION =
@@ -82,6 +83,8 @@ export default function RootLayout({ children }: { children: ReactNode }): React
8283
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
8384
/>
8485
{children}
86+
{/* one delegated handler for every [data-copy] button, site-wide */}
87+
<CopyButtons />
8588
<Script
8689
data-site="56a0c760-e6cb-4875-844e-8b8aaa80b59b"
8790
src="https://crawlproof.com/stats.js"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
5+
// One delegated listener for every `[data-copy]` button on the page.
6+
//
7+
// Delegation rather than a handler per button because the markup these target
8+
// is server-rendered in two different ways -- the homepage arrives as an HTML
9+
// string through dangerouslySetInnerHTML, the rest as JSX -- and a listener on
10+
// `document` does not care which. It also means a new copy button anywhere on
11+
// the site needs no wiring, just the attribute.
12+
export function CopyButtons(): null {
13+
useEffect(() => {
14+
const flash = (button: HTMLButtonElement, message: string): void => {
15+
const original = button.dataset.copyLabel ?? button.textContent ?? "Copy";
16+
button.dataset.copyLabel = original;
17+
button.textContent = message;
18+
button.classList.add("is-copied");
19+
window.setTimeout(() => {
20+
button.textContent = original;
21+
button.classList.remove("is-copied");
22+
}, 1600);
23+
};
24+
25+
// navigator.clipboard is undefined outside a secure context, which includes
26+
// plain-http previews and older Safari. Falling back to a throwaway
27+
// textarea keeps the button honest there instead of silently doing nothing.
28+
const legacyCopy = (text: string): boolean => {
29+
const field = document.createElement("textarea");
30+
field.value = text;
31+
field.setAttribute("readonly", "");
32+
field.style.position = "fixed";
33+
field.style.opacity = "0";
34+
document.body.appendChild(field);
35+
field.select();
36+
let copied = false;
37+
try {
38+
copied = document.execCommand("copy");
39+
} catch {
40+
copied = false;
41+
}
42+
field.remove();
43+
return copied;
44+
};
45+
46+
const onClick = async (event: MouseEvent): Promise<void> => {
47+
const target = event.target as HTMLElement | null;
48+
const button = target?.closest<HTMLButtonElement>("button[data-copy]");
49+
if (!button) return;
50+
51+
const text = button.dataset.copy ?? "";
52+
if (!text) return;
53+
54+
try {
55+
if (navigator.clipboard?.writeText) {
56+
await navigator.clipboard.writeText(text);
57+
flash(button, "Copied");
58+
return;
59+
}
60+
} catch {
61+
// permission denied or a non-secure context -- fall through
62+
}
63+
flash(button, legacyCopy(text) ? "Copied" : "Press Ctrl+C");
64+
};
65+
66+
document.addEventListener("click", onClick);
67+
return () => document.removeEventListener("click", onClick);
68+
}, []);
69+
70+
return null;
71+
}

apps/logicsrc-web/src/components/site-shell.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from "react";
2+
import { renderInstallCommand } from "@/lib/install-command";
23

34
// Mirrors the rail/nav from page-markup.ts so standalone routes (e.g. /blog)
45
// share the site chrome. Anchor links point at the homepage sections.
@@ -40,6 +41,9 @@ export function SiteShell({
4041
<small>Open coordination standards</small>
4142
</div>
4243
</a>
44+
{/* Same markup the homepage uses, so the two can never drift apart.
45+
Static content from a module constant -- nothing user-supplied. */}
46+
<div dangerouslySetInnerHTML={{ __html: renderInstallCommand("rail") }} />
4347
<nav aria-label="LogicSRC sections">
4448
{NAV.map((item) => (
4549
<a
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// The one-line CLI install, rendered as HTML so the string-built homepage
2+
// (page-markup.ts) and the React chrome (SiteShell) can share one definition.
3+
// Two renderings of the same command is how a shipped hint ends up disagreeing
4+
// with itself, so there is deliberately only one here.
5+
6+
/**
7+
* The command, verbatim.
8+
*
9+
* The flags are not decoration. Without `-f`, curl prints an HTTP error body
10+
* and exits 0, so a 404 gets piped into `sh`; without `-L` the install breaks
11+
* the moment the URL redirects. `-sS` keeps the progress meter out of the pipe
12+
* while leaving real errors visible. This is the form install.sh documents in
13+
* its own header.
14+
*/
15+
export const INSTALL_COMMAND = "curl -fsSL https://logicsrc.com/install.sh | sh";
16+
17+
/** Where the script itself lives, for people who read before they pipe. */
18+
export const INSTALL_SCRIPT_PATH = "/install.sh";
19+
20+
/**
21+
* A copy button. The command is static and contains no markup-significant
22+
* characters, so it goes into the attribute as-is; `copy-buttons.tsx` reads it
23+
* back out. Keeping the text on the button means the clipboard can never
24+
* disagree with what is on screen.
25+
*/
26+
function copyButton(className: string): string {
27+
return `<button type="button" class="${className}" data-copy="${INSTALL_COMMAND}" aria-label="Copy the install command">Copy</button>`;
28+
}
29+
30+
/**
31+
* @param variant - `hero` is the homepage's unmissable version; `rail` is the
32+
* compact one that rides along in the site chrome on every other page.
33+
*/
34+
export function renderInstallCommand(variant: "hero" | "rail"): string {
35+
if (variant === "rail") {
36+
return `<div class="install-rail">
37+
<span class="install-rail-label">Install the CLI</span>
38+
<div class="install-rail-row">
39+
<code>${INSTALL_COMMAND}</code>
40+
${copyButton("install-copy install-copy-sm")}
41+
</div>
42+
</div>`;
43+
}
44+
45+
return `<div class="install-cta">
46+
<p class="install-cta-label">Get the CLI</p>
47+
<div class="install-cta-row">
48+
<code class="install-cta-cmd">${INSTALL_COMMAND}</code>
49+
${copyButton("install-copy")}
50+
</div>
51+
<p class="install-cta-note">macOS and Linux · needs Node 18+ · <a href="${INSTALL_SCRIPT_PATH}">read the script first</a></p>
52+
</div>`;
53+
}

apps/logicsrc-web/src/lib/page-markup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// same class hooks — now rendered on the server for SEO instead of in the
44
// browser. Interactivity (hire-us form, CoinPay button, section scroll) lives in
55
// the `home-interactivity` client component.
6+
import { renderInstallCommand } from "./install-command";
67

78
const primitives = [
89
{ name: "Identity", detail: "DIDs, OAuth accounts, profiles, and organization membership." },
@@ -119,6 +120,7 @@ export function renderPageMarkup(): string {
119120
<small>Open coordination standards</small>
120121
</div>
121122
</div>
123+
${renderInstallCommand("rail")}
122124
<nav aria-label="LogicSRC sections">
123125
<a class="active" href="#overview">Overview</a>
124126
<a href="#schemas">Schemas</a>
@@ -146,6 +148,7 @@ export function renderPageMarkup(): string {
146148
<p class="eyebrow">Profullstack open spec project</p>
147149
<h1>LogicSRC</h1>
148150
<p class="lede">Open schemas, primitives, and conventions for coordination between humans, AI agents, plugins, payment systems, and hosted products.</p>
151+
${renderInstallCommand("hero")}
149152
<div class="hero-actions">
150153
<a class="button-primary" href="/api/oauth/coinpay/start">Connect CoinPay</a>
151154
</div>

0 commit comments

Comments
 (0)