mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
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>
This commit is contained in:
parent
d28f20b9f4
commit
f41abbd712
7 changed files with 349 additions and 0 deletions
84
apps/logicsrc-web/contract/install-command.contract.test.ts
Normal file
84
apps/logicsrc-web/contract/install-command.contract.test.ts
Normal file
|
|
@ -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[^>]*>([^<]+)<\/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("<nav"));
|
||||
});
|
||||
|
||||
it("renders the command twice and no more", () => {
|
||||
expect(home.split(INSTALL_COMMAND).length - 1).toBe(4); // 2 placements x (code + data-copy)
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */}
|
||||
<CopyButtons />
|
||||
<Script
|
||||
data-site="56a0c760-e6cb-4875-844e-8b8aaa80b59b"
|
||||
src="https://crawlproof.com/stats.js"
|
||||
|
|
|
|||
71
apps/logicsrc-web/src/components/copy-buttons.tsx
Normal file
71
apps/logicsrc-web/src/components/copy-buttons.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
// One delegated listener for every `[data-copy]` button on the page.
|
||||
//
|
||||
// Delegation rather than a handler per button because the markup these target
|
||||
// is server-rendered in two different ways -- the homepage arrives as an HTML
|
||||
// string through dangerouslySetInnerHTML, the rest as JSX -- and a listener on
|
||||
// `document` does not care which. It also means a new copy button anywhere on
|
||||
// the site needs no wiring, just the attribute.
|
||||
export function CopyButtons(): null {
|
||||
useEffect(() => {
|
||||
const flash = (button: HTMLButtonElement, message: string): void => {
|
||||
const original = button.dataset.copyLabel ?? button.textContent ?? "Copy";
|
||||
button.dataset.copyLabel = original;
|
||||
button.textContent = message;
|
||||
button.classList.add("is-copied");
|
||||
window.setTimeout(() => {
|
||||
button.textContent = original;
|
||||
button.classList.remove("is-copied");
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
// navigator.clipboard is undefined outside a secure context, which includes
|
||||
// plain-http previews and older Safari. Falling back to a throwaway
|
||||
// textarea keeps the button honest there instead of silently doing nothing.
|
||||
const legacyCopy = (text: string): boolean => {
|
||||
const field = document.createElement("textarea");
|
||||
field.value = text;
|
||||
field.setAttribute("readonly", "");
|
||||
field.style.position = "fixed";
|
||||
field.style.opacity = "0";
|
||||
document.body.appendChild(field);
|
||||
field.select();
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
field.remove();
|
||||
return copied;
|
||||
};
|
||||
|
||||
const onClick = async (event: MouseEvent): Promise<void> => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const button = target?.closest<HTMLButtonElement>("button[data-copy]");
|
||||
if (!button) return;
|
||||
|
||||
const text = button.dataset.copy ?? "";
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
flash(button, "Copied");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// permission denied or a non-secure context -- fall through
|
||||
}
|
||||
flash(button, legacyCopy(text) ? "Copied" : "Press Ctrl+C");
|
||||
};
|
||||
|
||||
document.addEventListener("click", onClick);
|
||||
return () => document.removeEventListener("click", onClick);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { renderInstallCommand } from "@/lib/install-command";
|
||||
|
||||
// Mirrors the rail/nav from page-markup.ts so standalone routes (e.g. /blog)
|
||||
// share the site chrome. Anchor links point at the homepage sections.
|
||||
|
|
@ -40,6 +41,9 @@ export function SiteShell({
|
|||
<small>Open coordination standards</small>
|
||||
</div>
|
||||
</a>
|
||||
{/* Same markup the homepage uses, so the two can never drift apart.
|
||||
Static content from a module constant -- nothing user-supplied. */}
|
||||
<div dangerouslySetInnerHTML={{ __html: renderInstallCommand("rail") }} />
|
||||
<nav aria-label="LogicSRC sections">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
|
|
|
|||
53
apps/logicsrc-web/src/lib/install-command.ts
Normal file
53
apps/logicsrc-web/src/lib/install-command.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// The one-line CLI install, rendered as HTML so the string-built homepage
|
||||
// (page-markup.ts) and the React chrome (SiteShell) can share one definition.
|
||||
// Two renderings of the same command is how a shipped hint ends up disagreeing
|
||||
// with itself, so there is deliberately only one here.
|
||||
|
||||
/**
|
||||
* The command, verbatim.
|
||||
*
|
||||
* The flags are not decoration. Without `-f`, curl prints an HTTP error body
|
||||
* and exits 0, so a 404 gets piped into `sh`; without `-L` the install breaks
|
||||
* the moment the URL redirects. `-sS` keeps the progress meter out of the pipe
|
||||
* while leaving real errors visible. This is the form install.sh documents in
|
||||
* its own header.
|
||||
*/
|
||||
export const INSTALL_COMMAND = "curl -fsSL https://logicsrc.com/install.sh | sh";
|
||||
|
||||
/** Where the script itself lives, for people who read before they pipe. */
|
||||
export const INSTALL_SCRIPT_PATH = "/install.sh";
|
||||
|
||||
/**
|
||||
* A copy button. The command is static and contains no markup-significant
|
||||
* characters, so it goes into the attribute as-is; `copy-buttons.tsx` reads it
|
||||
* back out. Keeping the text on the button means the clipboard can never
|
||||
* disagree with what is on screen.
|
||||
*/
|
||||
function copyButton(className: string): string {
|
||||
return `<button type="button" class="${className}" data-copy="${INSTALL_COMMAND}" aria-label="Copy the install command">Copy</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param variant - `hero` is the homepage's unmissable version; `rail` is the
|
||||
* compact one that rides along in the site chrome on every other page.
|
||||
*/
|
||||
export function renderInstallCommand(variant: "hero" | "rail"): string {
|
||||
if (variant === "rail") {
|
||||
return `<div class="install-rail">
|
||||
<span class="install-rail-label">Install the CLI</span>
|
||||
<div class="install-rail-row">
|
||||
<code>${INSTALL_COMMAND}</code>
|
||||
${copyButton("install-copy install-copy-sm")}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="install-cta">
|
||||
<p class="install-cta-label">Get the CLI</p>
|
||||
<div class="install-cta-row">
|
||||
<code class="install-cta-cmd">${INSTALL_COMMAND}</code>
|
||||
${copyButton("install-copy")}
|
||||
</div>
|
||||
<p class="install-cta-note">macOS and Linux · needs Node 18+ · <a href="${INSTALL_SCRIPT_PATH}">read the script first</a></p>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
// same class hooks — now rendered on the server for SEO instead of in the
|
||||
// browser. Interactivity (hire-us form, CoinPay button, section scroll) lives in
|
||||
// the `home-interactivity` client component.
|
||||
import { renderInstallCommand } from "./install-command";
|
||||
|
||||
const primitives = [
|
||||
{ name: "Identity", detail: "DIDs, OAuth accounts, profiles, and organization membership." },
|
||||
|
|
@ -119,6 +120,7 @@ export function renderPageMarkup(): string {
|
|||
<small>Open coordination standards</small>
|
||||
</div>
|
||||
</div>
|
||||
${renderInstallCommand("rail")}
|
||||
<nav aria-label="LogicSRC sections">
|
||||
<a class="active" href="#overview">Overview</a>
|
||||
<a href="#schemas">Schemas</a>
|
||||
|
|
@ -146,6 +148,7 @@ export function renderPageMarkup(): string {
|
|||
<p class="eyebrow">Profullstack open spec project</p>
|
||||
<h1>LogicSRC</h1>
|
||||
<p class="lede">Open schemas, primitives, and conventions for coordination between humans, AI agents, plugins, payment systems, and hosted products.</p>
|
||||
${renderInstallCommand("hero")}
|
||||
<div class="hero-actions">
|
||||
<a class="button-primary" href="/api/oauth/coinpay/start">Connect CoinPay</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -761,3 +761,134 @@ pre {
|
|||
padding-left: 1rem;
|
||||
color: #5b6b7a;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- install command ----- */
|
||||
/* Two placements of one command: `.install-cta` on the homepage hero, meant to
|
||||
be the first thing anyone sees, and `.install-rail` riding in the dark chrome
|
||||
on every page -- present, but never competing with the nav. */
|
||||
|
||||
.install-copy {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
background: #5ac8a6;
|
||||
color: #101418;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.install-copy:hover {
|
||||
background: #7ad9bd;
|
||||
}
|
||||
|
||||
/* Confirmation has to survive the button's own text changing width, so the
|
||||
label is centred in a fixed box rather than reflowing the row. */
|
||||
.install-copy.is-copied {
|
||||
background: #2d6f60;
|
||||
color: #f6f7f4;
|
||||
}
|
||||
|
||||
.install-copy-sm {
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* ---- homepage hero ---- */
|
||||
|
||||
.install-cta {
|
||||
margin: 1rem 0 0.25rem;
|
||||
padding: 0.9rem 1rem 0.8rem;
|
||||
border: 2px solid #5ac8a6;
|
||||
border-radius: 10px;
|
||||
background: #101418;
|
||||
color: #f6f7f4;
|
||||
}
|
||||
|
||||
.install-cta-label {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #5ac8a6;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.install-cta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* The command is the point of the box, so it gets the room: it scrolls on its
|
||||
own rather than wrapping mid-flag or pushing the Copy button off the edge. */
|
||||
.install-cta-cmd {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
background: #1b2229;
|
||||
color: #f6f7f4;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install-cta-note {
|
||||
margin: 0.6rem 0 0;
|
||||
color: #b5beb2;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.install-cta-note a {
|
||||
color: #5ac8a6;
|
||||
}
|
||||
|
||||
/* ---- site chrome ---- */
|
||||
|
||||
.install-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid #3f5049;
|
||||
border-radius: 8px;
|
||||
background: #171d24;
|
||||
}
|
||||
|
||||
.install-rail-label {
|
||||
color: #8fa39a;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.install-rail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.install-rail-row code {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
color: #d7ded8;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.66rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
/* Stacked layout: the row would otherwise squeeze the command to nothing. */
|
||||
.install-cta-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.install-cta-cmd {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue