Expand moderation case workflows
CI / checks (push) Successful in 6m49s

This commit is contained in:
2026-10-17 16:19:33 -04:00
parent 4501eba873
commit 7f2205e9c4
22 changed files with 2815 additions and 167 deletions
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { authError, requireUser } from '$lib/server/auth/http.js';
import { moderationCaseDetail } from '$lib/server/moderation/cases.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ locals, params }) => {
try {
return json(moderationCaseDetail(requireUser(locals.user).id, params.id), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return authError(cause);
}
};
+109 -53
View File
@@ -5,6 +5,8 @@
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationOverview,
type ModerationQuery,
type ModerationGame
@@ -14,7 +16,8 @@
import {
moderationActionLabels,
moderationGameName,
moderationEventDetail
moderationEventDetail,
moderationCaseHref
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
@@ -52,9 +55,7 @@
let generation = 0;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
>('restriction');
let mode = $state<'restriction' | 'action' | 'case' | 'note' | 'revoke'>('restriction');
let seed = $state<{
game?: ModerationGame;
guid?: string;
@@ -160,7 +161,15 @@
dialogKey++;
dialogOpen = true;
}
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
function submitted(target: { game?: ModerationGame; guid?: string | null; id?: string }) {
if (mode === 'case' && target.id) {
cancelRefresh();
void goto(moderationCaseHref(target.id), { invalidateAll: true }).catch(() => {
navigationError = 'Case saved. Open it from the case list or refresh to try again.';
void refresh();
});
return;
}
notice = 'Saved. Enforcement and delivery status are shown below.';
cancelRefresh();
const view =
@@ -320,7 +329,15 @@
class="filters"
onsubmit={(event) => {
event.preventDefault();
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
void navigate({
q: filters.q,
game: filters.game,
guid: filters.guid,
caseStatus: filters.caseStatus ?? null,
priority: filters.priority ?? null,
assignedTo: filters.assignedTo ?? null,
offset: null
});
}}
>
<Field label="Search moderation" class="search"
@@ -343,6 +360,47 @@
/></Field
>
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
{#if report.query.view === 'cases'}
<Field label="Case status"
><Select
name="caseStatus"
bind:value={filters.caseStatus}
options={[
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active cases' },
{ value: 'closed', label: 'Closed cases' },
...moderationCaseStatuses.map((status) => ({
value: status,
label: moderationCaseStatusLabels[status]
}))
]}
/></Field
>
<Field label="Priority"
><Select
name="priority"
bind:value={filters.priority}
options={[
{ value: '', label: 'All priorities' },
{ value: 'urgent', label: 'Urgent' },
{ value: 'high', label: 'High' },
{ value: 'normal', label: 'Normal' },
{ value: 'low', label: 'Low' }
]}
/></Field
>
<Field label="Assignment"
><Select
name="assignedTo"
bind:value={filters.assignedTo}
options={[
{ value: '', label: 'Anyone' },
{ value: 'me', label: 'Assigned to me' },
{ value: 'unassigned', label: 'Unassigned' }
]}
/></Field
>
{/if}
<Button type="submit" disabled={busy}>Apply</Button>
<Button
disabled={busy}
@@ -354,7 +412,10 @@
offset: null,
server: null,
message: null,
matchId: null
matchId: null,
caseStatus: null,
priority: null,
assignedTo: null
})}>Reset</Button
>
</form>
@@ -507,6 +568,9 @@
</dd>
</div>
</dl>
{#if sanction.caseId}<a href={moderationCaseHref(sanction.caseId)}
>View linked case</a
>{/if}
{#if sanction.note}<p class="private-note">
<strong>Private note</strong>
{sanction.note}
@@ -536,47 +600,46 @@
{#if report.cases.length}<div class="record-list">
{#each report.cases as item (item.id)}<article class="record">
<div class="record-heading">
<h2>{item.title}</h2>
<h2><a href={moderationCaseHref(item.id)}>{item.title}</a></h2>
<StatusBadge state={`moderation-${item.status}`} />
</div>
<p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a
>
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
value={item.createdAt}
/>
· {moderationGameName(item.game)}
</p>
<details>
<summary>Evidence and staff notes ({item.evidence.length})</summary>
<ol class="evidence-list">
{#each item.evidence as evidence (evidence.id)}<li>
<p>{evidence.text}</p>
<small
>{evidence.source} · {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/></small
>
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
</ol>
</details>
<dl>
<div>
<dt>Priority</dt>
<dd>
{item.priority.charAt(0).toUpperCase() +
item.priority.slice(1)}
</dd>
</div>
<div>
<dt>Assigned to</dt>
<dd>{item.assignee ?? 'Unassigned'}</dd>
</div>
<div>
<dt>Updated</dt>
<dd><RelativeTime value={item.updatedAt} /></dd>
</div>
<div>
<dt>Opened by</dt>
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
</div>
</dl>
<div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
class="row-actions"
>
<Button
size="sm"
onclick={() => openDialog('case-note', { id: item.id })}
>Add case note</Button
>{#if item.status === 'open'}<Button
size="sm"
onclick={() =>
openDialog('resolve', {
id: item.id,
revision: item.revision
})}>Review case</Button
>{/if}
</div>{/if}
<span class="hint"
>{item.evidence.length} evidence and staff note {item.evidence
.length === 1
? 'entry'
: 'entries'}</span
>
<Button href={moderationCaseHref(item.id)} size="sm"
>View case</Button
>
</div>
</article>{/each}
</div>{:else}<EmptyState
@@ -628,10 +691,12 @@
: ''}{event.game
? ` · ${moderationGameName(event.game)}`
: ''}</small
>{#if event.subjectId}<CopyValue
value={event.subjectId}
label="Record ID"
/>{/if}
>{#if event.subjectId}
{#if event.action.startsWith('case.')}<a
href={moderationCaseHref(event.subjectId)}>View case</a
>{/if}
<CopyValue value={event.subjectId} label="Record ID" />
{/if}
</li>{/each}
</ol>{:else}<EmptyState
title="No history found"
@@ -788,15 +853,6 @@
.private-note {
font-size: var(--text-sm);
}
.evidence-list {
padding-left: var(--space-5);
}
.evidence-list li {
margin-top: var(--space-4);
}
.evidence-list small {
font-size: var(--text-xs);
}
.audit-list {
list-style: none;
padding: 0;
@@ -0,0 +1,15 @@
import { error } from '@sveltejs/kit';
import { AuthError } from '$lib/server/auth/index.js';
import { requireUser } from '$lib/server/auth/http.js';
import { moderationCaseDetail } from '$lib/server/moderation/cases.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals, params, setHeaders }) => {
setHeaders({ 'Cache-Control': 'no-store' });
try {
return { moderationCase: moderationCaseDetail(requireUser(locals.user).id, params.id) };
} catch (cause) {
if (cause instanceof AuthError) error(cause.status, cause.message);
throw cause;
}
};
@@ -0,0 +1,651 @@
<script lang="ts">
import { onMount, untrack, type ComponentProps } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { navigating, page } from '$app/stores';
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
isModerationCaseOpen,
type ModerationCaseDetail
} from '$lib/moderation.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { contextHref } from '$lib/navigation-context.js';
import {
moderationActionLabels,
moderationEventDetail,
moderationGameName,
moderationHref
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
let snapshot = $state<ModerationCaseDetail | null>(null);
const report = $derived(snapshot ?? data.moderationCase);
const item = $derived(report.case);
const active = $derived(isModerationCaseOpen(item.status));
const permitted = $derived(canModerate($page.data?.user?.role));
const admin = $derived($page.data?.user?.role === 'admin');
const busy = $derived(!!$navigating);
const archivedEvidence = $derived(item.evidence.filter((entry) => !!entry.snapshot));
const notes = $derived(item.evidence.filter((entry) => !entry.snapshot));
const coverage = $derived(
report.coverage.filter(
(server) =>
server.game === item.game ||
report.sanctions.some((restriction) => restriction.scope === 'fleet')
)
);
let refreshing = $state(false);
let refreshError = $state('');
let notice = $state('');
let updatedAt = $state<number | null>(null);
let generation = 0;
let controller: AbortController | undefined;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<ComponentProps<typeof ModerationDialog>['mode']>('case-note');
let seed = $state<NonNullable<ComponentProps<typeof ModerationDialog>['seed']>>({});
$effect(() => {
const initial = data.moderationCase;
untrack(() => {
cancelRefresh();
snapshot = null;
refreshError = '';
notice = '';
dialogOpen = false;
updatedAt = Date.parse(initial.checkedAt);
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
async function refresh(signal?: AbortSignal) {
if (busy || refreshing) return;
const current = ++generation;
const request = new AbortController();
controller = request;
const abort = () => request.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) request.abort();
const timeout = setTimeout(abort, 30_000);
refreshing = true;
try {
const response = await fetch(`/api/moderation/cases/${encodeURIComponent(item.id)}`, {
signal: request.signal
});
if (!response.ok) throw new Error('Refresh failed');
const next: ModerationCaseDetail = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation)
refreshError =
'Case updates paused. Showing the last loaded records. Refresh before making another change.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 15_000, false);
return () => {
stop();
cancelRefresh();
};
});
function openDialog(next: typeof mode, target: typeof seed = {}) {
mode = next;
const matchId = Number($page.url.searchParams.get('matchId'));
const message = $page.url.searchParams.get('message');
seed = {
game: item.game,
guid: item.guid,
id: item.id,
revision: item.revision,
title: item.title,
priority: item.priority,
assigneeId: item.assigneeId,
assignee: item.assignee,
status: item.status,
...(next === 'restriction' ? { caseId: item.id, caseRevision: item.revision } : {}),
...(next === 'evidence' && Number.isSafeInteger(matchId) && matchId > 0 && message
? { matchId, message }
: {}),
...target
};
dialogKey++;
dialogOpen = true;
}
function submitted() {
notice =
mode === 'restriction'
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you record an outcome.'
: mode === 'revoke'
? 'Revocation saved. Servers apply it when they receive the latest policy.'
: 'Case updated. The change is recorded in the timeline.';
cancelRefresh();
void refresh();
}
function archiveContext(raw: string | null | undefined) {
if (!raw) return null;
try {
const value = JSON.parse(raw);
if (!value || typeof value !== 'object') return null;
return {
matchId:
Number.isSafeInteger(value.matchId) && value.matchId > 0
? (value.matchId as number)
: null,
server:
typeof value.match?.server === 'string' ? (value.match.server as string) : null,
startedAt:
typeof value.match?.started_at === 'string'
? (value.match.started_at as string)
: null,
player:
typeof value.entry?.message?.player === 'string'
? (value.entry.message.player as string)
: null,
time:
typeof value.entry?.message?.time === 'string'
? (value.entry.message.time as string)
: null,
realTime:
typeof value.entry?.context?.realTime === 'string'
? (value.entry.context.realTime as string)
: null,
round: Number.isInteger(value.entry?.context?.round)
? (value.entry.context.round as number)
: null,
original: JSON.stringify(value, null, 2)
};
} catch {
return null;
}
}
</script>
<svelte:head><title>{item.title} · Moderation · Electric Cherry</title></svelte:head>
<div class="case-page">
<SectionHeader
title={item.title}
eyebrow="Moderation case"
subtitle={`${moderationGameName(item.game)} · ${report.player.name}`}
>
{#snippet crumbs()}<a href="/moderation?view=cases">Moderation cases</a>{/snippet}
{#snippet actions()}
<Freshness
{updatedAt}
intervalLabel="every 15 s"
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
{#if permitted}
{#if active}<Button variant="primary" onclick={() => openDialog('resolve')}
>Review case</Button
>
{:else}<Button variant="primary" onclick={() => openDialog('reopen')}
>Reopen case</Button
>{/if}
{/if}
{/snippet}
</SectionHeader>
{#if !permitted}<p class="notice">
Read-only moderation access. You can inspect this case, its evidence, and linked
restrictions.
</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if refreshError}<div class="ui-error" role="alert">
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
>Retry</Button
>
</div>{/if}
<section class="case-summary panel" aria-label="Case overview">
<div class="row">
<StatusBadge state={`moderation-${item.status}`} />
<Badge
variant={item.priority === 'urgent'
? 'danger'
: item.priority === 'high'
? 'warning'
: 'neutral'}
>{item.priority[0].toUpperCase() + item.priority.slice(1)} priority</Badge
>
<span>{active ? 'Active case' : 'Closed case'}</span>
{#if permitted}<Button size="sm" onclick={() => openDialog('edit-case')}
>Edit case</Button
>{/if}
</div>
<dl class="case-metadata">
<div>
<dt>Assigned to</dt>
<dd>{item.assignee ?? 'Unassigned'}</dd>
</div>
<div>
<dt>Opened by</dt>
<dd>{item.actor} · <RelativeTime value={item.createdAt} /></dd>
</div>
<div>
<dt>Last updated</dt>
<dd><RelativeTime value={item.updatedAt} /></dd>
</div>
</dl>
<CopyValue value={item.id} label="Case ID" />
</section>
<div class="workspace">
<div class="case-main">
<section class="panel" aria-labelledby="evidence-title">
<div class="section-heading">
<h2 id="evidence-title">
Archived evidence <span>({archivedEvidence.length})</span>
</h2>
{#if permitted}<Button size="sm" onclick={() => openDialog('evidence')}
>Attach evidence</Button
>{/if}
</div>
{#if archivedEvidence.length}
<ol class="records">
{#each archivedEvidence as evidence (evidence.id)}
{@const archive = archiveContext(evidence.snapshot)}
<li class="record">
<div class="row">
<strong>{archive?.player ?? 'Archived message'}</strong
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
>Round {archive.round}</Badge
>{/if}
</div>
<blockquote>{evidence.text}</blockquote>
<p class="hint">{evidence.source}</p>
{#if archive}<div class="source-context">
{#if archive.matchId}<a
href={contextHref(
`/matches/${archive.matchId}`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
{#if archive.realTime}<span
>Estimated message time · {$timeFormat.dateTime(
archive.realTime
)}</span
>{/if}
{#if archive.time}<span
>Original log time · {archive.time}</span
>{/if}
{#if archive.startedAt}<span
>Match started · {$timeFormat.dateTime(
archive.startedAt
)}</span
>{/if}
</div>{/if}
<p class="hint">
Preserved by {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/>
</p>
{#if archive}<details>
<summary>Preserved archive snapshot</summary>
<pre>{archive.original}</pre>
</details>{/if}
</li>
{/each}
</ol>
{:else}<EmptyState
title="No archived evidence yet"
description="Attach a verified chat message to preserve its original player, match, and timing context."
/>{/if}
</section>
<section class="panel" aria-labelledby="notes-title">
<div class="section-heading">
<h2 id="notes-title">
Staff notes and decisions <span>({notes.length})</span>
</h2>
{#if permitted}<Button size="sm" onclick={() => openDialog('case-note')}
>Add case note</Button
>{/if}
</div>
{#if notes.length}<ol class="records">
{#each notes as evidence (evidence.id)}<li class="record">
<div class="row">
<strong>{evidence.source}</strong><RelativeTime
value={evidence.createdAt}
/>
</div>
<p class="record-text">{evidence.text}</p>
<p class="hint">{evidence.actor}</p>
</li>{/each}
</ol>{:else}<p class="hint">No staff notes or decisions recorded.</p>{/if}
</section>
<section class="panel" aria-labelledby="timeline-title">
<div class="section-heading">
<h2 id="timeline-title">Case timeline</h2>
<span class="hint">Newest first</span>
</div>
{#if report.events.length}<ol class="records timeline">
{#each report.events as event (event.id)}<li class="record">
<div class="section-heading">
<strong
>{moderationActionLabels[event.action] ??
event.action}</strong
><RelativeTime value={event.createdAt} />
</div>
<p class="record-text">
{moderationEventDetail(event.detail, event.action)}
</p>
<p class="hint">{event.actor}</p>
</li>{/each}
</ol>{:else}<p class="hint">No case activity recorded yet.</p>{/if}
</section>
</div>
<aside class="case-sidebar">
<section class="panel" aria-labelledby="player-title">
<h2 id="player-title">Player identity</h2>
<a class="player-name" href={contextHref(playerProfileHref(item.guid), $page.url)}
>{report.player.name}</a
>
<p class="hint">{moderationGameName(item.game)}</p>
<CopyValue value={item.guid} label="Player GUID" />
<dl>
<div>
<dt>Known aliases</dt>
<dd>{report.player.aliases.join(', ') || 'None recorded'}</dd>
</div>
<div>
<dt>Last seen</dt>
<dd>
<RelativeTime value={report.player.lastSeen} missing="Not recorded" />
</dd>
</div>
<div>
<dt>Active restrictions</dt>
<dd>{report.player.restrictions}</dd>
</div>
</dl>
<a
href={`${moderationHref({ game: item.game, guid: item.guid })}&view=restrictions`}
>View all player restrictions</a
>
</section>
<section class="panel" aria-labelledby="restrictions-title">
<div class="section-heading">
<h2 id="restrictions-title">Linked restrictions</h2>
{#if permitted && active}<Button
size="sm"
variant="danger"
onclick={() => openDialog('restriction')}>Restrict player</Button
>{/if}
</div>
{#if !active && permitted}<p class="hint">
Reopen this case before applying another restriction.
</p>{/if}
{#if report.sanctions.length}<ol class="records">
{#each report.sanctions as restriction (restriction.id)}<li class="record">
<div class="row">
<strong>{restriction.kind === 'ban' ? 'Ban' : 'Mute'}</strong
><StatusBadge
state={`moderation-${restriction.active ? 'active' : restriction.revokedAt ? 'revoked' : 'expired'}`}
/>
</div>
<p class="record-text">{restriction.reason}</p>
<dl>
<div>
<dt>Scope</dt>
<dd>
{#if restriction.server}<ServerIdentity
name={restriction.server}
/>{:else}{restriction.scope === 'fleet'
? 'Entire fleet'
: moderationGameName(restriction.game)}{/if}
</dd>
</div>
<div>
<dt>Expires</dt>
<dd>
{restriction.expiresAt
? $timeFormat.dateTime(restriction.expiresAt)
: 'Permanent'}
</dd>
</div>
<div>
<dt>Applied by</dt>
<dd>
{restriction.actor} · <RelativeTime
value={restriction.createdAt}
/>
</dd>
</div>
{#if restriction.revokedAt}<div>
<dt>Revoked</dt>
<dd>{$timeFormat.dateTime(restriction.revokedAt)}</dd>
</div>{/if}
</dl>
{#if restriction.note}<p class="record-text">
<strong>Private note</strong> · {restriction.note}
</p>{/if}
<CopyValue value={restriction.id} label="Restriction ID" />
{#if restriction.active && canRevokeModerationRestriction($page.data?.user, restriction)}<Button
size="sm"
onclick={() =>
openDialog('revoke', {
id: restriction.id,
revision: restriction.revision
})}>Revoke restriction</Button
>{/if}
</li>{/each}
</ol>{:else}<p class="hint">
No restrictions have been applied from this case.
</p>{/if}
<p class="hint">
Applying or revoking a restriction does not change the case outcome.
</p>
</section>
<section class="panel" aria-labelledby="coverage-title">
<h2 id="coverage-title">Enforcement coverage</h2>
<p class="hint">
Current means a server acknowledged the latest policy. Pending or unavailable
servers may not apply a new restriction or revocation yet.
</p>
{#if coverage.length}<ul class="records">
{#each coverage as server (server.server)}<li class="record">
<a href={`/servers/${encodeURIComponent(server.server)}`}
><ServerIdentity name={server.server} /></a
><StatusBadge state={`moderation-${server.state}`} />
<p class="hint">
Policy {server.revision ?? '—'} / {server.desiredRevision} · {server.capabilities.join(
', '
) || 'No capabilities reported'}
</p>
</li>{/each}
</ul>{:else}<p class="hint">
No server enforcement reports are available yet.
</p>{/if}
</section>
</aside>
</div>
</div>
{#if permitted}{#key dialogKey}<ModerationDialog
bind:open={dialogOpen}
{mode}
{seed}
{admin}
coverage={report.coverage}
assignees={report.assignees}
onsubmitted={submitted}
/>{/key}{/if}
<style>
.case-page,
.case-main,
.case-sidebar,
.panel,
.records,
.record {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.case-page,
.case-main,
.case-sidebar {
gap: var(--space-5);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
gap: var(--space-5);
align-items: start;
}
.case-main,
.case-sidebar {
align-content: start;
}
.panel {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
.row,
.section-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.section-heading {
justify-content: space-between;
}
.row > span {
color: var(--text-secondary);
font-size: var(--text-sm);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
h2 span {
font-weight: var(--weight-regular);
color: var(--text-tertiary);
}
.records {
list-style: none;
padding: 0;
margin: 0;
}
.record {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-4);
gap: var(--space-3);
}
.record-text,
blockquote,
p {
margin: 0;
overflow-wrap: anywhere;
}
.record-text,
blockquote {
white-space: pre-wrap;
}
blockquote {
padding: var(--space-3);
border-left: 3px solid var(--accent-primary);
background: var(--surface-sunken);
border-radius: var(--radius-sm);
}
.hint,
dt,
.source-context,
summary {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.source-context {
display: grid;
gap: var(--space-2);
}
.notice {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-4);
background: var(--surface-raised);
font-size: var(--text-sm);
}
dl {
display: grid;
gap: var(--space-3);
margin: 0;
}
.case-metadata {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
dl > div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
dd {
margin: 0;
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.player-name {
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
pre {
padding: var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
font-size: var(--text-xs);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 960px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.case-metadata {
grid-template-columns: 1fr;
}
.panel {
padding: var(--space-4);
}
}
</style>