Phase 8: share panel, polish, accessibility and deploy config

- Share links section: scoreboard link, every judged game's judge links
  and open link with QR codes, and 'Copy all judge links' as plain text
  for WhatsApp. Warns when links point at localhost.
- Error and not-found pages with plain-language copy; a real start page.
- Accessibility: darker 'alert strong' red for text and white-on-red
  (Alert was 4.0:1 on white), no text below 70% ink, 44px block buttons
  and link targets, scoreboard status stays readable when idle.
- vercel.json pins functions to Dublin next to the West EU database;
  README documents deployment and pre-game-night checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:02:29 +02:00
co-authored by Claude Opus 5.5
parent 2702491096
commit f1b3a862f8
25 changed files with 264 additions and 45 deletions
+32 -3
View File
@@ -34,7 +34,36 @@ an admin control room, a projector scoreboard, and per-game judge links.
- Open `docs/KICKOFF_PROMPT.md`, paste its contents into Claude Code. - Open `docs/KICKOFF_PROMPT.md`, paste its contents into Claude Code.
- Let it go phase by phase. After each phase: run it, click around, commit, then say "next phase". - Let it go phase by phase. After each phase: run it, click around, commit, then say "next phase".
## Tips ## Commands
- Keep `docs/` as the source of truth. If you change your mind on a feature, edit the doc first, then tell Claude Code "I updated PRD.md section X, implement it". | Command | What it does |
- Test the scoreboard on the actual projector PC + one judge on a phone before game night. Wi-Fi at church is the #1 thing that'll bite you. |---|---|
| `npm run dev` | Run locally at http://localhost:3000 |
| `npm run test` / `npm run test:coverage` | Unit tests (scoring engine must stay at 100%) |
| `npm run lint` / `npm run build` | Lint / production build |
| `npm run check:rls` | Proves the anon key cannot read or write any table |
| `npm run gen:types` | Regenerate `src/lib/database.types.ts` after a migration |
## Deploying to Vercel
1. Push this repo to GitHub, then in Vercel choose **Add New → Project** and import it.
2. Add these environment variables in Vercel (Production): `NEXT_PUBLIC_SUPABASE_URL`,
`NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `ADMIN_EMAILS`,
`NEXT_PUBLIC_APP_NAME`, and `NEXT_PUBLIC_SITE_URL` set to the site's address
(e.g. `https://your-project.vercel.app`, no trailing slash). The build fails with a clear
message if any is missing.
3. Deploy. `vercel.json` runs the server functions in Dublin (`dub1`), next to a West EU
Supabase project; change it if your database is elsewhere.
4. In Supabase → Authentication → URL Configuration, set **Site URL** to the Vercel address and
add `https://your-project.vercel.app/auth/confirm` to **Redirect URLs** (keep the localhost
one for development).
5. If you change `NEXT_PUBLIC_SITE_URL` later, redeploy: share links and QR codes are built from it.
## Before game night
- Free Supabase projects pause after about a week without use. Open the Supabase dashboard a
day before and click **Restore** if it is paused.
- Sign in as host the day before; the magic-link email is limited to a few per hour.
- Test the scoreboard on the actual projector PC and one judge link on a phone, on the venue's
Wi-Fi. The scoreboard also polls every 10 seconds if live updates are blocked.
- Keep `docs/` as the source of truth: change the doc first, then the code.
+1 -1
View File
@@ -5,7 +5,7 @@ human a short "how to test this manually" checklist. Don't start the next phase
## Phase 0 — Scaffold ## Phase 0 — Scaffold
- Next.js + TS strict + Tailwind + ESLint + Vitest. `src/` layout per ARCHITECTURE.md. - Next.js + TS strict + Tailwind + ESLint + Vitest. `src/` layout per ARCHITECTURE.md.
- Fonts: Big Shoulders Display + Atkinson Hyperlegible via `next/font/google`. Tokens from UI.md as CSS variables + Tailwind theme. - Fonts: Big Shoulders (Google's new name for Big Shoulders Display) + Atkinson Hyperlegible via `next/font/google`. Tokens from UI.md as CSS variables + Tailwind theme.
- Supabase clients (`browser`, `server`, `admin` with `server-only`). - Supabase clients (`browser`, `server`, `admin` with `server-only`).
- `.env.local` loading; fail fast with a clear error if vars are missing. - `.env.local` loading; fail fast with a clear error if vars are missing.
**Done when:** `npm run dev` shows a placeholder home; `npm run test` runs a dummy test. **Done when:** `npm run dev` shows a placeholder home; `npm run test` runs a dummy test.
+3 -2
View File
@@ -12,11 +12,12 @@ Spend the boldness in ONE place: the scoreboard's rank-change moment. Admin + ju
| Chalk | `#F5F7FA` | Admin/judge background | | Chalk | `#F5F7FA` | Admin/judge background |
| Ink | `#14213D` | Primary text on light | | Ink | `#14213D` | Primary text on light |
| Signal | `#2EC4B6` | Saved/connected state | | Signal | `#2EC4B6` | Saved/connected state |
| Alert | `#E63946` | Errors, penalties, hidden state | | Alert | `#E63946` | Penalty/hidden accents, borders, status dots |
| Alert strong | `#C1121F` | Error text on light backgrounds, and red fills under white text (Alert itself is only 4.0:1 on white) |
Team colours are user-chosen; pick text colour per team by contrast (white or Ink). Team colours are user-chosen; pick text colour per team by contrast (white or Ink).
Type: Type:
- **Big Shoulders Display** (Google Fonts) — scoreboard numbers, team names, game titles. Tabular numbers. - **Big Shoulders** (Google Fonts; formerly "Big Shoulders Display") — scoreboard numbers, team names, game titles. Tabular numbers.
- **Atkinson Hyperlegible** — all body/UI text (readable on phones + projector). - **Atkinson Hyperlegible** — all body/UI text (readable on phones + projector).
Sentence case everywhere. No all-caps labels. Sentence case everywhere. No all-caps labels.
+7 -1
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { EventDetailsForm } from "@/components/admin/event-details-form"; import { EventDetailsForm } from "@/components/admin/event-details-form";
import { GamesEditor } from "@/components/admin/games-editor"; import { GamesEditor } from "@/components/admin/games-editor";
import { SharePanel } from "@/components/admin/share-panel";
import { TeamsEditor } from "@/components/admin/teams-editor"; import { TeamsEditor } from "@/components/admin/teams-editor";
import { buttonClass } from "@/components/ui/button"; import { buttonClass } from "@/components/ui/button";
import { requireAdmin } from "@/lib/auth"; import { requireAdmin } from "@/lib/auth";
@@ -21,7 +22,7 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
return ( return (
<div className="mx-auto flex max-w-5xl flex-col gap-8"> <div className="mx-auto flex max-w-5xl flex-col gap-8">
<div> <div>
<Link href="/admin" className="text-sm underline underline-offset-4"> <Link href="/admin" className="inline-flex min-h-11 items-center text-sm underline underline-offset-4">
← All game nights ← All game nights
</Link> </Link>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3"> <div className="mt-1 flex flex-wrap items-center justify-between gap-3">
@@ -59,6 +60,11 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
<h2 className="text-xl font-bold">Games</h2> <h2 className="text-xl font-bold">Games</h2>
<GamesEditor eventId={event.id} games={games} /> <GamesEditor eventId={event.id} games={games} />
</section> </section>
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Share links</h2>
<SharePanel scoreboardToken={event.scoreboard_token} games={games} />
</section>
</div> </div>
); );
} }
+28
View File
@@ -0,0 +1,28 @@
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function ErrorPage({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col items-start justify-center gap-4 p-6">
<h1 className="font-display text-4xl font-bold">This page couldn&apos;t load</h1>
<p className="text-lg">
The server or the connection had a problem. Nothing you entered before this was lost. Check the
Wi-Fi and try again.
</p>
<Button onClick={() => retry()}>Try again</Button>
</main>
);
}
+3
View File
@@ -8,6 +8,8 @@
--ink: #14213d; --ink: #14213d;
--signal: #2ec4b6; --signal: #2ec4b6;
--alert: #e63946; --alert: #e63946;
/* Alert darkened to pass 4.5:1 as text on white, and as a background under white text. */
--alert-strong: #c1121f;
} }
/* Expose the tokens as Tailwind utilities: bg-stage, text-ink, font-display, ... */ /* Expose the tokens as Tailwind utilities: bg-stage, text-ink, font-display, ... */
@@ -18,6 +20,7 @@
--color-ink: var(--ink); --color-ink: var(--ink);
--color-signal: var(--signal); --color-signal: var(--signal);
--color-alert: var(--alert); --color-alert: var(--alert);
--color-alert-strong: var(--alert-strong);
--font-display: var(--font-big-shoulders), ui-sans-serif, system-ui, sans-serif; --font-display: var(--font-big-shoulders), ui-sans-serif, system-ui, sans-serif;
--font-sans: var(--font-atkinson), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--font-atkinson), ui-sans-serif, system-ui, sans-serif;
} }
+1 -1
View File
@@ -30,7 +30,7 @@ export function LoginForm({ initialError }: { initialError: string | null }) {
</Button> </Button>
<p role="status" aria-live="polite" className="min-h-6"> <p role="status" aria-live="polite" className="min-h-6">
{state.status === "sent" && <span className="font-bold text-ink">{state.message}</span>} {state.status === "sent" && <span className="font-bold text-ink">{state.message}</span>}
{error && <span className="font-bold text-alert">{error}</span>} {error && <span className="font-bold text-alert-strong">{error}</span>}
</p> </p>
</form> </form>
); );
+17
View File
@@ -0,0 +1,17 @@
import Link from "next/link";
import { buttonClass } from "@/components/ui/button";
export default function NotFound() {
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col items-start justify-center gap-4 p-6">
<h1 className="font-display text-4xl font-bold">Page not found</h1>
<p className="text-lg">
That link doesn&apos;t lead anywhere. If the host sent it to you, ask them for a new one.
</p>
<Link href="/" className={buttonClass("secondary")}>
Go to the start page
</Link>
</main>
);
}
+15 -23
View File
@@ -1,32 +1,24 @@
import Link from "next/link";
import { buttonClass } from "@/components/ui/button";
import { publicEnv } from "@/lib/env.public"; import { publicEnv } from "@/lib/env.public";
const tokens = [
{ name: "Stage", className: "bg-stage" },
{ name: "Floodlight", className: "bg-floodlight" },
{ name: "Chalk", className: "bg-chalk" },
{ name: "Ink", className: "bg-ink" },
{ name: "Signal", className: "bg-signal" },
{ name: "Alert", className: "bg-alert" },
];
// Placeholder home for Phase 0: proves fonts, tokens and env loading work.
export default function Home() { export default function Home() {
return ( return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-8 p-6"> <main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-6 p-6">
<div> <div>
<h1 className="font-display text-6xl font-bold tabular-nums"> <h1 className="font-display text-6xl font-bold">{publicEnv.NEXT_PUBLIC_APP_NAME}</h1>
{publicEnv.NEXT_PUBLIC_APP_NAME} <p className="mt-2 text-lg">
</h1> Live scoring for game nights: a control room for the host, a scoreboard for the projector and
<p className="mt-2 text-lg">Scaffold is running. Admin, scoreboard and judge pages come next.</p> links for judges&apos; phones.
</p>
</div> </div>
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-6"> <div>
{tokens.map((token) => ( <Link href="/admin" className={buttonClass("primary")}>
<li key={token.name} className="text-sm"> Host sign-in
<div className={`h-12 rounded border border-ink/20 ${token.className}`} /> </Link>
{token.name} </div>
</li> <p>Judging or watching the scoreboard? Use the link or QR code the host gave you.</p>
))}
</ul>
</main> </main>
); );
} }
+1 -1
View File
@@ -113,7 +113,7 @@ export function BonusPanel({ view }: { view: LiveView }) {
{adjustments.map((adjustment) => ( {adjustments.map((adjustment) => (
<li key={adjustment.id} className="flex items-center gap-2"> <li key={adjustment.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className={`font-bold tabular-nums ${adjustment.points < 0 ? "text-alert" : ""}`}> <span className={`font-bold tabular-nums ${adjustment.points < 0 ? "text-alert-strong" : ""}`}>
{signed(adjustment.points)} {signed(adjustment.points)}
</span>{" "} </span>{" "}
{teamName(adjustment.teamId)} {teamName(adjustment.teamId)}
+4 -1
View File
@@ -81,7 +81,10 @@ export function ControlRoom({ view }: { view: LiveView }) {
<div className="mx-auto flex max-w-[100rem] flex-col gap-4"> <div className="mx-auto flex max-w-[100rem] flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<Link href={`/admin/events/${event.id}`} className="text-sm underline underline-offset-4"> <Link
href={`/admin/events/${event.id}`}
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4"
>
← Setup ← Setup
</Link> </Link>
<h1 className="font-display text-4xl font-bold">{event.name} · control room</h1> <h1 className="font-display text-4xl font-bold">{event.name} · control room</h1>
+1 -1
View File
@@ -1,6 +1,6 @@
export function ErrorText({ children }: { children: string | null }) { export function ErrorText({ children }: { children: string | null }) {
return ( return (
<p role="alert" className="font-bold text-alert empty:hidden"> <p role="alert" className="font-bold text-alert-strong empty:hidden">
{children} {children}
</p> </p>
); );
+1 -1
View File
@@ -93,7 +93,7 @@ export function JudgedPanel({
✓<span className="sr-only"> all scored</span> ✓<span className="sr-only"> all scored</span>
</span> </span>
) : scored === 0 ? ( ) : scored === 0 ? (
<span className="text-ink/50"> <span className="text-ink/70">
✗<span className="sr-only"> nothing scored</span> ✗<span className="sr-only"> nothing scored</span>
</span> </span>
) : ( ) : (
+1 -1
View File
@@ -30,7 +30,7 @@ export function JudgesEditor({ eventId, game }: { eventId: string; game: GameWit
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{game.judges.map((judge) => ( {game.judges.map((judge) => (
<li key={judge.id} className="flex flex-wrap items-center gap-2 rounded-md bg-white p-2"> <li key={judge.id} className="flex flex-wrap items-center gap-2 rounded-md bg-white p-2">
<span className={`min-w-32 flex-1 font-bold ${judge.revoked ? "text-ink/50 line-through" : ""}`}> <span className={`min-w-32 flex-1 font-bold ${judge.revoked ? "text-ink/70 line-through" : ""}`}>
{judge.name} {judge.name}
{judge.source === "open_link" && ( {judge.source === "open_link" && (
<span className="ml-2 text-sm font-normal text-ink/70 no-underline">joined via open link</span> <span className="ml-2 text-sm font-normal text-ink/70 no-underline">joined via open link</span>
+1 -1
View File
@@ -84,7 +84,7 @@ export function ScoreboardPanel({ view }: { view: LiveView }) {
className={`min-h-14 rounded-md border px-1 text-sm font-bold disabled:opacity-60 ${ className={`min-h-14 rounded-md border px-1 text-sm font-bold disabled:opacity-60 ${
selected selected
? visibility === "hidden" ? visibility === "hidden"
? "border-alert bg-alert text-white" ? "border-alert-strong bg-alert-strong text-white"
: "border-ink bg-ink text-white" : "border-ink bg-ink text-white"
: "border-ink/25 bg-white hover:bg-ink/5" : "border-ink/25 bg-white hover:bg-ink/5"
}`} }`}
+2 -2
View File
@@ -118,7 +118,7 @@ export function ScoringGrid({
) : ( ) : (
<> <>
<strong <strong
className={`text-lg tabular-nums ${value !== null ? "text-ink/50 line-through" : ""}`} className={`text-lg tabular-nums ${value !== null ? "text-ink/70 line-through" : ""}`}
> >
{formatScore(cell.judgeAverage)} {formatScore(cell.judgeAverage)}
</strong>{" "} </strong>{" "}
@@ -128,7 +128,7 @@ export function ScoringGrid({
</> </>
)} )}
{value !== null && ( {value !== null && (
<strong className="ml-1 rounded bg-alert px-1.5 text-white"> <strong className="ml-1 rounded bg-alert-strong px-1.5 text-white">
Override {formatScore(value)} Override {formatScore(value)}
</strong> </strong>
)} )}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/env.public", () => ({ publicEnv: { NEXT_PUBLIC_SITE_URL: "https://gamenight.example" } }));
const { judgeLinksText } = await import("./share-panel");
const judge = (name: string, token: string, revoked = false) =>
({ id: token, game_id: "g", name, token, revoked, source: "nominated", created_at: "" }) as const;
describe("judgeLinksText", () => {
it("lists one line per active judge, skipping revoked ones", () => {
const text = judgeLinksText({
title: "Karaoke",
judges: [judge("Eben", "aaa"), judge("Gone", "bbb", true), judge("Melissa", "ccc")],
});
expect(text).toBe(
"Karaoke — judge links\nEben: https://gamenight.example/j/aaa\nMelissa: https://gamenight.example/j/ccc",
);
});
});
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { publicEnv } from "@/lib/env.public";
import type { GameWithCriteria } from "@/lib/queries/event";
import { ShareLink } from "./share-link";
const site = publicEnv.NEXT_PUBLIC_SITE_URL;
/** Plain text for pasting into WhatsApp: one line per judge. */
export function judgeLinksText(game: Pick<GameWithCriteria, "title" | "judges">): string {
const lines = game.judges
.filter((judge) => !judge.revoked)
.map((judge) => `${judge.name}: ${site}/j/${judge.token}`);
return [`${game.title} — judge links`, ...lines].join("\n");
}
/** Every link for the night in one place: the scoreboard, and each judged game's judges. */
export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string; games: GameWithCriteria[] }) {
const judgedGames = games.filter((game) => game.scoring_mode === "judged");
const localOnly = /\/\/(localhost|127\.0\.0\.1)/.test(site);
return (
<div className="mt-3 flex flex-col gap-4">
{localOnly && (
<p className="rounded-md border-2 border-ink bg-chalk p-3">
These links point at <strong>{site}</strong>, which only works on this computer. Phones can open
them once the site is deployed and <code>NEXT_PUBLIC_SITE_URL</code> is set to its address.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-40 flex-1 font-bold">Scoreboard (projector)</p>
<ShareLink
url={`${site}/s/${scoreboardToken}`}
copyLabel="Copy scoreboard link"
qrLabel="QR code for the scoreboard"
/>
</div>
{judgedGames.length === 0 ? (
<p>No judged games yet. Set a game to “Judged” to give judges their own links.</p>
) : (
judgedGames.map((game) => {
const activeJudges = game.judges.filter((judge) => !judge.revoked);
return (
<div key={game.id} className="flex flex-col gap-2 border-t border-ink/15 pt-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-40 flex-1 text-lg font-bold">{game.title}</h3>
{activeJudges.length > 0 && <CopyAllButton text={judgeLinksText(game)} />}
</div>
{activeJudges.length === 0 && !game.open_link_enabled && (
<p>No judges yet. Open this game below to add judges or turn on its open link.</p>
)}
<ul className="flex flex-col gap-2">
{activeJudges.map((judge) => (
<li key={judge.id} className="flex flex-wrap items-center gap-2">
<span className="min-w-32 flex-1">{judge.name}</span>
<ShareLink
url={`${site}/j/${judge.token}`}
copyLabel="Copy judge link"
qrLabel={`QR code for ${judge.name}'s judge link`}
/>
</li>
))}
{game.open_link_enabled && (
<li className="flex flex-wrap items-center gap-2">
<span className="min-w-32 flex-1">Open link (anyone, enters their name)</span>
<ShareLink
url={`${site}/jo/${game.open_token}`}
copyLabel="Copy open link"
qrLabel={`QR code for the open judge link of ${game.title}`}
/>
</li>
)}
</ul>
</div>
);
})
)}
</div>
);
}
function CopyAllButton({ text }: { text: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
<>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setState("copied");
setTimeout(() => setState("idle"), 2500);
} catch {
setState("failed");
}
}}
>
{state === "copied" ? "Copied ✓" : "Copy all judge links"}
</Button>
{state === "failed" && (
<div className="basis-full">
<p className="text-sm">Couldn&apos;t copy automatically. Select the text below.</p>
<pre className="mt-1 rounded bg-chalk p-2 text-sm whitespace-pre-wrap select-all">{text}</pre>
</div>
)}
</>
);
}
+1 -1
View File
@@ -142,7 +142,7 @@ export function JudgeView({ token, initial }: { token: string; initial: JudgePay
{isSaving(inTeam) ? ( {isSaving(inTeam) ? (
"Saving…" "Saving…"
) : error ? ( ) : error ? (
<span className="text-alert">{error}</span> <span className="text-alert-strong">{error}</span>
) : scored > 0 ? ( ) : scored > 0 ? (
`Saved ✓${scored < payload.criteria.length ? ` · ${scored} of ${payload.criteria.length} scored` : ""}` `Saved ✓${scored < payload.criteria.length ? ` · ${scored} of ${payload.criteria.length} scored` : ""}`
) : ( ) : (
+1 -1
View File
@@ -88,7 +88,7 @@ export function OpenLinkForm({ openToken, gameTitle }: { openToken: string; game
<Button type="submit" disabled={pending}> <Button type="submit" disabled={pending}>
{pending ? "Joining…" : "Start judging"} {pending ? "Joining…" : "Start judging"}
</Button> </Button>
<p role="alert" className="min-h-6 font-bold text-alert"> <p role="alert" className="min-h-6 font-bold text-alert-strong">
{error} {error}
</p> </p>
</form> </form>
+1 -1
View File
@@ -143,7 +143,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
</ol> </ol>
<footer <footer
className={`flex items-center justify-end gap-[2vw] text-[clamp(0.875rem,2.2vh,1.5rem)] transition-opacity duration-500 ${idle ? "opacity-40" : ""}`} className={`flex items-center justify-end gap-[2vw] text-[clamp(0.875rem,2.2vh,1.5rem)] transition-opacity duration-500 ${idle ? "opacity-70" : ""}`}
> >
{payload.visibility === "frozen" && !revealing && ( {payload.visibility === "frozen" && !revealing && (
<span className="mr-auto text-white/70">Scores frozen</span> <span className="mr-auto text-white/70">Scores frozen</span>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { ButtonHTMLAttributes } from "react";
const variants = { const variants = {
primary: "bg-ink text-white hover:bg-ink/90", primary: "bg-ink text-white hover:bg-ink/90",
secondary: "border border-ink/25 bg-white text-ink hover:bg-ink/5", secondary: "border border-ink/25 bg-white text-ink hover:bg-ink/5",
danger: "border border-alert/50 bg-white text-alert hover:bg-alert/10", danger: "border border-alert-strong/60 bg-white text-alert-strong hover:bg-alert/10",
} as const; } as const;
const sizes = { const sizes = {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export const inputClass = export const inputClass =
"min-h-11 w-full rounded-md border border-ink/25 bg-white px-3 text-base text-ink placeholder:text-ink/40"; "min-h-11 w-full rounded-md border border-ink/25 bg-white px-3 text-base text-ink placeholder:text-ink/60";
export const textareaClass = `${inputClass} py-2`; export const textareaClass = `${inputClass} py-2`;
+1 -1
View File
@@ -40,7 +40,7 @@ export function BlockInput({
aria-pressed={selected} aria-pressed={selected}
onClick={() => onChange(selected ? null : option)} onClick={() => onChange(selected ? null : option)}
className={`min-h-11 rounded-md border text-base font-bold tabular-nums transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${ className={`min-h-11 rounded-md border text-base font-bold tabular-nums transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
compact ? "min-w-9 px-1" : "min-w-11 flex-1 px-2" compact ? "min-w-11 px-1" : "min-w-11 flex-1 px-2"
} ${selected ? "border-transparent" : "border-ink/25 bg-white hover:bg-ink/5"}`} } ${selected ? "border-transparent" : "border-ink/25 bg-white hover:bg-ink/5"}`}
style={selected ? { backgroundColor: color, color: readableTextColor(color) } : undefined} style={selected ? { backgroundColor: color, color: readableTextColor(color) } : undefined}
> >
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["dub1"]
}