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
89 changes: 48 additions & 41 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -1,65 +1,72 @@
# Product

<!-- Synthesized 2026-07-13 from AGENTS.md, .context/2026-07-13-uploads-brand-distinctiveness-handoff.md,
and project history during an autonomous /impeccable audit run. Review and correct — especially
Brand Personality and Anti-references, which drive design decisions. -->

## Register

product

<!-- Split surface: apps/web/src/pages/index.astro (+ docs, github-screenshots) is brand register;
console/account/admin/login and packages/ui are product register. Product is the default. -->
<!-- Split surface: the landing page, docs, and guides are brand register;
console/account/admin and packages/ui are product register. Product is the default. -->

## Users

Developers and their coding agents. Primary user today: a single internal user (Zach) and
the agents he runs. The core workflow is CLI/agent-first: `uploads put shot.png --pr 123`
from a terminal or an agent skill, then the hosted URL lands in a GitHub PR or issue.
The web surfaces are secondary: a landing page that explains the product, and authenticated
console/account/admin pages for managing workspaces, keys, files, and galleries.
Developers and the coding agents that work alongside them. The core workflow is
CLI- and agent-first: one command takes a local file — a PR screenshot, an agent
artifact, a quick share — and returns a stable, embeddable URL, usually landing in
a GitHub PR or issue. Agents are first-class users, not an afterthought: they reach
the product through the CLI, the agent skill, and the MCP server, and they can't
drag-and-drop into GitHub the way a human can.

The web surfaces serve two moments: deciding (a landing page and docs that explain
the product) and managing (signed-in console, account, and admin pages for
workspaces, keys, files, and galleries).

## Product Purpose

uploads.sh is purpose-built file hosting for putting screenshots into PRs, issues, and
agent workflows — not generic file hosting. It exists so agents (which can't drag-and-drop
into GitHub) and developers get a one-command path from local file to embeddable URL.
Success: the CLI/MCP is the product; the web app makes what you've hosted legible and
manageable; the brand makes a tiny infrastructure tool feel deliberately crafted.
uploads.sh is purpose-built file hosting for development workflows — not generic
"store anything" file hosting. It exists so that getting a file from a local machine
into a PR, issue, or agent pipeline is one command instead of a drag-and-drop a
robot can't perform. It is open source and runs as a hosted service; workspaces
keep teams' files, limits, and tokens isolated from each other.

Success looks like: the tool a developer installs once and stops thinking about,
and the default way coding agents attach visual evidence to their work.

## Brand Personality

Terminal-native, precise, quietly playful. A developer's tool that takes craft seriously —
mono-leaning typography, dark by construction, with one genuinely distinctive asset:
Geist Pixel and its variable ELSH (element-shape) axis, which should read as the brand's
signature rather than a wordmark-only garnish. Personality shows up in small, exact
moments (pixel morphs, the chevron/upload motif), never in loudness.
Terminal-native, precise, quietly playful. A developer's tool that takes craft
seriously — mono-first typography, dark by construction, with one genuinely
distinctive asset: Geist Pixel and its variable ELSH (element-shape) axis, used as
the brand's signature rather than a wordmark-only garnish. Personality shows up in
small, exact moments (pixel morphs, the chevron/upload motif), never in loudness.

## Anti-references

- The generic "dark + purple dev tool" (Linear/Vercel-adjacent): #0a0a0b + violet accent
- Geist-for-everything is the saturated default this brand must escape.
- SaaS landing-page grammar: hero-metric blocks, identical icon-card grids, tiny uppercase
tracked eyebrows above every section, gradient text, glassmorphism.
- Generic file-hosting framing (Dropbox-alike "store anything" pitches) — the pitch is
purpose-first: PR screenshots and agent workflows.
- The generic "dark + purple dev tool" (Linear/Vercel-adjacent) rendered entirely
in stock defaults. The palette family is allowed; the tell is default-ness —
distinctiveness must come from owned assets and usage, not template chrome.
- SaaS landing-page grammar: hero-metric blocks, identical icon-card grids, tiny
uppercase tracked eyebrows above every section, gradient text, glassmorphism.
- Generic file-hosting framing (Dropbox-alike "store your files" pitches). The
pitch is purpose-first: screenshots on PRs, artifacts from agents.

