Added the frontend in web/, following its code conventions, with linting and generated backend types

web/CODE_CONVENTIONS.md covers the Vite, SolidJS, Tailwind and TypeScript
frontend: tooling, files, imports, exports, naming, TypeScript, formatting,
Solid's reactivity, styling, the backend's types, API calls, errors,
security, formatting values, comments, accessibility, storage,
dependencies and tests. npm run check (strict tsc -b, ESLint and Prettier)
passes, and npm run build builds it.

ESLint (typescript-eslint with type information, eslint-plugin-solid,
simple-import-sort, eslint-comments, and rules for the naming, type import,
export type and promise conventions) and Prettier (with the Tailwind class
plugin) check what they can. Tool versions are pinned exactly. Imports
between folders use @/, which works in Vite as well as TypeScript.

The backend's types are generated from its OpenAPI description into
src/types/api.ts by npm run generate:api, which turns swag's x-nullable
into null types first; src/types/Opportunities.ts names them for the app.

The app works against the backend's API: it fetches every page of
/api/opportunities with each opportunity's marks, the classifications to
filter by from /api/classifications, sets and clears marks (hidden among
them) with the CSRF token, and shows the OCDS tender statuses. Its lists'
requests are aborted when reloaded or no longer needed. The dev server
proxies /api and /ws to job-search serve, by default at localhost:9393.
web/go.mod keeps web/ out of the Go module, as an npm package in
node_modules ships Go code.

