forked from yvv/decision
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
599 lines
21 KiB
Svelte
599 lines
21 KiB
Svelte
<script lang="ts">
|
|
// A version in the panel, tab « Détails »: its text and what changed, then
|
|
// what is not the vote — typos (and the fixes waiting for an answer),
|
|
// translations, objections, history. The vote itself unfolds under the
|
|
// article (VoteFold). Objections and validated typo fixes: ADR 0010.
|
|
import { api, ApiError } from '$lib/api';
|
|
import { condense } from '$lib/diff';
|
|
import { shown, type DocState } from '$lib/doc.svelte';
|
|
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
|
import { inertiaBadge, orderNote, titleOf } from '$lib/labels';
|
|
import { session } from '$lib/session.svelte';
|
|
import { toasts } from '$lib/toast.svelte';
|
|
import { announce } from '$lib/vote';
|
|
import type { DiffPart, Entered, Inertia, Lang, Objection, ObjectionKind, Proposal, TypoFix } from '$lib/types';
|
|
import DiffText from './DiffText.svelte';
|
|
import Prose from './Prose.svelte';
|
|
|
|
let {
|
|
doc,
|
|
proposalId,
|
|
lang,
|
|
edit = false,
|
|
onvote,
|
|
onpropose,
|
|
onanswer,
|
|
}: {
|
|
doc: DocState;
|
|
proposalId: string;
|
|
lang: Lang;
|
|
/** Opened to reword one's own version (an answer to an objection). */
|
|
edit?: boolean;
|
|
/** Unfolds the vote of this version under its article. */
|
|
onvote: (proposalId: string) => void;
|
|
onpropose: (prefill: { title: string; body: string }) => void;
|
|
/** Answers an objection holding a version back: modify the article repeated, or reword. */
|
|
onanswer: (objection: Objection) => void;
|
|
} = $props();
|
|
|
|
interface HistoryItem {
|
|
revision: number;
|
|
kind: 'initial' | 'typo' | 'substantive' | 'amendment' | 'revert';
|
|
author: string | null;
|
|
authorInvited: boolean;
|
|
at: string;
|
|
diff: DiffPart[] | null;
|
|
/** A fix someone else wrote: who let it in, or that it passed at its deadline. */
|
|
typo: { id: string; answeredBy: string | null; deadline: boolean } | null;
|
|
}
|
|
|
|
interface Detail {
|
|
proposal: Proposal & { author: string | null; authorInvited: boolean };
|
|
unit: { id: string; code: string; inertia: Inertia };
|
|
inForce: { id: string; title: string; body: string } | null;
|
|
diff: DiffPart[] | null;
|
|
history: HistoryItem[];
|
|
objections: Objection[];
|
|
typos: TypoFix[];
|
|
}
|
|
|
|
let detail = $state<Detail | null>(null);
|
|
let busy = $state(false);
|
|
let showDiff = $state(false);
|
|
let error = $state('');
|
|
let typoOpen = $state(false);
|
|
let typoTitle = $state('');
|
|
let typoBody = $state('');
|
|
let typoSubstantive = $state(false);
|
|
let trTitle = $state('');
|
|
let trBody = $state('');
|
|
let objectionKind = $state<ObjectionKind>('contredit');
|
|
let objectionTarget = $state('');
|
|
let objectionReason = $state('');
|
|
|
|
const view = $derived(doc.view);
|
|
const unit = $derived(doc.unit(detail?.unit.id ?? null));
|
|
const p = $derived(detail?.proposal);
|
|
const source = $derived(view?.doc.sourceLang ?? 'fr');
|
|
const text = $derived(p ? shown(p, lang, source) : null);
|
|
/** Open or in force: fixed, translated, objected to — whether held back or not. */
|
|
const live = $derived(p?.status === 'open' || p?.status === 'in_force');
|
|
/** The versions an objection may name: another article's, open or in force — never two texts in force. */
|
|
const objectionTargets = $derived(
|
|
(view?.units ?? []).flatMap((u) =>
|
|
[u.inForce, ...u.proposals]
|
|
.filter((x): x is Proposal => !!x && x.id !== p?.id && (x.status === 'open' || x.status === 'in_force') && x.kind === 'text')
|
|
.filter((x) => u.id !== unit?.id && !(x.status === 'in_force' && p?.status === 'in_force'))
|
|
.map((x) => ({ id: x.id, label: `${x.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')} — ${x.title}` })),
|
|
),
|
|
);
|
|
|
|
async function load() {
|
|
try {
|
|
detail = await api.get<Detail>(`/proposals/${proposalId}`);
|
|
error = '';
|
|
const d = detail;
|
|
typoTitle = d.proposal.title;
|
|
typoBody = d.proposal.body;
|
|
typoSubstantive = false;
|
|
if (edit && d.proposal.mine && d.proposal.status === 'open') typoOpen = true;
|
|
const tr = d.proposal.translations[lang];
|
|
trTitle = tr?.title ?? d.proposal.title;
|
|
trBody = tr?.body ?? d.proposal.body;
|
|
} catch (e) {
|
|
error = message(e);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
void proposalId;
|
|
void lang;
|
|
void edit;
|
|
void load();
|
|
});
|
|
|
|
function message(e: unknown): string {
|
|
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
|
|
}
|
|
|
|
async function refresh() {
|
|
await doc.reload();
|
|
await load();
|
|
void session.refreshUnread();
|
|
}
|
|
|
|
async function run(fn: () => Promise<void>) {
|
|
busy = true;
|
|
error = '';
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
error = message(err);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function saveTypo(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
busy = true;
|
|
error = '';
|
|
typoSubstantive = false;
|
|
try {
|
|
const r = await api.patch<{ edit: string; typo?: { id: string; dueAt: string; answerer: 'author' | 'admin' } }>(
|
|
`/proposals/${proposalId}`,
|
|
{ title: typoTitle, body: typoBody },
|
|
);
|
|
if (r.edit === 'typo_pending' && r.typo) {
|
|
toasts.show(t(r.typo.answerer === 'admin' ? 'typo.pending.admin' : 'typo.pending.author', { date: i18n.date(r.typo.dueAt) }), 'info', 7000);
|
|
} else {
|
|
toasts.show(t(r.edit === 'substantive' ? 'propose.saved.substantive' : r.edit === 'typo' ? 'typo.done' : 'common.save'));
|
|
}
|
|
typoOpen = false;
|
|
await refresh();
|
|
} catch (err) {
|
|
if (err instanceof ApiError && (err.code === 'substantive_in_force' || err.code === 'substantive_not_author')) {
|
|
typoSubstantive = true;
|
|
} else {
|
|
error = message(err);
|
|
}
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function answerTypo(fix: TypoFix, gesture: 'validate' | 'refuse') {
|
|
await run(async () => {
|
|
await api.post(`/typos/${fix.id}/${gesture}`);
|
|
toasts.show(t(gesture === 'validate' ? 'typo.validated' : 'typo.refused'));
|
|
await refresh();
|
|
});
|
|
}
|
|
|
|
async function saveTranslation(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
await run(async () => {
|
|
await api.put(`/proposals/${proposalId}/translations/${lang}`, { title: trTitle, body: trBody });
|
|
toasts.show(t('translate.saved'));
|
|
await refresh();
|
|
});
|
|
}
|
|
|
|
async function raise(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
await run(async () => {
|
|
await api.post(`/proposals/${proposalId}/objections`, { otherId: objectionTarget, kind: objectionKind, reason: objectionReason });
|
|
objectionTarget = '';
|
|
objectionReason = '';
|
|
toasts.show(t('objection.sent'), 'info', 6000);
|
|
await refresh();
|
|
});
|
|
}
|
|
|
|
async function settle(o: Objection, gesture: 'lift' | 'reiterate') {
|
|
await run(async () => {
|
|
const r = await api.post<{ entered?: Entered[] }>(`/objections/${o.id}/${gesture}`);
|
|
toasts.show(t(gesture === 'lift' ? 'objection.done.lifted' : 'objection.done.reiterated'));
|
|
await refresh();
|
|
announce(doc, r.entered ?? []);
|
|
});
|
|
}
|
|
|
|
async function withdraw() {
|
|
await run(async () => {
|
|
await api.post(`/proposals/${proposalId}/withdraw`);
|
|
toasts.show(t('propose.withdrawn'));
|
|
await doc.reload();
|
|
});
|
|
}
|
|
|
|
/** Where an objection stands, in a few words. */
|
|
function stateOf(o: Objection): string {
|
|
if (o.phase === 'lifted') return t(`objection.lifted.${o.lift ?? 'objector'}` as Key);
|
|
if (o.phase === 'objector') return t('objection.phase.objector', { date: i18n.date(o.dueAt) });
|
|
return t('objection.phase.author');
|
|
}
|
|
|
|
/** What became of a fix, or when it applies by itself. */
|
|
function typoState(fix: TypoFix): string {
|
|
if (fix.status === 'pending') return t('typo.status.pending', { date: i18n.date(fix.dueAt) });
|
|
if (fix.status === 'stale') return t('typo.status.stale');
|
|
const what = t(`typo.status.${fix.status}`);
|
|
if (fix.settledBy === 'deadline') return `${what} · ${t('typo.settled.deadline')}`;
|
|
if (fix.settledBy && fix.settledBy !== 'changed') return `${what} · ${t('typo.settled.by', { name: fix.settledBy })}`;
|
|
return what;
|
|
}
|
|
|
|
function typoGesture(fix: TypoFix, g: 'validate' | 'refuse'): string {
|
|
if (fix.status === 'applied') return t('typo.undo');
|
|
if (fix.status === 'refused') return t('typo.redo');
|
|
return t(g === 'validate' ? 'typo.validate' : 'typo.refuse');
|
|
}
|
|
</script>
|
|
|
|
{#if detail && p && text && view}
|
|
<article class="details">
|
|
<header>
|
|
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
|
|
<h3>{p.kind === 'text' || p.kind === 'title' ? text.title : titleOf(p, unit)}</h3>
|
|
<p class="meta faint">
|
|
{#if p.seedMinimal}<span class="tag water" title={t('doc.seed.long')}>{t('doc.seed')}</span>
|
|
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
|
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
|
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
|
|
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
|
|
</p>
|
|
</header>
|
|
|
|
{#if p.kind === 'text'}
|
|
{#if detail.diff && showDiff}
|
|
<DiffText parts={detail.diff} />
|
|
{:else}
|
|
<Prose text={text.body} />
|
|
{/if}
|
|
{#if detail.diff}
|
|
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
|
|
{/if}
|
|
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
|
|
{:else if p.kind === 'remove' && detail.inForce}
|
|
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
|
{:else if p.kind === 'order'}
|
|
{@const order = doc.proposal(p.id)?.order}
|
|
<ol class="order" aria-label={t(p.status === 'in_force' ? 'module.order.inforce' : 'module.order.proposal')}>
|
|
{#each order?.ids ?? [] as id (id)}<li>{doc.articleTitle(id, lang, source)}</li>{/each}
|
|
</ol>
|
|
{#each orderNote({ ...p, order }) as note (note)}<p class="faint note">{note}</p>{/each}
|
|
<p class="faint note">{t('module.order.rule')}</p>
|
|
{:else if p.kind === 'title'}
|
|
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
|
{/if}
|
|
|
|
{#if live}
|
|
<p class="tally faint">
|
|
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
|
|
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
|
|
</p>
|
|
{/if}
|
|
|
|
{#if (p.kind === 'text' || p.kind === 'title') && live}
|
|
<section class="sub">
|
|
<details bind:open={typoOpen}>
|
|
<summary>{p.mine && p.status === 'open' ? t('propose.edit') : t('typo.title')}</summary>
|
|
<form class="form" onsubmit={saveTypo}>
|
|
<p class="faint small">{p.mine && p.status === 'open' ? t('propose.rule') : t('typo.note')}</p>
|
|
<input class="input" bind:value={typoTitle} maxlength="120" aria-label={t('propose.title')} />
|
|
{#if p.kind === 'text'}<textarea class="textarea" bind:value={typoBody} rows="6" aria-label={t('propose.body')}></textarea>{/if}
|
|
{#if typoSubstantive}
|
|
<p class="warn">{t('typo.substantive')}</p>
|
|
{#if p.kind === 'text'}<button class="btn small" type="button" onclick={() => onpropose({ title: typoTitle, body: typoBody })}>{t('typo.asProposal')}</button>{/if}
|
|
{/if}
|
|
<button class="btn small primary" type="submit" disabled={busy}>{t('typo.save')}</button>
|
|
</form>
|
|
</details>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if detail.typos.length}
|
|
<section class="sub" aria-labelledby="typos-{p.id}">
|
|
<h4 id="typos-{p.id}">{t('typo.list')}</h4>
|
|
<ul class="fixes">
|
|
{#each detail.typos as fix (fix.id)}
|
|
<li class="fix {fix.status}">
|
|
<DiffText parts={condense(fix.diff)} />
|
|
<p class="faint small">
|
|
{t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)}
|
|
</p>
|
|
<p class="small state">{typoState(fix)}</p>
|
|
{#if fix.gestures.length}
|
|
<div class="gestures">
|
|
{#each fix.gestures as g (g)}
|
|
<button class="btn small" class:primary={g === 'validate' && fix.status === 'pending'} type="button" disabled={busy} onclick={() => answerTypo(fix, g)}>{typoGesture(fix, g)}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if lang !== source && (p.kind === 'text' || p.kind === 'title')}
|
|
<section class="sub">
|
|
<details>
|
|
<summary>{t('translate.title', { lang: lang.toUpperCase() })}</summary>
|
|
<form class="form" onsubmit={saveTranslation}>
|
|
<p class="faint small">{t('translate.note')}</p>
|
|
<input class="input" bind:value={trTitle} maxlength="120" aria-label={t('propose.title')} />
|
|
{#if p.kind === 'text'}<textarea class="textarea" bind:value={trBody} rows="6" aria-label={t('propose.body')}></textarea>{/if}
|
|
<button class="btn small" type="submit" disabled={busy}>{t('translate.save')}</button>
|
|
</form>
|
|
</details>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if detail.history.length > 1}
|
|
<section class="sub">
|
|
<h4>{t('history.title')}</h4>
|
|
<ol class="history">
|
|
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
|
<li>
|
|
<p class="small">
|
|
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}
|
|
{#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if}
|
|
</p>
|
|
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if p.kind === 'text' && (live || detail.objections.length)}
|
|
<section class="sub" aria-labelledby="objections-{p.id}">
|
|
<h4 id="objections-{p.id}">{t('objection.title')}</h4>
|
|
{#each detail.objections as o (o.id)}
|
|
{@const standing = o.phase !== 'lifted'}
|
|
<div class="objection" class:lifted={!standing}>
|
|
<p class="small">
|
|
<span class="tag" class:ochre={standing}>{t(`objection.kind.${o.kind}`)}</span>
|
|
<b>{t(`objection.says.${o.kind}`, { held: o.heldVersion?.title ?? '', other: o.other.title ?? '' })}</b>
|
|
</p>
|
|
<p class="reason">{o.reason}</p>
|
|
<p class="faint small">
|
|
{t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)}
|
|
· {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if}
|
|
</p>
|
|
{#if standing && o.held}<p class="small held">{t('objection.held.here')}</p>{/if}
|
|
{#if standing}
|
|
<div class="gestures">
|
|
{#if o.iAnswer && !o.mine && o.phase === 'author'}
|
|
<button class="btn small primary" type="button" onclick={() => onanswer(o)}>{t(o.kind === 'redit' ? 'objection.respond.redit' : 'objection.respond.contredit')}</button>
|
|
{/if}
|
|
{#if o.canLift}
|
|
<button class="btn small" type="button" disabled={busy} onclick={() => settle(o, 'lift')}>{t(o.mine ? 'objection.lift' : 'objection.lift.admin')}</button>
|
|
{/if}
|
|
{#if o.canReiterate}
|
|
<button class="btn small quiet" type="button" disabled={busy} onclick={() => settle(o, 'reiterate')}>{t('objection.reiterate')}</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<p class="faint small">{t('objection.none')}</p>
|
|
{/each}
|
|
{#if live && objectionTargets.length}
|
|
<details>
|
|
<summary>{t('objection.add')}</summary>
|
|
<form class="form" onsubmit={raise}>
|
|
<p class="faint small">{t('objection.note')}</p>
|
|
<div class="kinds" role="radiogroup" aria-label={t('objection.kind')}>
|
|
{#each ['redit', 'contredit'] as const as k (k)}
|
|
<label class:on={objectionKind === k}>
|
|
<input type="radio" bind:group={objectionKind} value={k} />
|
|
<b>{t(`objection.kind.${k}`)}</b><span class="hint">{t(`objection.kind.${k}.hint`)}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
<select class="select" bind:value={objectionTarget} required aria-label={t('objection.target')}>
|
|
<option value="" disabled>{t('objection.target')}</option>
|
|
{#each objectionTargets as target (target.id)}<option value={target.id}>{target.label}</option>{/each}
|
|
</select>
|
|
<textarea
|
|
class="textarea short"
|
|
bind:value={objectionReason}
|
|
rows="3"
|
|
minlength="10"
|
|
maxlength="600"
|
|
placeholder={t(`objection.reason.${objectionKind}`)}
|
|
aria-label={t(`objection.reason.${objectionKind}`)}
|
|
></textarea>
|
|
<button class="btn small" type="submit" disabled={busy || !objectionTarget || objectionReason.trim().length < 10}>{t('objection.send')}</button>
|
|
</form>
|
|
</details>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
{#if p.mine && p.status === 'open'}
|
|
<button class="btn quiet small" type="button" disabled={busy} onclick={withdraw}>{t('propose.withdraw')}</button>
|
|
{/if}
|
|
|
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
|
</article>
|
|
{:else if error}
|
|
<p class="warn" role="alert">{error}</p>
|
|
{:else}
|
|
<p class="faint">{t('common.loading')}</p>
|
|
{/if}
|
|
|
|
<style>
|
|
.details {
|
|
display: grid;
|
|
gap: 1.1rem;
|
|
}
|
|
header {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
}
|
|
h3 {
|
|
font-family: var(--font-text);
|
|
font-weight: 500;
|
|
font-size: 1.4rem;
|
|
}
|
|
h4 {
|
|
font-size: 0.92rem;
|
|
font-weight: 800;
|
|
}
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
font-size: 0.85rem;
|
|
}
|
|
.note,
|
|
.small {
|
|
font-size: 0.84rem;
|
|
}
|
|
.removed {
|
|
opacity: 0.6;
|
|
}
|
|
.order {
|
|
margin: 0;
|
|
padding-left: 1.6rem;
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
font-family: var(--font-text);
|
|
}
|
|
.tally {
|
|
font-size: 0.88rem;
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.linkish {
|
|
border: 0;
|
|
background: none;
|
|
padding: 0;
|
|
color: var(--water);
|
|
font-weight: 750;
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
}
|
|
.warn {
|
|
color: var(--ochre);
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
}
|
|
.sub {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
}
|
|
details > summary {
|
|
cursor: pointer;
|
|
font-weight: 750;
|
|
color: var(--water);
|
|
font-size: 0.92rem;
|
|
}
|
|
.form {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
margin-top: 0.6rem;
|
|
}
|
|
.form .btn {
|
|
justify-self: start;
|
|
}
|
|
.textarea.short {
|
|
min-height: 5rem;
|
|
}
|
|
.history {
|
|
margin: 0;
|
|
padding-left: 1rem;
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
}
|
|
.fixes {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
}
|
|
.fix,
|
|
.objection {
|
|
padding: 0.6rem 0.8rem;
|
|
border-radius: var(--r-input);
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
}
|
|
.fix {
|
|
background: var(--water-wash);
|
|
}
|
|
.fix.pending .state {
|
|
font-weight: 750;
|
|
color: var(--water);
|
|
}
|
|
.fix.refused,
|
|
.fix.stale {
|
|
background: var(--surface-2);
|
|
opacity: 0.8;
|
|
}
|
|
.objection {
|
|
background: var(--ochre-wash);
|
|
}
|
|
.objection.lifted {
|
|
background: var(--surface-2);
|
|
opacity: 0.75;
|
|
}
|
|
.reason {
|
|
font-family: var(--font-text);
|
|
font-style: italic;
|
|
color: var(--ink-soft);
|
|
font-size: 0.92rem;
|
|
}
|
|
.held {
|
|
font-weight: 750;
|
|
color: var(--ochre);
|
|
}
|
|
.gestures {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
margin-top: 0.2rem;
|
|
}
|
|
.kinds {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
|
gap: 0.4rem;
|
|
}
|
|
.kinds label {
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
padding: 0.55rem 0.8rem;
|
|
border-radius: var(--r-input);
|
|
background: var(--surface-2);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.kinds label.on {
|
|
background: var(--ochre);
|
|
color: var(--on-water);
|
|
}
|
|
.kinds label.on .hint {
|
|
color: inherit;
|
|
opacity: 0.85;
|
|
}
|
|
.kinds input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.kinds label:has(input:focus-visible) {
|
|
outline: 2px solid var(--water);
|
|
outline-offset: 2px;
|
|
}
|
|
.hint {
|
|
font-size: 0.8rem;
|
|
color: var(--ink-soft);
|
|
}
|
|
</style>
|