feat: Revamp instance page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 37s

This commit is contained in:
2026-08-11 10:32:59 +01:00
parent ed260cd86c
commit a20e165ac2
13 changed files with 253 additions and 107 deletions
+10 -6
View File
@@ -81,7 +81,7 @@ export default function BillingPage() {
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
/>
) : (
<Table>
<Table stack>
<THead>
<TR className="hover:bg-transparent">
<TH>Instance</TH>
@@ -97,14 +97,18 @@ export default function BillingPage() {
const name = nameFor(s.instance_id);
return (
<TR key={s.subscription_id}>
<TD>
<TD label="Instance">
{name ?? <span className="text-ink-3">Not linked yet</span>}
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
</TD>
<TD>{s.tier.replace("_", " ")}</TD>
<TD className="text-ink-2">{s.term}</TD>
<TD className="text-ink-2">{s.status}</TD>
<TD>
<TD label="Plan">{s.tier.replace("_", " ")}</TD>
<TD label="Billing" className="text-ink-2">
{s.term}
</TD>
<TD label="Status" className="text-ink-2">
{s.status}
</TD>
<TD label="Renews">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
@@ -184,16 +184,16 @@ export default function InstancePage() {
</Panel>
)}
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<Panel title="Who can sign in">
<p className="text-[0.86rem] text-ink-2">Users for this install are managed inside it, in Settings &rarr; Instance. We have no access to your own deployment.</p>
</Panel>
)}
{/*
* On a self-hosted instance the licence is the errand: someone
* opens this page to fetch the blob and paste it. It sits
* directly under the term, above the panels that only explain
* things.
*/}
{lic && !cloud && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
{cloud && <MembersPanel instanceId={instance.instance_id} />}
{/*
* "Moves" rather than "Relinks": the count is rationed, so the
* headline is how many are left, and the panel explains what
@@ -201,12 +201,33 @@ export default function InstancePage() {
* so the panel is absent rather than present and refusing.
*/}
{!cloud && (
<Panel title="Moves" meta={`${instance.relink_count} of ${maxRelinks} used`}>
<p className="text-[0.86rem] text-ink-2">A licence binds to one install. Moving it to different hardware is a relink, and each account gets {maxRelinks}.</p>
<Panel title="Moves" meta={`${Math.max(0, maxRelinks - instance.relink_count)} of ${maxRelinks} left`}>
<p className="text-[0.86rem] text-ink-2">
A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID
that is a move, and it covers the rest of your current term.
</p>
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
</Panel>
)}
{/*
* A panel holding one sentence has not decided what it is for.
* For a self-hosted install the useful content is not "we don't
* do this" but where the thing they came looking for actually
* lives — and why the people on their HQ account are not it.
*/}
{!cloud && (
<Panel title="Who can sign in" meta="Managed in your install">
<p className="text-[0.86rem] text-ink-2">
You run this deployment, so its users live inside it rather than here. Add and remove them in the instance&rsquo;s own settings.
</p>
<p className="text-[0.82rem] text-ink-3">
People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting
one never grants the other.
</p>
</Panel>
)}
{!lic && (
<Panel bodyless>
<EmptyState title="No licence issued yet." body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
@@ -5,7 +5,7 @@ import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Button, controlClass } from "@/components/Button";
import { Field } from "@/components/Field";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { formatDate } from "@/lib/format";
@@ -89,11 +89,7 @@ export function InvitePanel() {
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
Account role
</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as AccountRole)}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
>
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className={controlClass()}>
{assignable.map((r) => (
<option key={r} value={r}>
{r}
@@ -6,6 +6,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { EmptyState, Panel } from "@/components/Panel";
import { controlClass } from "@/components/Button";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
export function AccountSearch() {
@@ -27,7 +28,7 @@ export function AccountSearch() {
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, email, ctm_… or an instance UUID"
className="w-full rounded border border-rule bg-panel-2 px-3 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none"
className={controlClass()}
/>
</label>
</Panel>
+2 -1
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { formatDate, formatStamp } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
@@ -30,7 +31,7 @@ export default function AuditPage() {
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Action, actor or target"
className="w-full rounded border border-rule bg-panel-2 px-3 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none"
className={controlClass()}
/>
</label>
</Panel>
@@ -6,11 +6,12 @@ import { useState } from "react";
import { api, type Tier } from "@/lib/api";
import { formatDate, licenceState } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
import { controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { TermSpark } from "@/components/TermBar";
const SELECT = "rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none";
const SELECT = controlClass("w-auto");
export default function LicensesPage() {
const [tier, setTier] = useState<"" | Tier>("");
+26 -1
View File
@@ -8,9 +8,34 @@ type Variant = "solid" | "line";
* border on the secondary variant. site/ does not have an accent-outlined
* button and this app should not invent one.
*/
/*
* The height every form control resolves to, buttons included.
*
* Padding alone cannot align them: a select is mono at 0.84rem and a button is
* sans at 0.94rem, so identical padding still leaves them ~7px apart and a
* filter row looks assembled from two different kits. It is the height the
* button's own padding already computed to, so buttons do not move — everything
* else comes up to meet them.
*/
export const CONTROL_HEIGHT = "h-11";
/*
* An input or select that sits on a form row with a button. Mono, because in
* this product the values typed into these are addresses, UUIDs and price IDs.
*/
export function controlClass(className?: string) {
return clsx(
CONTROL_HEIGHT,
"w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink",
"focus:border-accent focus:outline-none",
className,
);
}
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
return clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold",
CONTROL_HEIGHT,
"transition-[filter,border-color] duration-150 hover:brightness-110",
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
+12 -12
View File
@@ -1,7 +1,10 @@
import { controlClass } from "./Button";
export function Field({
label,
hint,
error,
className,
...input
}: React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
@@ -10,18 +13,15 @@ export function Field({
}) {
return (
<label className="grid max-w-md gap-1.5">
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</span>
<input
{...input}
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
/>
{error ? (
<span className="text-[0.82rem] text-expired">{error}</span>
) : hint ? (
<span className="text-[0.82rem] text-ink-3">{hint}</span>
) : null}
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
{/*
* className is pulled out of the spread rather than left in it: it
* used to be spread onto the input and then overwritten by the
* hardcoded one below, so a caller passing className got nothing and
* no warning.
*/}
<input {...input} className={controlClass(className)} aria-invalid={error ? true : undefined} />
{error ? <span className="text-[0.82rem] text-expired">{error}</span> : hint ? <span className="text-[0.82rem] text-ink-3">{hint}</span> : null}
</label>
);
}
+56 -26
View File
@@ -1,56 +1,86 @@
"use client";
import { useState } from "react";
import { Button } from "./Button";
import { Panel } from "./Panel";
/*
* A licence blob is signed public data, not a secret it is useless on any
* A licence blob is signed public data, not a secret it is useless on any
* instance other than the one it names. So it is safe to show inline, and
* showing it is what stops a blocked download from blocking a paying customer.
* That is also why it is never collapsed behind a toggle: someone whose
* clipboard and download are both blocked has to be able to select it by hand.
*
* It is evidence rather than content, so it is set in a well with a keyed strip
* saying what it is and how much of it there is, and given a fixed height. It
* used to run to 250px of base64 and was the largest thing on the page, which
* is a strange amount of room to give a string nobody reads.
*
* The download lives in the page header beside Renew, not here — it was in both
* places, which is one button too many for one file.
*/
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(blob);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
try {
await navigator.clipboard.writeText(blob);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard is refused without a secure context or a gesture the
// browser trusts. The blob is on screen and selectable either way,
// so this needs no error state.
}
}
const steps = [
<>
Open <code className="rounded-sm bg-accent-wash px-1">Settings Licence</code> on your install.
Open <Code>Settings Licence</Code> on your install.
</>,
<>Paste the licence into the box and save.</>,
<>
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
The page reports <Code>Valid</Code> straight away no restart.
</>,
];
return (
<section className="grid gap-3">
<h2 className="text-xl">Your licence</h2>
<div className="flex flex-wrap items-center gap-3">
<a
href={downloadUrl}
download={`vantage-${instanceId}.lic`}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-4 py-2.5 text-[0.94rem] font-semibold text-accent-ink"
>
Download licence
</a>
<Button variant="line" type="button" onClick={copy}>
{copied ? "Copied" : "Copy to clipboard"}
</Button>
<Panel title="Your licence" meta="Paste into your install">
<div className="grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Licence key</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">{blob.length.toLocaleString()} characters</span>
</div>
<div className="relative">
{/* Dashed, because this is data to be carried somewhere else
rather than a surface to read. */}
<pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 pr-24 font-mono text-[0.7rem] leading-relaxed text-ink-2">
{blob}
</pre>
<button
type="button"
onClick={copy}
className="absolute right-2 top-2 rounded border border-rule bg-panel px-2.5 py-1 font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-2 hover:border-accent hover:text-accent"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">{blob}</pre>
{/* Numbered because this is an actual sequence — each step is only
possible once the one before it is done. */}
<ol className="grid gap-2">
{steps.map((body, i) => (
<li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">{i + 1}</span>
<span>{body}</span>
<li key={i} className="grid grid-cols-[1.5rem_1fr] items-start gap-3 text-[0.84rem] text-ink-2">
<span className="grid h-[1.4rem] place-items-center rounded-sm border border-rule font-mono text-[0.68rem] text-accent">{i + 1}</span>
<span className="leading-[1.4rem]">{body}</span>
</li>
))}
</ol>
</section>
</Panel>
);
}
function Code({ children }: { children: React.ReactNode }) {
return <code className="rounded-sm bg-accent-wash px-1 font-mono text-[0.8rem] text-ink">{children}</code>;
}
+45 -28
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type InstanceRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { Button } from "@/components/Button";
import { Button, controlClass } from "@/components/Button";
import { EmptyState, Panel } from "@/components/Panel";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
@@ -23,7 +23,8 @@ const ROLE_GRANTS: Record<InstanceRole, string> = {
const SELECT_QUIET =
"rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none";
const SELECT = "rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.84rem] text-ink focus:border-accent focus:outline-none";
/* Same height as the Grant access button beside it — see controlClass. */
const SELECT = controlClass("bg-panel");
/*
* The access roster for one instance.
@@ -110,34 +111,46 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
/>
) : (
<ul className="grid border-t border-rule-soft">
{/*
* Two columns on a phone — monogram and address — with the
* controls dropping to their own full-width row beneath;
* three columns from sm up, controls right-aligned. As one
* wrapping flex row the address competed with a select and
* two buttons for 320px and lost, and the confirm step put
* three more elements into the same row.
*/}
{rows.map((m) => (
<li key={m.member_id} className="flex flex-wrap items-center gap-3 border-b border-rule-soft px-4 py-2.5 last:border-b-0">
<li
key={m.member_id}
className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-rule-soft px-4 py-3 last:border-b-0 sm:grid-cols-[auto_1fr_auto]"
>
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.62rem] font-bold text-accent-ink">
{m.email.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[0.84rem]">{m.email}</span>
<span className="min-w-0 break-all font-mono text-[0.84rem] sm:truncate sm:break-normal">{m.email}</span>
{canManage ? (
<label className="shrink-0">
<span className="sr-only">Role for {m.email}</span>
<select
value={m.role}
title={ROLE_GRANTS[m.role]}
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
className={SELECT_QUIET}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
) : (
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
)}
<div className="col-span-2 flex flex-wrap items-center gap-2 sm:col-span-1 sm:flex-nowrap sm:justify-end">
{canManage ? (
<label className="shrink-0">
<span className="sr-only">Role for {m.email}</span>
<select
value={m.role}
title={ROLE_GRANTS[m.role]}
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
className={SELECT_QUIET}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
) : (
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
)}
{canManage &&
{canManage &&
/*
* Confirming inline rather than through
* window.confirm(), and in the row itself rather
@@ -145,7 +158,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
* where the eye already is.
*/
(confirming === m.customer_user_id ? (
<span className="flex shrink-0 items-center gap-2.5">
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
<button
type="button"
@@ -173,6 +186,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
Revoke<span className="sr-only"> access for {m.email}</span>
</button>
))}
</div>
</li>
))}
</ul>
@@ -180,15 +194,18 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
{canManage && (
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
{/* Stacked and full width on a phone; one row from sm up.
Three controls side by side left the person select about
90px wide, which is not enough to read an address in. */}
<form
className="flex flex-wrap items-end gap-3"
className="grid gap-3 sm:flex sm:flex-wrap sm:items-end"
onSubmit={(e) => {
e.preventDefault();
setError(null);
if (selected) grant.mutate();
}}
>
<label className="grid min-w-0 flex-1 gap-1.5">
<label className="grid min-w-0 gap-1.5 sm:flex-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Grant access to</span>
<select value={selected} onChange={(e) => setSelected(e.target.value)} className={SELECT} disabled={candidates.length === 0}>
<option value="">{candidates.length === 0 ? "Everyone already has access" : "Choose a person…"}</option>
@@ -209,7 +226,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
))}
</select>
</label>
<Button type="submit" disabled={!selected || grant.isPending}>
<Button type="submit" disabled={!selected || grant.isPending} className="w-full justify-center sm:w-auto">
{grant.isPending ? "Granting…" : "Grant access"}
</Button>
</form>
+10 -4
View File
@@ -15,6 +15,8 @@ function CopyButton({ value }: { value: string }) {
return (
<button
type="button"
// Never the thing that wraps: it is 5 characters and the value
// beside it may be 36.
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
@@ -26,7 +28,7 @@ function CopyButton({ value }: { value: string }) {
// selectable either way, so this needs no error state.
}
}}
className="rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
className="shrink-0 rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
>
{done ? "Copied" : "Copy"}
</button>
@@ -76,9 +78,13 @@ export function PageHeader({
{(record?.length || status) && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
{record?.map((f) => (
<span key={f.key} className="flex items-center gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
// min-w-0 and break-all because the commonest value here
// is a 36-character UUID with a Copy button beside it,
// which does not fit a 320px screen as one unbreakable
// token and pushed the whole page sideways.
<span key={f.key} className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
<span className="min-w-0 break-all font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
+46 -7
View File
@@ -13,12 +13,24 @@ import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
* The head is the keyed idiom — mono, small, uppercase, widely tracked — which
* is what a column head is: a key above a value, exactly as the record line is
* a key beside one.
*
* MOBILE. `stack` collapses the table into one card per row below sm, each cell
* becoming a label/value pair drawn from TD's `label`. The variants below hang
* off a `stacked` class on the <table>, so a table that does not opt in is
* untouched at every width.
*
* It is opt-in rather than automatic because a stacked row whose cells have no
* labels is worse than a scrolling one — the values lose the only thing naming
* them. Customer screens stack; the staff console's wide registry tables scroll
* sideways instead, which is the right trade for eight columns read at a desk.
*/
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
const STACK = "max-sm:[.stacked_&]:block";
export function Table({ stack, className, children, ...props }: HTMLAttributes<HTMLTableElement> & { stack?: boolean }) {
return (
<div className="overflow-x-auto">
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", className)} {...props}>
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", stack && "stacked max-sm:block", className)} {...props}>
{children}
</table>
</div>
@@ -27,7 +39,7 @@ export function Table({ className, children, ...props }: HTMLAttributes<HTMLTabl
export function THead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-rule", className)} {...props}>
<thead className={clsx("border-b border-rule", "max-sm:[.stacked_&]:hidden", className)} {...props}>
{children}
</thead>
);
@@ -35,7 +47,7 @@ export function THead({ className, children, ...props }: HTMLAttributes<HTMLTabl
export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={className} {...props}>
<tbody className={clsx(STACK, "max-sm:[.stacked_&]:space-y-3 max-sm:[.stacked_&]:p-3", className)} {...props}>
{children}
</tbody>
);
@@ -43,7 +55,19 @@ export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTabl
export function TR({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr className={clsx("border-b border-rule-soft last:border-0 hover:bg-panel-2", className)} {...props}>
<tr
className={clsx(
"border-b border-rule-soft last:border-0 hover:bg-panel-2",
STACK,
// Plain bg-panel-2, not an opacity modifier: this app's tokens
// are whole colours rather than RGB channels, so `/40` has
// nothing to drop an alpha into. web/ stores channels precisely
// because it leans on those modifiers; this one must not.
"max-sm:[.stacked_&]:rounded max-sm:[.stacked_&]:border max-sm:[.stacked_&]:border-rule max-sm:[.stacked_&]:bg-panel-2 max-sm:[.stacked_&]:p-3",
className,
)}
{...props}
>
{children}
</tr>
);
@@ -69,9 +93,24 @@ export function TH({ className, numeric, children, ...props }: ThHTMLAttributes<
);
}
export function TD({ className, numeric, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps) {
export function TD({ className, numeric, label, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps & { label?: string }) {
return (
<td className={clsx("px-4 py-3 align-middle", numeric && "text-right tabular-nums", className)} {...props}>
<td
className={clsx(
"px-4 py-3 align-middle",
numeric && "text-right tabular-nums",
// Stacked, a cell is a label above its value and the right
// alignment that made a money column read down the page is
// meaningless, so it is dropped.
STACK,
"max-sm:[.stacked_&]:px-0 max-sm:[.stacked_&]:py-1 max-sm:[.stacked_&]:text-left",
className,
)}
{...props}
>
{label && (
<span className="mb-0.5 hidden font-mono text-[0.6rem] uppercase tracking-[0.13em] text-ink-3 max-sm:[.stacked_&]:block">{label}</span>
)}
{children}
</td>
);
+9 -4
View File
@@ -66,10 +66,15 @@ export function TermBar({
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Issued {formatDate(issuedAt)}</span>
<span className="font-mono text-[0.74rem] font-bold tabular-nums">{remaining}</span>
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
{/*
* On a phone the three ends stack, and the figure someone actually
* came for goes first — wrapping a justify-between row left "9 days
* left" marooned between two dates in the middle of the stack.
*/}
<div className="grid gap-1 sm:flex sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4">
<span className="order-1 font-mono text-[0.74rem] font-bold tabular-nums sm:order-2">{remaining}</span>
<span className="order-2 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3 sm:order-1">Issued {formatDate(issuedAt)}</span>
<span className="order-3 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
</div>
</div>
);