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

## 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.

## 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.

## 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.

## 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.

## 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.

## 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.
2 changes: 0 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@
"@astrojs/cloudflare": "^14.1.2",
"@astrojs/react": "^6.0.1",
"@uploads/ui": "workspace:*",
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.8",
"astro": "^7.0.6",
"files-sdk": "^2.1.0",
"markdown-for-agents": "^1.3.4",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AuthIndicator.astro
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const { authOrigin } = Astro.props;
}
.auth-indicator :global(a:hover),
.auth-indicator :global(a:focus-visible) {
color: var(--accent, #b794ff);
color: var(--accent, #c27eff);
outline: none;
}
</style>
34 changes: 34 additions & 0 deletions apps/web/src/components/BaseHead.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
/**
* Shared <head> baseline for every page: charset, viewport, the chevron favicon,
* the design-system stylesheet (@uploads/ui: tokens, @font-face, ul-* classes),
* and a preload for the brand mono face (the one family every surface uses).
*
* Pages own everything that varies (<title>, CSP meta, robots/referrer, OG tags)
* and render <BaseHead /> first inside <head>. Importing the DS stylesheet here is
* the single font/token pipeline: no page should declare its own :root tokens or
* @font-face.
*
* CSP note: pages shipping a `default-src 'none'` policy need `font-src 'self'`
* (self-hosted woff2) and `style-src` including `'self'` (Astro-extracted CSS).
*/
import "@uploads/ui/styles.css";
import geistMonoUrl from "@uploads/ui/fonts/geist-mono.woff2?url";
import geistSansUrl from "@uploads/ui/fonts/geist.woff2?url";

interface Props {
/** Also preload the sans face (marketing/prose-heavy pages). */
preloadSans?: boolean;
}

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";
---

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href={FAVICON} />
<link rel="preload" as="font" type="font/woff2" crossorigin href={geistMonoUrl} />
{preloadSans && <link rel="preload" as="font" type="font/woff2" crossorigin href={geistSansUrl} />}
76 changes: 30 additions & 46 deletions apps/web/src/components/Brand.astro
Original file line number Diff line number Diff line change
@@ -1,60 +1,44 @@
---
/**
* Canonical uploads.sh brand lockup: the stacked-chevron mark + the "uploads.sh"
* wordmark set in Geist Pixel, linking home. This is the single source of truth —
* every page's top-left brand should render <Brand />, not its own markup, so the
* homepage treatment stays identical everywhere.
* Astro mirror of the design system's Brand component (packages/ui/src/Brand.tsx).
* The DS is the source of truth; this file must stay markup-identical to Brand.tsx
* (same .ul-brand classes, same SVG) so the lockup renders the same everywhere
* without hydrating React for a static element. If Brand.tsx changes, change this.
*
* Importing the pixel face here means any page that drops in <Brand /> gets the
* font wired up with no extra import. Colors read the page's --fg / --accent
* tokens (with the homepage literals as fallbacks) so the mark fits each surface.
* Styling comes from @uploads/ui/styles.css (.ul-brand), imported here so any page
* that drops in <Brand /> gets the tokens, classes, and Geist Pixel face wired up.
*
* CSP note: pages under a `default-src 'none'` policy must add `font-src 'self'`
* so the self-hosted woff2 can load.
*/
import "../styles/geist-pixel.css";
import "@uploads/ui/styles.css";

interface Props {
/** Link target for the lockup. Defaults to `/`. Pass `null` to render a non-link. */
href?: string | null;
/** `"lg"` renders the larger homepage-hero treatment. */
size?: "md" | "lg";
/** Wordmark text. Defaults to the canonical `uploads.sh`. */
label?: string;
}

const { href = "/", size = "md", label = "uploads.sh" } = Astro.props;
const cls = ["ul-brand", size === "lg" && "ul-brand--lg"].filter(Boolean).join(" ");
const Tag = href == null ? "span" : "a";
const attrs =
href == null
? { class: cls, "aria-label": `${label} brand` }
: { class: cls, href, "aria-label": `${label} home` };
---

<a class="brand" href="/" aria-label="uploads.sh home">
<svg viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="#121214"></rect>
<g fill="none" stroke="var(--accent, #b794ff)" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
<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>
</g>
</svg>
<span>uploads.sh</span>
</a>

<style>
.brand {
display: inline-flex;
align-items: center;
gap: 9px;
color: var(--fg, #ececea);
text-decoration: none;
font-family: "Geist Pixel", "Geist Mono Variable", ui-monospace, monospace;
font-variation-settings: "ELSH" var(--pixel-shape, 1);
font-size: 15px;
letter-spacing: 0.04em;
line-height: 1;
white-space: nowrap;
}
.brand svg {
width: 20px;
height: 20px;
border-radius: 5px;
flex: none;
}
.brand:hover,
.brand:focus-visible {
color: var(--fg, #ececea);
outline: none;
}
.brand:focus-visible {
outline: 2px solid var(--accent, #b794ff);
outline-offset: 3px;
border-radius: 3px;
}
</style>
<span>{label}</span>
</Tag>
2 changes: 1 addition & 1 deletion apps/web/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const REPO = "https://github.com/buildinternet/uploads";
}
.site-footer a:hover,
.site-footer a:focus-visible {
color: var(--accent, #b794ff);
color: var(--accent, #c27eff);
outline: none;
}
</style>
11 changes: 5 additions & 6 deletions apps/web/src/layouts/AccountLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
*/
import { env } from "cloudflare:workers";
import {
SITE_FAVICON,
resolveShowConsoleLinks,
resolveSignedInOrigins,
signedInCsp,
} from "../lib/signed-in-page";
import BaseHead from "../components/BaseHead.astro";
import Brand from "../components/Brand.astro";
import "../styles/signed-in-shell.css";
import "../styles/account-content.css";
Expand Down Expand Up @@ -44,20 +44,19 @@ const nav: { href: string; section: AccountSection; label: string }[] = [

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<BaseHead />
<meta name="robots" content="noindex, nofollow, noarchive" />
<meta name="referrer" content="no-referrer" />
<meta http-equiv="Content-Security-Policy" content={csp} />
<title>{docTitle}</title>
<link rel="icon" href={SITE_FAVICON} />
</head>
<body>
<div class="shell">
<div id="checking" class="status" role="status">Checking your session…</div>
<h1 class="sr-only">{titles[section]}</h1>
<div id="checking" class="ul-callout status" role="status">Checking your session…</div>

<div id="signed-out" hidden>
<div class="status" data-state="denied" role="alert">You're not signed in.</div>
<div class="ul-callout status" data-state="error" role="alert">You're not signed in.</div>
<a class="button" href="/login">Sign in</a>
</div>

Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/layouts/AdminLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
*/
import { env } from "cloudflare:workers";
import {
SITE_FAVICON,
resolveShowConsoleLinks,
resolveSignedInOrigins,
signedInCsp,
} from "../lib/signed-in-page";
import BaseHead from "../components/BaseHead.astro";
import Brand from "../components/Brand.astro";
import "../styles/signed-in-shell.css";

Expand Down Expand Up @@ -40,20 +40,19 @@ const nav: { href: string; section: AdminSection; label: string }[] = [

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<BaseHead />
<meta name="robots" content="noindex, nofollow, noarchive" />
<meta name="referrer" content="no-referrer" />
<meta http-equiv="Content-Security-Policy" content={csp} />
<title>{docTitle}</title>
<link rel="icon" href={SITE_FAVICON} />
</head>
<body>
<div class="shell">
<div id="checking" class="status" role="status">Checking your session…</div>
<h1 class="sr-only">{titles[section]}</h1>
<div id="checking" class="ul-callout status" role="status">Checking your session…</div>

<div id="denied" hidden>
<div class="status" data-state="denied" role="alert">You need admin access to view this page.</div>
<div class="ul-callout status" data-state="error" role="alert">You need admin access to view this page.</div>
<a class="button" href="/login">Sign in</a>
</div>

Expand Down
27 changes: 4 additions & 23 deletions apps/web/src/layouts/ErrorLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
* Shared shell for Astro status pages (404 missing route, 500 application error).
* Same flat panel + titlebar treatment as the landing terminal.
*/
import "@fontsource-variable/geist/index.css";
import "@fontsource-variable/geist-mono/index.css";
import BaseHead from "../components/BaseHead.astro";
import Brand from "../components/Brand.astro";
import Footer from "../components/Footer.astro";

Expand All @@ -17,33 +16,15 @@ interface Props {
}

const { status, title, message, hint } = 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='%23b794ff' 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";
---

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<BaseHead />
<meta name="robots" content="noindex, nofollow, noarchive" />
<meta name="description" content={`${status} ${title} — uploads.sh`} />
<title>{status} · {title} · uploads.sh</title>
<link rel="icon" href={FAVICON} />
<style>
:root {
color-scheme: dark;
--bg: #0a0a0b;
--panel: #121214;
--line: #232327;
--fg: #ececea;
--body: #b3b3ad;
--muted: #7d7d77;
--accent: #b794ff;
--mono: "Geist Mono Variable", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
--sans: "Geist Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--pixel: "Geist Pixel", "Geist Mono Variable", ui-monospace, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
Expand All @@ -60,7 +41,7 @@ const FAVICON =
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
border-radius: var(--radius-lg);
overflow: hidden;
}
.titlebar {
Expand Down Expand Up @@ -118,7 +99,7 @@ const FAVICON =
color: var(--fg);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 7px;
border-radius: var(--radius-sm);
padding: 7px 12px;
text-decoration: none;
}
Expand Down
Loading
Loading