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:
@@ -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.
|
||||
- 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".
|
||||
- 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.
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `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
@@ -5,7 +5,7 @@ human a short "how to test this manually" checklist. Don't start the next phase
|
||||
|
||||
## Phase 0 — Scaffold
|
||||
- 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`).
|
||||
- `.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.
|
||||
|
||||
+3
-2
@@ -12,11 +12,12 @@ Spend the boldness in ONE place: the scoreboard's rank-change moment. Admin + ju
|
||||
| Chalk | `#F5F7FA` | Admin/judge background |
|
||||
| Ink | `#14213D` | Primary text on light |
|
||||
| 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).
|
||||
|
||||
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).
|
||||
Sentence case everywhere. No all-caps labels.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { EventDetailsForm } from "@/components/admin/event-details-form";
|
||||
import { GamesEditor } from "@/components/admin/games-editor";
|
||||
import { SharePanel } from "@/components/admin/share-panel";
|
||||
import { TeamsEditor } from "@/components/admin/teams-editor";
|
||||
import { buttonClass } from "@/components/ui/button";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
@@ -21,7 +22,7 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
|
||||
return (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-8">
|
||||
<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
|
||||
</Link>
|
||||
<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>
|
||||
<GamesEditor eventId={event.id} games={games} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
--ink: #14213d;
|
||||
--signal: #2ec4b6;
|
||||
--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, ... */
|
||||
@@ -18,6 +20,7 @@
|
||||
--color-ink: var(--ink);
|
||||
--color-signal: var(--signal);
|
||||
--color-alert: var(--alert);
|
||||
--color-alert-strong: var(--alert-strong);
|
||||
--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;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LoginForm({ initialError }: { initialError: string | null }) {
|
||||
</Button>
|
||||
<p role="status" aria-live="polite" className="min-h-6">
|
||||
{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>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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'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
@@ -1,32 +1,24 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { buttonClass } from "@/components/ui/button";
|
||||
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() {
|
||||
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>
|
||||
<h1 className="font-display text-6xl font-bold tabular-nums">
|
||||
{publicEnv.NEXT_PUBLIC_APP_NAME}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg">Scaffold is running. Admin, scoreboard and judge pages come next.</p>
|
||||
<h1 className="font-display text-6xl font-bold">{publicEnv.NEXT_PUBLIC_APP_NAME}</h1>
|
||||
<p className="mt-2 text-lg">
|
||||
Live scoring for game nights: a control room for the host, a scoreboard for the projector and
|
||||
links for judges' phones.
|
||||
</p>
|
||||
</div>
|
||||
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{tokens.map((token) => (
|
||||
<li key={token.name} className="text-sm">
|
||||
<div className={`h-12 rounded border border-ink/20 ${token.className}`} />
|
||||
{token.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
<Link href="/admin" className={buttonClass("primary")}>
|
||||
Host sign-in
|
||||
</Link>
|
||||
</div>
|
||||
<p>Judging or watching the scoreboard? Use the link or QR code the host gave you.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function BonusPanel({ view }: { view: LiveView }) {
|
||||
{adjustments.map((adjustment) => (
|
||||
<li key={adjustment.id} className="flex items-center gap-2">
|
||||
<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)}
|
||||
</span>{" "}
|
||||
{teamName(adjustment.teamId)}
|
||||
|
||||
@@ -81,7 +81,10 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
||||
<div className="mx-auto flex max-w-[100rem] flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<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
|
||||
</Link>
|
||||
<h1 className="font-display text-4xl font-bold">{event.name} · control room</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function ErrorText({ children }: { children: string | null }) {
|
||||
return (
|
||||
<p role="alert" className="font-bold text-alert empty:hidden">
|
||||
<p role="alert" className="font-bold text-alert-strong empty:hidden">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ export function JudgedPanel({
|
||||
✓<span className="sr-only"> all scored</span>
|
||||
</span>
|
||||
) : scored === 0 ? (
|
||||
<span className="text-ink/50">
|
||||
<span className="text-ink/70">
|
||||
✗<span className="sr-only"> nothing scored</span>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -30,7 +30,7 @@ export function JudgesEditor({ eventId, game }: { eventId: string; game: GameWit
|
||||
<ul className="flex flex-col gap-2">
|
||||
{game.judges.map((judge) => (
|
||||
<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.source === "open_link" && (
|
||||
<span className="ml-2 text-sm font-normal text-ink/70 no-underline">joined via open link</span>
|
||||
|
||||
@@ -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 ${
|
||||
selected
|
||||
? visibility === "hidden"
|
||||
? "border-alert bg-alert text-white"
|
||||
? "border-alert-strong bg-alert-strong text-white"
|
||||
: "border-ink bg-ink text-white"
|
||||
: "border-ink/25 bg-white hover:bg-ink/5"
|
||||
}`}
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ScoringGrid({
|
||||
) : (
|
||||
<>
|
||||
<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)}
|
||||
</strong>{" "}
|
||||
@@ -128,7 +128,7 @@ export function ScoringGrid({
|
||||
</>
|
||||
)}
|
||||
{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)}
|
||||
</strong>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export function JudgeView({ token, initial }: { token: string; initial: JudgePay
|
||||
{isSaving(inTeam) ? (
|
||||
"Saving…"
|
||||
) : error ? (
|
||||
<span className="text-alert">{error}</span>
|
||||
<span className="text-alert-strong">{error}</span>
|
||||
) : scored > 0 ? (
|
||||
`Saved ✓${scored < payload.criteria.length ? ` · ${scored} of ${payload.criteria.length} scored` : ""}`
|
||||
) : (
|
||||
|
||||
@@ -88,7 +88,7 @@ export function OpenLinkForm({ openToken, gameTitle }: { openToken: string; game
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Joining…" : "Start judging"}
|
||||
</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}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
||||
</ol>
|
||||
|
||||
<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 && (
|
||||
<span className="mr-auto text-white/70">Scores frozen</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ButtonHTMLAttributes } from "react";
|
||||
const variants = {
|
||||
primary: "bg-ink text-white hover:bg-ink/90",
|
||||
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;
|
||||
|
||||
const sizes = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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`;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function BlockInput({
|
||||
aria-pressed={selected}
|
||||
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 ${
|
||||
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"}`}
|
||||
style={selected ? { backgroundColor: color, color: readableTextColor(color) } : undefined}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"regions": ["dub1"]
|
||||
}
|
||||
Reference in New Issue
Block a user