Add link previews and icons; make PIG_INVITE_CODE actually work

Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons,
generated from an HTML template by a script so the mark, wordmark and tagline
cannot drift from the product. The apple-touch-icon referenced in index.html
was a 404 until now. Icons are drawn on an opaque plate with inset because iOS
rounds corners and Android may apply a circle — an edge-to-edge mark loses its
ears to that crop. og:image is absolute, which is the single most common
reason a card unfurls blank.

PIG_INVITE_CODE was a lie. Signup validates against the invites table, so
setting the variable only flipped a label in the UI — an operator would set it,
hand the code to a colleague, and watch them be rejected. It is now reconciled
into a real invite row at boot: setting it issues, changing it rotates and
revokes the predecessor, and removing it revokes. Verified all three, plus that
a restart with an unchanged code does not duplicate.

Two bugs found while doing that:

- `uses_remaining` was jsonb, so the SQL decrement could never have worked.
  Now integer. The generated migration failed because Postgres has no implicit
  jsonb->integer cast, so the USING clause is hand-written.
- Redemption keyed off `redeemedAt`, which would have made every reusable
  invite single-use — a confusing way to lock a team out. Availability now
  comes from `usesRemaining`, and redemption records who used it most recently
  without consuming it.

Sign-in gains a password option alongside the magic link, defaulting to
password since that is the daily path. PIG stores neither; both are handled by
the identity provider and PIG only ever sees the resulting token.
autocomplete is set so password managers and iOS can fill.