The page stays up to date over the backend's WebSocket (src/api/events.ts,
with hand-written event types in src/types/Events.ts), reconnecting with a
growing delay. ClassificationsUpdated changes the filter's classifications
in place and OpportunityMarksUpdated sets an opportunity's marks (unless
this page is still saving its own change). OpportunitiesUpdated catches the
list up: it asks for the opportunities changed after the last change batch
it has seen (changed_after) and merges them in, reloading the whole list
only when the backend doesn't know the batch. A reconnect catches up the
same way and reloads the classifications.
This commit is contained in:
bwilliams committed 2026-10-16 16:01:38 +01:00
1 parent 9905ffb4cb
commit 1832ea7437
42 files changed
+8019

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
import type { JSX } from 'solid-js';
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
import type { ClassificationCode } from '@/types/Opportunities';
import { fieldLabel, input, linkButton } from './styles';
const MAX_VISIBLE = 150;
interface Props {
classifications: ClassificationCode[];
isLoading: boolean;
error: Error | undefined;
onRetry: () => void;
selected: ReadonlySet<number>;
onChange: (next: Set<number>) => void;
class?: string;
}
/** A searchable list of classifications to filter by, with the chosen ones above it. */
export function ClassificationPicker(props: Props): JSX.Element {
const [query, setQuery] = createSignal('');
const inputId = createUniqueId();
const sorted = createMemo(() =>
[...props.classifications].sort((a, b) => a.code.localeCompare(b.code)),
);
const byId = createMemo(() => new Map(sorted().map((c) => [c.id, c])));
const matches = createMemo(() => {
const q = query().trim().toLocaleLowerCase();
if (!q) return sorted();
return sorted().filter(
(code) =>
code.code.toLocaleLowerCase().startsWith(q) ||
code.classification.toLocaleLowerCase().includes(q),
);
});
const toggle = (id: number): void => {
const next = new Set(props.selected);
if (next.has(id)) next.delete(id);
else next.add(id);
props.onChange(next);
};
const isUnavailable = (): boolean => props.isLoading || !!props.error;
return (
<div class={`min-w-0 ${props.class ?? ''}`}>
<label class={fieldLabel} for={inputId}>
Classification
</label>
<Show when={props.selected.size > 0}>
<ul class="mb-2 flex flex-wrap gap-1.5" aria-label="Selected classifications">
<For each={[...props.selected]}>
{(id) => {
const code = (): ClassificationCode | undefined => byId().get(id);
return (
<li>
<button
type="button"
class="inline-flex max-w-72 items-baseline gap-1.5 rounded-md bg-accent-soft px-2 py-1 text-accent"
onClick={() => toggle(id)}
title="Remove this classification"
>
<span class="text-sm tabular-nums">{code()?.code ?? id}</span>
<span class="truncate">{code()?.classification ?? 'Unknown'}</span>
<span aria-hidden="true" class="font-bold">
×
</span>
<span class="sr-only">Remove</span>
</button>
</li>
);
}}
</For>
</ul>
</Show>
<input
id={inputId}
type="search"
class={input}
placeholder="Find by code or name, e.g. 72000000 or software"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
disabled={isUnavailable()}
/>
<Show when={props.isLoading}>
<p class="mt-1.5 text-sm text-muted">Loading classifications…</p>
</Show>
<Show when={props.error}>
<p class="mt-1.5 text-sm text-mark-lost">
Classifications didn't load.{' '}
<button type="button" class={linkButton} onClick={() => props.onRetry()}>
Try again
</button>
</p>
</Show>
<Show when={!isUnavailable()}>
<ul
class="mt-1.5 max-h-72 overflow-y-auto rounded-md border border-line-soft p-1"
role="group"
aria-label="Classifications"
>
<For
each={matches().slice(0, MAX_VISIBLE)}
fallback={
<li class="p-1.5 text-sm text-muted">No classifications match “{query()}”.</li>
}
>
{(code) => (
<li>
<label class="grid cursor-pointer grid-cols-[auto_auto_1fr_auto] items-baseline gap-2 rounded px-1.5 py-1 hover:bg-paper">
<input
type="checkbox"
checked={props.selected.has(code.id)}
onChange={() => toggle(code.id)}
/>
<span class="text-sm text-muted tabular-nums">{code.code}</span>
<span class="truncate">{code.classification}</span>
<Show when={code.is_cpv}>
<span class="text-xs text-muted" title="Common Procurement Vocabulary">
CPV
</span>
</Show>
</label>
</li>
)}
</For>
</ul>
<Show when={matches().length > MAX_VISIBLE}>
<p class="mt-1.5 text-sm text-muted">
Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow
the list.
</p>
</Show>
</Show>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import type { JSX } from 'solid-js';
import { createSignal, createUniqueId, For, Show } from 'solid-js';
import type { MarkFilter, OpportunityFilters } from '@/lib/filterOpportunities';
import {
activeFilterCount,
hasActiveFilters,
NOT_MARKED,
statusLabel,
} from '@/lib/filterOpportunities';
import { readStored, STORAGE_KEYS, writeStored } from '@/lib/storage';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import type { ClassificationCode } from '@/types/Opportunities';
import { ClassificationPicker } from './ClassificationPicker';
import { buttonQuiet, fieldLabel, input, MARK_BG } from './styles';
interface Props {
filters: OpportunityFilters;
onChange: (next: OpportunityFilters) => void;
onClear: () => void;
statuses: string[];
classifications: ClassificationCode[];
isLoadingClassifications: boolean;
classificationsError: Error | undefined;
onRetryClassifications: () => void;
shown: number;
total: number;
}
const toggleChip =
'inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3.5 py-1.5 hover:border-muted aria-pressed:border-ink aria-pressed:bg-ink aria-pressed:text-surface';
function toggled<T>(set: ReadonlySet<T>, value: T): Set<T> {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
}
/**
* The collapsible panel of filters (title, status, marks, classification,
* hidden), with the count of opportunities shown.
*/
export function FilterPanel(props: Props): JSX.Element {
const titleId = createUniqueId();
const bodyId = createUniqueId();
// Open by default; the last choice is remembered.
const [isOpen, setIsOpen] = createSignal(readStored(STORAGE_KEYS.filtersOpen) !== 'false');
const toggleOpen = (): void => {
const next = !isOpen();
setIsOpen(next);
writeStored(STORAGE_KEYS.filtersOpen, String(next));
};
const update = (patch: Partial<OpportunityFilters>): void =>
props.onChange({ ...props.filters, ...patch });
return (
<section class="mb-4 rounded-[10px] border border-line bg-surface" aria-label="Filters">
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 sm:px-4">
<button
type="button"
class="-mx-1 inline-flex items-center gap-2 rounded-md px-1 py-1 font-semibold"
aria-expanded={isOpen()}
aria-controls={bodyId}
onClick={toggleOpen}
>
<svg
viewBox="0 0 16 16"
class="size-4 text-muted transition-transform duration-200 motion-reduce:transition-none"
classList={{ 'rotate-90': isOpen() }}
fill="currentColor"
aria-hidden="true"
>
<path d="M6 3.5 10.5 8 6 12.5z" />
</svg>
Filters
<Show when={activeFilterCount(props.filters) > 0}>
<span class="rounded-full bg-accent px-2 text-xs leading-5 font-semibold text-on-accent">
{activeFilterCount(props.filters)}
<span class="sr-only"> active</span>
</span>
</Show>
</button>
<p class="ml-auto text-muted tabular-nums" aria-live="polite">
{props.shown === props.total
? `${props.total.toLocaleString('en-GB')} opportunities`
: `${props.shown.toLocaleString('en-GB')} of ${props.total.toLocaleString('en-GB')} opportunities`}
</p>
<button
type="button"
class={buttonQuiet}
onClick={() => props.onClear()}
disabled={!hasActiveFilters(props.filters)}
>
Clear filters
</button>
</div>
{/* Animating grid rows from 0fr to 1fr collapses to the content's real height. */}
<div
id={bodyId}
class="grid transition-[grid-template-rows] duration-200 motion-reduce:transition-none"
classList={{ 'grid-rows-[1fr]': isOpen(), 'grid-rows-[0fr]': !isOpen() }}
inert={!isOpen()}
>
<div class="min-h-0 overflow-hidden">
<div class="border-t border-line-soft">
<div class="grid grid-cols-1 items-start gap-x-8 gap-y-6 p-4 sm:p-5 md:grid-cols-2">
<div class="flex min-w-0 flex-col gap-5">
<div>
<label class={fieldLabel} for={titleId}>
Title
</label>
<input
id={titleId}
type="search"
class={`${input} px-3 py-2.5 text-base`}
placeholder="Search opportunity titles"
value={props.filters.title}
onInput={(e) => update({ title: e.currentTarget.value })}
/>
</div>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class={fieldLabel}>Status</legend>
<div class="flex flex-wrap gap-1.5">
<For each={props.statuses}>
{(status) => (
<button
type="button"
class={toggleChip}
aria-pressed={props.filters.statuses.has(status)}
onClick={() =>
update({ statuses: toggled(props.filters.statuses, status) })
}
>
{statusLabel(status)}
</button>
)}
</For>
</div>
</fieldset>
<fieldset class="m-0 min-w-0 border-0 p-0">
<legend class={fieldLabel}>Marks</legend>
<div class="flex flex-wrap gap-1.5">
<For each={[...OPPORTUNITY_MARKS, NOT_MARKED] as MarkFilter[]}>
{(mark) => (
<button
type="button"
class={toggleChip}
aria-pressed={props.filters.marks.has(mark)}
onClick={() => update({ marks: toggled(props.filters.marks, mark) })}
>
<Show
when={mark !== NOT_MARKED}
fallback={
<span
class="size-2 rounded-full border border-current"
aria-hidden="true"
/>
}
>
<span
class={`size-2 rounded-full ${MARK_BG[mark as OpportunityMark]}`}
aria-hidden="true"
/>
</Show>
{mark === NOT_MARKED ? 'Not marked' : MARK_LABELS[mark]}
</button>
)}
</For>
</div>
</fieldset>
</div>
<ClassificationPicker
classifications={props.classifications}
isLoading={props.isLoadingClassifications}
error={props.classificationsError}
onRetry={props.onRetryClassifications}
selected={props.filters.classificationIds}
onChange={(classificationIds) => update({ classificationIds })}
/>
</div>
<div class="border-t border-line-soft px-4 py-3 sm:px-5">
<label class="inline-flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={props.filters.showHidden}
onChange={(e) => update({ showHidden: e.currentTarget.checked })}
/>
Show hidden opportunities
</label>
</div>
</div>
</div>
</div>
</section>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { JSX } from 'solid-js';
import { createEffect, createSignal, createUniqueId, For, onCleanup, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import { MARK_BG } from './styles';
interface Props {
marks: OpportunityMark[];
onToggle: (mark: OpportunityMark) => void;
/** Used in the button's accessible name. */
opportunityTitle: string;
}
const MENU_WIDTH = 200;
const GAP = 4;
/**
* A dropdown of mark checkboxes. Each mark toggles on its own and the menu
* stays open, so several can be set in one go. The menu is portalled to
* <body> with fixed positioning so the table's scroll container can't clip it.
*/
export function MarkMenu(props: Props): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false);
const [pos, setPos] = createSignal({ top: 0, left: 0, isAbove: false });
const menuId = createUniqueId();
let button!: HTMLButtonElement;
let menu: HTMLDivElement | undefined;
const place = () => {
const rect = button.getBoundingClientRect();
const height = menu?.offsetHeight ?? 220;
const isAbove = rect.bottom + GAP + height > window.innerHeight && rect.top - GAP - height > 0;
const left = Math.min(Math.max(8, rect.right - MENU_WIDTH), window.innerWidth - MENU_WIDTH - 8);
setPos({ top: isAbove ? rect.top - GAP - height : rect.bottom + GAP, left, isAbove });
};
const close = (shouldRefocus = false) => {
setIsOpen(false);
if (shouldRefocus) button.focus();
};
createEffect(() => {
if (!isOpen()) return;
place();
requestAnimationFrame(() => {
place(); // re-measure now the menu has a real height
menu?.querySelector<HTMLInputElement>('input')?.focus();
});
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (!button.contains(target) && !menu?.contains(target)) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(true);
};
const onScroll = (e: Event) => {
if (!menu?.contains(e.target as Node)) close();
};
document.addEventListener('pointerdown', onPointer);
document.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', place);
onCleanup(() => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', place);
});
});
const summary = (): string =>
props.marks.length === 0
? 'not marked'
: props.marks.map((mark) => MARK_LABELS[mark]).join(', ');
return (
<>
<button
ref={button}
type="button"
class="flex min-h-9 w-44 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
aria-haspopup="true"
aria-expanded={isOpen()}
aria-controls={isOpen() ? menuId : undefined}
aria-label={`Marks for ${props.opportunityTitle}: ${summary()}`}
onClick={() => setIsOpen(!isOpen())}
>
<span class="flex min-w-0 flex-1 flex-wrap gap-1">
<Show when={props.marks.length > 0} fallback={<span class="text-muted">Not marked</span>}>
<For each={props.marks}>
{(mark) => (
<span class="inline-flex items-center gap-1 rounded bg-paper px-1.5 text-xs leading-5 whitespace-nowrap text-ink">
<span class={`size-2 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</span>
)}
</For>
</Show>
</span>
<svg
viewBox="0 0 16 16"
class="size-3.5 shrink-0 text-muted"
fill="currentColor"
aria-hidden="true"
>
<path d="M3.5 6 8 10.5 12.5 6z" />
</svg>
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={menu}
id={menuId}
role="group"
aria-label={`Marks for ${props.opportunityTitle}`}
class="fixed z-50 rounded-lg border border-line bg-surface p-1 text-ink shadow-[0_8px_24px_rgb(0_0_0/0.18)]"
style={{ top: `${pos().top}px`, left: `${pos().left}px`, width: `${MENU_WIDTH}px` }}
>
<For each={OPPORTUNITY_MARKS}>
{(mark) => (
<label class="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-paper">
<input
type="checkbox"
checked={props.marks.includes(mark)}
onChange={() => props.onToggle(mark)}
/>
<span class={`size-2.5 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</label>
)}
</For>
</div>
</Portal>
</Show>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import { statusLabel } from '@/lib/filterOpportunities';
import { formatDate, formatMoney } from '@/lib/format';
import type { OpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import type { Opportunity } from '@/types/Opportunities';
import { MarkMenu } from './MarkMenu';
import { buttonQuiet, MARK_BG, statusBadge } from './styles';
interface Props {
opportunities: Opportunity[];
annotations: OpportunityAnnotations;
}
const th =
'sticky top-0 z-10 whitespace-nowrap border-b border-line bg-surface p-3 text-left text-sm font-semibold text-muted';
const td = 'border-b border-line-soft p-3 align-middle';
/** The table of opportunities, each with its marks and a hide button. */
export function OpportunityTable(props: Props): JSX.Element {
return (
<div class="overflow-x-auto rounded-[10px] border border-line bg-surface">
<table class="w-full border-separate border-spacing-0 tabular-nums">
<thead>
<tr>
<th scope="col" class={`${th} min-w-80`}>
Title
</th>
<th scope="col" class={th}>
Status
</th>
<th scope="col" class={`${th} text-center`}>
SME suitable
</th>
<th scope="col" class={th}>
Published
</th>
<th scope="col" class={th}>
Start
</th>
<th scope="col" class={th}>
End
</th>
<th scope="col" class={`${th} text-right`}>
Value
</th>
<th scope="col" class={th}>
Mark
</th>
<th scope="col" class={`${th} text-center`}>
Hide
</th>
</tr>
</thead>
<tbody class="[&>tr:last-child>td]:border-b-0">
<For each={props.opportunities}>
{(opportunity) => (
<OpportunityRow opportunity={opportunity} annotations={props.annotations} />
)}
</For>
</tbody>
</table>
</div>
);
}
interface RowProps {
opportunity: Opportunity;
annotations: OpportunityAnnotations;
}
function OpportunityRow(props: RowProps): JSX.Element {
const opportunity = (): Opportunity => props.opportunity;
const annotation = () => props.annotations.get(opportunity().uuid);
const marks = () => annotation().marks;
const hidden = () => annotation().hidden;
return (
<tr
classList={{
'text-muted': hidden() || marks().includes('skipped'),
'bg-[repeating-linear-gradient(-45deg,transparent_0_8px,var(--color-paper)_8px_9px)]':
hidden(),
'opacity-70': props.annotations.isSaving(opportunity().uuid),
}}
aria-busy={props.annotations.isSaving(opportunity().uuid) || undefined}
>
{/* The marks colour the leading edge of each row, one segment per
mark, so a scan down the left side shows where every opportunity stands. */}
<td class={`${td} relative max-w-[520px] pl-[18px]`}>
<span class="absolute inset-y-0 left-0 flex w-1 flex-col" aria-hidden="true">
<For each={marks()}>{(m) => <span class={`flex-1 ${MARK_BG[m]}`} />}</For>
</span>
<span class="block font-semibold" title={opportunity().description || undefined}>
{opportunity().title || 'Untitled opportunity'}
</span>
<span class="mt-0.5 block text-sm text-muted">{opportunity().buyer_name}</span>
</td>
<td class={td}>
<span
class={`inline-block rounded px-2 py-0.5 text-sm font-semibold whitespace-nowrap ${statusBadge(opportunity().status)}`}
>
{statusLabel(opportunity().status)}
</span>
</td>
<td class={`${td} text-center`}>
{opportunity().sme_suitable ? (
<span class="font-semibold text-mark-won">Yes</span>
) : (
<span class="text-muted">No</span>
)}
</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().latest_release_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().start_date)}</td>
<td class={`${td} whitespace-nowrap`}>{formatDate(opportunity().end_date)}</td>
<td class={`${td} text-right whitespace-nowrap`}>{formatMoney(opportunity().gross_value)}</td>
<td class={td}>
<MarkMenu
marks={marks()}
opportunityTitle={opportunity().title}
onToggle={(mark) => props.annotations.toggleMark(opportunity().uuid, mark)}
/>
</td>
<td class={`${td} text-center`}>
<button
type="button"
class={`${buttonQuiet} px-2.5 py-1 text-sm text-ink`}
aria-pressed={hidden()}
aria-label={`${hidden() ? 'Show again' : 'Hide'}: ${opportunity().title}`}
onClick={() => props.annotations.setHidden(opportunity().uuid, !hidden())}
>
{hidden() ? 'Unhide' : 'Hide'}
</button>
</td>
</tr>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { JSX } from 'solid-js';
import { For } from 'solid-js';
import type { Theme } from '@/primitives/createTheme';
interface Props {
theme: Theme;
onChange: (theme: Theme) => void;
}
const OPTIONS: { value: Theme; label: string; icon: string }[] = [
{
value: 'light',
label: 'Light',
icon: 'M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z',
},
{
value: 'dark',
label: 'Dark',
icon: 'M20.35 14.35A8.5 8.5 0 0 1 9.65 3.65 8.5 8.5 0 1 0 20.35 14.35Z',
},
];
/** A pair of buttons choosing the light or dark theme. */
export function ThemeToggle(props: Props): JSX.Element {
return (
<div
class="inline-flex rounded-full border border-line bg-surface p-0.5"
role="group"
aria-label="Colour theme"
>
<For each={OPTIONS}>
{(option) => (
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm text-muted hover:text-ink aria-pressed:bg-ink aria-pressed:text-surface"
aria-pressed={props.theme === option.value}
onClick={() => props.onChange(option.value)}
>
<svg
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d={option.icon} />
</svg>
{option.label}
</button>
)}
</For>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { OpportunityMark } from '@/types/Annotations';
// Tailwind only generates classes it can see written out in full, so these
// lookups list every class literally rather than building names at runtime.
/** Badge colours for each known status; others get a neutral badge. */
export const STATUS_BADGE: Record<string, string> = {
planning: 'bg-status-planning-soft text-status-planning',
planned: 'bg-status-planning-soft text-status-planning',
active: 'bg-status-open-soft text-status-open',
complete: 'bg-accent-soft text-accent',
};
/** The badge classes for status. */
export const statusBadge = (status: string): string => STATUS_BADGE[status] ?? 'bg-paper text-ink';
/** Fill colour for each mark: row stripe segments, menu dots, pills. */
export const MARK_BG: Record<OpportunityMark, string> = {
interested: 'bg-mark-interested',
skipped: 'bg-mark-skipped',
bid_submitted: 'bg-mark-bid-submitted',
lost: 'bg-mark-lost',
won: 'bg-mark-won',
};
export const input =
'w-full rounded-md border border-line bg-surface px-2.5 py-2 placeholder:text-muted disabled:bg-paper';
export const buttonQuiet =
'rounded-md border border-line bg-surface px-3.5 py-1.5 hover:enabled:border-muted disabled:opacity-60 disabled:text-muted';
export const buttonPrimary = 'rounded-md bg-accent px-3.5 py-1.5 font-semibold text-on-accent';
export const linkButton = 'text-accent underline';
export const fieldLabel = 'mb-1.5 block p-0 text-sm font-semibold';