## Design Principles

1. **The CLI is the hero.** Web surfaces demonstrate and manage what the CLI does; show
real commands, real output, real hosted files — not marketing abstractions.
2. **One brand, two registers.** The design system (`@uploads/ui`) is the single source of
truth; the live site and the DS must stay visually identical.
3. **Signature over decoration.** Distinctiveness comes from owned assets (Geist Pixel's
ELSH axis, the chevron/upload motif) used systematically — not from added ornament.
4. **Deliberate, not default.** Every value that survives an audit is either evolved or
explicitly kept with a recorded reason (impeccable ignore + rationale). No silent defaults.
5. **Agent-legible craft.** Pages must work without JS where possible, degrade cleanly,
and stay fast on Workers — the audience includes headless agents and curl.
1. **The CLI is the hero.** Web surfaces demonstrate and manage what the CLI does;
show real commands, real output, real hosted files — not marketing abstractions.
2. **One brand, one source of truth.** The design system (`@uploads/ui`) defines
the visual language; the site consumes it. The two never drift.
3. **Signature over decoration.** Distinctiveness comes from owned assets (Geist
Pixel's ELSH axis, the pixel chevron mark) used systematically — not added ornament.
4. **Deliberate, not default.** Every visual value is either chosen or explicitly
kept with a recorded reason. No silent defaults.
5. **Agent-legible craft.** Pages work without JavaScript where possible, degrade
cleanly, and stay fast at the edge — the audience includes headless agents and
curl, not just browsers.

## Accessibility & Inclusion

WCAG 2.1 AA. Dark-only palette must hold ≥4.5:1 body contrast (muted metadata text is the
known risk). Full keyboard operability on console/account/admin (file browser, forms).
`prefers-reduced-motion` alternatives for any pixel-morph/dissolve motion the brand work
adds. Touch targets ≥44px on mobile for the management surfaces.
WCAG 2.1 AA. The dark-only palette must hold ≥4.5:1 contrast for all text,
including muted metadata at small sizes. Full keyboard operability on the signed-in
surfaces (file browser, forms, navigation). Every animation ships a
`prefers-reduced-motion` alternative. Touch targets sized for mobile use on the
management surfaces.
2 changes: 1 addition & 1 deletion apps/web/src/components/BaseHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface Props {
const { preloadSans = false } = Astro.props;

const FAVICON =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23121214'/%3E%3Cg fill='none' stroke='%23c27eff' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 12.5 L16 5 L24 12.5'/%3E%3Cpath d='M8 19.5 L16 12 L24 19.5' opacity='.55'/%3E%3Cpath d='M8 26.5 L16 19 L24 26.5' opacity='.28'/%3E%3C/g%3E%3C/svg%3E";
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' shape-rendering='crispEdges'%3E%3Cpath d='M4 0H28V4H32V28H28V32H4V28H0V4H4Z' fill='%23121214'%3E%3C/path%3E%3Cg fill='%23c27eff'%3E%3Cpath d='M14 4h4v4h-4z M10 6h4v4h-4z M18 6h4v4h-4z M6 8h4v4h-4z M22 8h4v4h-4z'%3E%3C/path%3E%3Cpath opacity='.55' d='M14 12h4v4h-4z M10 14h4v4h-4z M18 14h4v4h-4z M6 16h4v4h-4z M22 16h4v4h-4z'%3E%3C/path%3E%3Cpath opacity='.28' d='M14 20h4v4h-4z M10 22h4v4h-4z M18 22h4v4h-4z M6 24h4v4h-4z M22 24h4v4h-4z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E";
---

<meta charset="utf-8" />
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/Brand.astro
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ const attrs =
---

<Tag {...attrs}>
<svg class="ul-brand__mark" viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="var(--panel, #121214)"></rect>
<g fill="none" stroke="var(--accent, #c27eff)" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 12.5 L16 5 L24 12.5"></path>
<path d="M8 19.5 L16 12 L24 19.5" opacity=".55"></path>
<path d="M8 26.5 L16 19 L24 26.5" opacity=".28"></path>
<svg class="ul-brand__mark" viewBox="0 0 32 32" shape-rendering="crispEdges" aria-hidden="true">
<path d="M4 0H28V4H32V28H28V32H4V28H0V4H4Z" fill="var(--panel, #121214)"></path>
<g fill="var(--accent, #c27eff)">
<path d="M14 4h4v4h-4z M10 6h4v4h-4z M18 6h4v4h-4z M6 8h4v4h-4z M22 8h4v4h-4z"></path>
<path opacity=".55" d="M14 12h4v4h-4z M10 14h4v4h-4z M18 14h4v4h-4z M6 16h4v4h-4z M22 16h4v4h-4z"></path>
<path opacity=".28" d="M14 20h4v4h-4z M10 22h4v4h-4z M18 22h4v4h-4z M6 24h4v4h-4z M22 24h4v4h-4z"></path>
</g>
</svg>
<span>{label}</span>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const REPO = "https://github.com/buildinternet/uploads";
---

<footer class:list={["site-footer", { compact }]}>
uploads.sh · a <a href="https://buildinternet.com">Build Internet</a> project · <a href={REPO}>source</a> · <a href="/docs">docs</a> · <a href="/terms">terms</a> · <a href="/privacy">privacy</a>
{compact ? null : <Fragment>uploads.sh · </Fragment>}a <a href="https://buildinternet.com">Build Internet</a> project · <a href={REPO}>source</a> · <a href="/docs">docs</a> · <a href="/terms">terms</a> · <a href="/privacy">privacy</a>
</footer>

<style>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/pages/console.astro
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
display: grid;
gap: 6px;
color: var(--muted);
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
Expand Down Expand Up @@ -88,7 +88,7 @@
white-space: pre-wrap;
word-break: break-word;
color: var(--body);
font-size: 12.5px;
font-size: 12px;
}
a {
color: var(--fg);
Expand Down Expand Up @@ -128,7 +128,7 @@
.session-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.lockup { display: inline-flex; align-items: center; gap: 9px; }
.pill {
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
Expand All @@ -137,7 +137,7 @@
padding: 2px 9px;
background: var(--bg);
}
.session-bar a { font-size: 12.5px; }
.session-bar a { font-size: 12px; }
.session-bar button {
font: 12px var(--mono);
color: var(--accent);
Expand Down Expand Up @@ -271,15 +271,15 @@
out.textContent = String(e);
}
}
el<HTMLButtonElement>("btn-usage").onclick = () => call("/usage");

Check warning on line 274 in apps/web/src/pages/console.astro

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-add-event-listener)