Verified: full migration chain applies to a fresh Postgres, the CSP hash for
the inline theme script is unchanged by the rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:43:10 -07:00
parent 93818a2d2c
commit 0551e8dd6e
19 changed files with 6855 additions and 53 deletions
+41 -1
View File
@@ -22,10 +22,50 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pig" />
<!--
Icons. The SVG is preferred by every modern browser and adapts to the
system theme; the PNG is the fallback for those that do not. The
apple-touch-icon is drawn on an opaque plate with inset, because iOS
rounds the corners itself and a transparent edge-to-edge mark loses its
ears to the crop.
-->
<link rel="icon" href="/pig.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/pig-touch.png" />
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>pig — Prime Intellect Growth</title>
<link rel="canonical" href="https://primeintellectgrowth.com/" />
<!--
Link previews. og:image must be an ABSOLUTE url — unfurlers fetch it
without a page context, so a relative path silently yields no image,
which is the single most common reason a card renders blank.
summary_large_image gives the 1200×630 card rather than a small square
thumbnail. Both og: and twitter: tags are present because Slack, Discord,
LinkedIn and iMessage read Open Graph while X prefers its own.
-->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="pig" />
<meta property="og:url" content="https://primeintellectgrowth.com/" />
<meta property="og:title" content="pig — Prime Intellect Growth" />
<meta
property="og:description"
content="An open-source, agent-native CRM for companies that buy GPU capacity and sell it. Margin, utilisation and idle capacity from one ledger."
/>
<meta property="og:image" content="https://primeintellectgrowth.com/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="pig — Prime Intellect Growth. The CRM for companies that buy compute and sell it." />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="pig — Prime Intellect Growth" />
<meta
name="twitter:description"
content="An open-source, agent-native CRM for companies that buy GPU capacity and sell it. Margin, utilisation and idle capacity from one ledger."
/>
<meta name="twitter:image" content="https://primeintellectgrowth.com/og.png" />
<script>
// Applied before first paint so a dark-mode user never sees a white
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+6 -1
View File
@@ -3,10 +3,15 @@
"short_name": "pig",
"description": "An agent-native CRM for two-sided AI-compute companies.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{ "src": "/pig.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
{ "src": "/pig.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+141 -44
View File
@@ -1,18 +1,29 @@
/**
* Sign-in.
*
* Magic link only. PIG stores no passwords, and adding a password field would
* mean either storing one or pretending to — both worse than an email link for
* an internal tool used by a couple of dozen people.
* Two methods, because they suit different situations. A password is faster
* for someone who uses PIG daily and has it in a manager; a magic link needs
* no credential at all and is the better answer for someone signing in once
* from a phone. Neither is stored by PIG — both are handled entirely by the
* identity provider, and PIG only ever sees the resulting token.
*
* Password is the default tab because the alternative — defaulting to a link
* and making daily users switch every time — is the more annoying of the two
* mistakes.
*/
import { useState } from 'react';
import { KeyRound, Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Card, CardContent, Input } from '@/components/ui';
import { PiggyMark } from '@/components/PiggyMark';
type Method = 'password' | 'link';
export function SignIn({ config }: { config: PublicConfig }) {
const [method, setMethod] = useState<Method>('password');
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [password, setPassword] = useState('');
const [status, setStatus] = useState<'idle' | 'busy' | 'sent' | 'error'>('idle');
const [message, setMessage] = useState('');
async function submit(event: React.FormEvent) {
@@ -24,16 +35,31 @@ export function SignIn({ config }: { config: PublicConfig }) {
return;
}
setStatus('sending');
setStatus('busy');
setMessage('');
if (method === 'password') {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
setStatus('error');
// Supabase deliberately returns the same message for a wrong password
// and an unknown address, which is correct — distinguishing them tells
// an attacker which addresses are registered.
setMessage(error.message);
return;
}
// The auth state listener in App.tsx picks this up and re-fetches; no
// navigation is needed here.
setStatus('idle');
return;
}
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: window.location.origin },
});
if (error) {
setStatus('error');
// Supabase returns a clear message for rate limits and disabled signup,
// both of which the person can act on, so it is shown rather than hidden.
setMessage(error.message);
return;
}
@@ -54,45 +80,116 @@ export function SignIn({ config }: { config: PublicConfig }) {
<Card>
<CardContent className="pt-5">
{status === 'sent' ? (
<div className="space-y-2 text-center">
<p className="font-medium">Check your email</p>
<p className="text-sm text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
</p>
<div className="space-y-3 text-center">
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden />
<div>
<p className="font-medium">Check your email</p>
<p className="mt-1 text-sm text-muted">
A sign-in link is on its way to {email}. It expires shortly, so use it
soon.
</p>
</div>
<button
type="button"
onClick={() => setStatus('idle')}
className="tap text-sm font-medium text-accent-fg"
>
Use a different address
</button>
</div>
) : (
<form onSubmit={submit} className="space-y-3">
<label className="block">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
<Button
type="submit"
variant="primary"
className="w-full"
disabled={status === 'sending'}
<>
<div
role="tablist"
aria-label="Sign-in method"
className="mb-4 inline-flex w-full rounded-lg bg-surface-2 p-1"
>
{status === 'sending' ? 'Sending…' : 'Email me a sign-in link'}
</Button>
{status === 'error' ? (
<p className="text-sm text-danger">{message}</p>
) : null}
{config.inviteRequired ? (
<p className="text-center text-xs text-muted">
PIG is invite-only. An account alone does not grant access.
</p>
) : null}
</form>
{(
[
{ key: 'password', label: 'Password', icon: KeyRound },
{ key: 'link', label: 'Email link', icon: Mail },
] as const
).map((option) => (
<button
key={option.key}
type="button"
role="tab"
aria-selected={method === option.key}
onClick={() => {
setMethod(option.key);
setStatus('idle');
setMessage('');
}}
className={[
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors',
method === option.key ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
<option.icon className="h-4 w-4" aria-hidden />
{option.label}
</button>
))}
</div>
<form onSubmit={submit} className="space-y-3">
<label className="block">
<span className="mb-1 block text-sm font-medium">Email</span>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoComplete="username"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</label>
{method === 'password' ? (
<label className="block">
<span className="mb-1 block text-sm font-medium">Password</span>
<Input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
// `current-password` is what lets a password manager
// offer to fill, and iOS to offer a saved credential.
autoComplete="current-password"
/>
</label>
) : null}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={status === 'busy'}
>
{status === 'busy'
? method === 'password'
? 'Signing in…'
: 'Sending…'
: method === 'password'
? 'Sign in'
: 'Email me a sign-in link'}
</Button>
{status === 'error' ? (
<p className="text-sm text-danger" role="alert">
{message}
</p>
) : null}
{config.inviteRequired ? (
<p className="pt-1 text-center text-xs text-muted">
PIG is invite-only. An account alone does not grant access.
</p>
) : null}
</form>
</>
)}
</CardContent>
</Card>
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<!--
Source for the social preview card. Rendered to PNG by `tools/render-assets.mjs`.
Kept as HTML rather than hand-drawn in a design tool so the wordmark, the
tagline and the mark stay in sync with the product when any of them changes —
and so regenerating is one command rather than an afternoon.
1200×630 is the size Twitter/X and most link unfurlers crop to. Anything
important is kept well inside the edges, because several clients crop to
roughly 1.91:1 and Slack shows a smaller centred slice.
-->
<html>
<head>
<meta charset="utf-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 88px;
background: #ffffff;
color: #09090b;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Inter, sans-serif;
position: relative;
overflow: hidden;
}
/* A faint grid, suggesting racked capacity without being literal about it. */
.grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(9, 9, 11, 0.045) 1px, transparent 1px),
linear-gradient(to bottom, rgba(9, 9, 11, 0.045) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 80% 70% at 70% 40%, #000 20%, transparent 75%);
}
.row { display: flex; align-items: center; gap: 22px; position: relative; }
.mark { width: 92px; height: 92px; flex: none; }
.wordmark {
font-size: 84px;
font-weight: 650;
letter-spacing: -0.045em;
line-height: 1;
}
.expansion {
margin-top: 30px;
font-size: 27px;
font-weight: 500;
color: #52525b;
letter-spacing: -0.01em;
position: relative;
}
h1 {
margin-top: 22px;
font-size: 46px;
font-weight: 600;
letter-spacing: -0.028em;
line-height: 1.16;
max-width: 20ch;
position: relative;
}
.footer {
position: absolute;
left: 88px;
right: 88px;
bottom: 56px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 22px;
color: #71717a;
}
.pill {
border: 1.5px solid #e4e4e7;
border-radius: 999px;
padding: 9px 20px;
font-size: 19px;
font-weight: 500;
color: #3f3f46;
}
</style>
</head>
<body>
<div class="grid"></div>
<div class="row">
<svg class="mark" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="#09090b" />
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="#09090b" />
<path
d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z"
fill="#09090b"
/>
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="#fff" />
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="#fff" />
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="#fff" />
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="#09090b" />
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="#09090b" />
</svg>
<span class="wordmark">pig</span>
</div>
<div class="expansion">Prime Intellect Growth</div>
<h1>The CRM for companies that buy compute and sell it.</h1>
<div class="footer">
<span>primeintellectgrowth.com</span>
<span class="pill">Open source · Apache 2.0</span>
</div>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
/**
* Render the social card and icon set to PNG.
*
* node apps/web/tools/render-assets.mjs
*
* Generated rather than hand-drawn so that the mark, the wordmark and the
* tagline cannot drift apart from the product, and so regenerating after a
* brand change is one command.
*
* Uses the system Chrome via `channel: 'chrome'` rather than Playwright's own
* bundled browser, so `npm install` does not pull a ~150MB download for a
* script that runs occasionally, by hand, when the brand changes.
*/
import { chromium } from 'playwright';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const publicDir = join(here, '..', 'public');
const PIG_SVG = `
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="INK"/>
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="INK"/>
<path d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z" fill="INK"/>
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="PAPER"/>
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="PAPER"/>
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="PAPER"/>
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="INK"/>
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="INK"/>
</svg>`;
const icon = (ink, paper) => PIG_SVG.replaceAll('INK', ink).replaceAll('PAPER', paper);
/**
* An icon page with padding around the mark.
*
* Home-screen and maskable icons are cropped by the platform — iOS rounds the
* corners, Android may apply a circle. A mark drawn edge to edge loses its
* ears to that crop, so it is inset inside a filled plate.
*/
function iconPage({ size, inset, background, ink, radius = 0 }) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
*{margin:0;padding:0;box-sizing:border-box}
body{width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center;
background:${background};border-radius:${radius}px;overflow:hidden}
svg{width:${size - inset * 2}px;height:${size - inset * 2}px}
</style></head><body>${icon(ink, background)}</body></html>`;
}
const browser = await chromium.launch({ channel: 'chrome' });
async function shoot(html, path, width, height, scale = 1) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: scale,
});
const page = await ctx.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
await page.screenshot({ path, omitBackground: false });
await ctx.close();
console.log(` ${path.replace(publicDir + '/', '')} ${width * scale}×${height * scale}`);
}
console.log('Rendering assets…');
// Social card. Rendered at 1× because 1200×630 is already the target size —
// unfurlers do not use a 2× variant and the extra bytes cost load time.
const { readFile } = await import('node:fs/promises');
const ogHtml = await readFile(join(here, 'og-template.html'), 'utf8');
await shoot(ogHtml, join(publicDir, 'og.png'), 1200, 630, 1);
// Apple touch icon. iOS composites onto whatever it likes and rounds the
// corners itself, so this is drawn on an opaque white plate with generous
// inset — a transparent one renders black on some home screens.
await shoot(
iconPage({ size: 180, inset: 26, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'apple-touch-icon.png'),
180,
180,
);
// PWA icons. `maskable` gets a larger inset so the safe zone survives a
// circular mask on Android.
await shoot(
iconPage({ size: 192, inset: 24, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'icon-192.png'),
192,
192,
);
await shoot(
iconPage({ size: 512, inset: 64, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'icon-512.png'),
512,
512,
);
await shoot(
iconPage({ size: 512, inset: 112, background: '#ffffff', ink: '#09090b' }),
join(publicDir, 'icon-maskable-512.png'),
512,
512,
);
// No separate tiny favicon is rendered. Chrome hangs screenshotting viewports
// below roughly 128px, and it is unnecessary: browsers prefer the SVG, and
// icon-192 downscales cleanly for the ones that do not.
await browser.close();
console.log('Done.');