Prefer `addEventListener()` over their `on`-function counterparts.
el<HTMLButtonElement>("btn-list").onclick = () => call("/files?limit=50");

Check warning on line 275 in apps/web/src/pages/console.astro

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-add-event-listener)

Prefer `addEventListener()` over their `on`-function counterparts.
el<HTMLButtonElement>("btn-reconcile").onclick = () => call("/usage/reconcile", "POST");

Check warning on line 276 in apps/web/src/pages/console.astro

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-add-event-listener)

Prefer `addEventListener()` over their `on`-function counterparts.

// Invite creation hits the admin API (POST /admin/enrollments) with the token
// field as the admin bearer token. The one-time code is shown once, here only.
const copyBtn = el<HTMLButtonElement>("btn-invite-copy");
let inviteLink = "";
el<HTMLButtonElement>("btn-invite").onclick = async () => {

Check warning on line 282 in apps/web/src/pages/console.astro

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-add-event-listener)

Prefer `addEventListener()` over their `on`-function counterparts.
const { api, ws, token } = cfg();
if (!token) {
out.textContent = "Paste the admin token into the token field first.";
Expand Down Expand Up @@ -322,7 +322,7 @@
out.textContent = String(e);
}
};
copyBtn.onclick = async () => {

Check warning on line 325 in apps/web/src/pages/console.astro

View workflow job for this annotation

GitHub Actions / Lint & Format

unicorn(prefer-add-event-listener)

Prefer `addEventListener()` over their `on`-function counterparts.
await navigator.clipboard.writeText(inviteLink);
copyBtn.textContent = "copied ✓";
setTimeout(() => (copyBtn.textContent = "Copy invite link"), 1500);
Expand Down
35 changes: 22 additions & 13 deletions apps/web/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ const authOrigin = import.meta.env.PUBLIC_UPLOADS_AUTH_ORIGIN ?? "https://auth.u
/* ---------- install panel ---------- */
.install { font-size: 13px; margin-top: 6px; }
.install .rows { display: grid; gap: 8px; }
.install .sub {
margin-left: 14px;
padding-left: 16px;
border-left: 1px solid var(--line);
display: grid;
gap: 8px;
}
.install .sub-lead { color: var(--muted); font-size: 12px; margin: 2px 0 0; }
.cmdrow {
display: grid;
grid-template-columns: 74px 1fr auto;
Expand Down Expand Up @@ -519,7 +527,7 @@ const authOrigin = import.meta.env.PUBLIC_UPLOADS_AUTH_ORIGIN ?? "https://auth.u
</p>

<section class="install" aria-label="Install commands">
<h2 class="sect-head"># get set up — click to copy</h2>
<h2 class="sect-head"># get set up</h2>
<div class="rows">
<div class="cmdrow">
<span class="tag">cli</span>
Expand All @@ -531,20 +539,21 @@ const authOrigin = import.meta.env.PUBLIC_UPLOADS_AUTH_ORIGIN ?? "https://auth.u
<span class="cmdtext">uploads install</span>
<button type="button" data-copy="uploads install" aria-live="polite">copy</button>
</div>
<div class="cmdrow">
<span class="tag">skill</span>
<span class="cmdtext">npx skills add buildinternet/uploads --skill uploads-cli</span>
<button type="button" data-copy="npx skills add buildinternet/uploads --skill uploads-cli" aria-live="polite">copy</button>
</div>
<div class="cmdrow">
<span class="tag">mcp</span>
<span class="cmdtext">claude mcp add --transport http uploads <span class="url">https://agents.uploads.sh/mcp</span></span>
<button type="button" data-copy="claude mcp add --transport http uploads https://agents.uploads.sh/mcp" aria-live="polite">copy</button>
<div class="sub" aria-label="What uploads install sets up, also available individually">
<p class="sub-lead">runs both of these — grab one on its own if you'd rather:</p>
<div class="cmdrow">
<span class="tag">skill</span>
<span class="cmdtext">npx skills add buildinternet/uploads</span>
<button type="button" data-copy="npx skills add buildinternet/uploads" aria-live="polite">copy</button>
</div>
<div class="cmdrow">
<span class="tag">mcp</span>
<span class="cmdtext">claude mcp add --transport http uploads <span class="url">https://agents.uploads.sh/mcp</span></span>
<button type="button" data-copy="claude mcp add --transport http uploads https://agents.uploads.sh/mcp" aria-live="polite">copy</button>
</div>
</div>
</div>
<p class="foot">
access by invitation · `uploads install` covers the skill + mcp rows · <a href="/docs">docs</a> · <a href={REPO}>source &amp; roadmap</a>
</p>
<p class="foot">access by invitation</p>
</section>

<Footer />
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/invite.astro
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const csp = [
<meta http-equiv="Content-Security-Policy" content={csp} />
<title>Accept your invite · uploads.sh</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:28px;background:var(--bg);color:var(--fg);font:14.5px/1.65 ui-monospace,"SFMono-Regular",Consolas,monospace}main{width:min(640px,100%);background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);padding:clamp(24px,6vw,44px)}.brandbar{display:flex;justify-content:center;margin-bottom:24px}h1{margin:0 0 8px;font-size:clamp(23px,5vw,30px);font-weight:600;letter-spacing:-.015em;line-height:1.25}p{color:var(--body)}.status{padding:12px 14px;margin:24px 0;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--bg);color:var(--body)}.status[data-state=ready]{color:var(--green)}.status[data-state=closed]{color:var(--red)}ol{padding-left:20px}li{margin:16px 0}.command{display:flex;gap:12px;align-items:center;margin-top:8px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--bg)}.command code{flex:1;min-width:0;white-space:nowrap;overflow-x:auto;display:block;font-variant-ligatures:none;font-feature-settings:"calt" 0,"liga" 0}.command button{flex-shrink:0}button{font-size:11px;letter-spacing:.06em;border:1px solid var(--line);border-radius:6px;padding:6px 10px;background:none;color:var(--accent);cursor:pointer;font-family:inherit}button:hover,button:focus-visible{border-color:var(--accent);outline:none}.security{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}[hidden]{display:none!important}
*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:28px;background:var(--bg);color:var(--fg);font:14.5px/1.65 var(--mono)}main{width:min(640px,100%);background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);padding:clamp(24px,6vw,44px)}.brandbar{display:flex;justify-content:center;margin-bottom:24px}h1{margin:0 0 8px;font-size:clamp(23px,5vw,30px);font-weight:600;letter-spacing:-.015em;line-height:1.25}p{color:var(--body)}.status{padding:12px 14px;margin:24px 0;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--bg);color:var(--body)}.status[data-state=ready]{color:var(--green)}.status[data-state=closed]{color:var(--red)}ol{padding-left:20px}li{margin:16px 0}.command{display:flex;gap:12px;align-items:center;margin-top:8px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--bg)}.command code{flex:1;min-width:0;white-space:nowrap;overflow-x:auto;display:block;font-variant-ligatures:none;font-feature-settings:"calt" 0,"liga" 0}.command button{flex-shrink:0}button{font-size:11px;letter-spacing:.06em;border:1px solid var(--line);border-radius:6px;padding:6px 10px;background:none;color:var(--accent);cursor:pointer;font-family:inherit}button:hover,button:focus-visible{border-color:var(--accent);outline:none}.security{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}[hidden]{display:none!important}
</style>
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/login.astro
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const csp = [
<body>
<main>
<div class="brandbar"><Brand /></div>
<h1>Sign in to uploads.sh</h1>
<h1>Sign in</h1>
<p>Manage workspaces, invites, and access.</p>

<div id="sent-state" hidden>
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/styles/account-content.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
}
.kv dt {
color: var(--muted);
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
align-self: center;
Expand Down Expand Up @@ -55,7 +55,7 @@ ul.plain .ws-detail {
border-top: 1px solid var(--line);
}
ul.plain .ws-section-head {
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
Expand All @@ -74,7 +74,7 @@ ul.plain .ws-items li {
border: 0;
border-radius: 0;
background: none;
font-size: 12.5px;
font-size: 12px;
}
ul.plain .ws-empty {
color: var(--muted);
Expand All @@ -83,7 +83,7 @@ ul.plain .ws-empty {
}
ul.plain .ws-note {
color: var(--body);
font-size: 12.5px;
font-size: 12px;
margin: 0;
}
ul.plain .command {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/styles/admin-workspaces.css
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
gap: 6px;
}
#workspaces-list .workspace li {
font-size: 12.5px;
font-size: 12px;
color: var(--body);
display: flex;
justify-content: space-between;
Expand All @@ -58,7 +58,7 @@
#workspaces-list .workspace .members,
#workspaces-list .workspace .muted {
color: var(--muted);
font-size: 12.5px;
font-size: 12px;
}
#workspaces-list .invite-form {
display: flex;
Expand All @@ -70,7 +70,7 @@
display: grid;
gap: 4px;
color: var(--muted);
font-size: 10px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/styles/signed-in-shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ header.top .lockup {
}
header.top .who {
color: var(--muted);
font-size: 12.5px;
font-size: 12px;
}
/* Status blocks render as .ul-callout (DS); .status is the JS hook only. */
.sr-only {
Expand Down Expand Up @@ -168,7 +168,7 @@ span.muted {
}
.pill {
display: inline-block;
font-size: 10.5px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
Expand Down
Loading
Loading