Files
shd.build/src/lib/components/isac/MechanicAuthoringAssistant.svelte
T

674 lines
28 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/state';
import PseudoScript from '$lib/components/PseudoScript.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import type {
AiMechanicAuthoringResponse,
AiMechanicContext,
AiMechanicTranscriptEntry,
AiMechanicValueColumn
} from '$lib/ai/mechanic-authoring';
import {
buildMechanicDeveloperPrompt,
type AiMechanicDeveloperHandoff
} from '$lib/ai/mechanic-developer-handoff';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { onDestroy, tick, untrack } from 'svelte';
type ClarificationAnswer = string | number | boolean;
let {
context,
currentDraft,
valueColumns,
idPrefix = 'mechanic',
allowAssignedAbility = false,
allowProtocolUpgradeScaling = false,
disabled = false,
onapply
}: {
context: AiMechanicContext;
currentDraft: ConditionalMechanicDraftV1;
valueColumns: AiMechanicValueColumn[];
idPrefix?: string;
allowAssignedAbility?: boolean;
allowProtocolUpgradeScaling?: boolean;
disabled?: boolean;
onapply: (draft: ConditionalMechanicDraftV1) => void;
} = $props();
const permissions = $derived((page.data.permissions as string[] | undefined) ?? []);
const canGenerate = $derived(permissions.includes('*') || permissions.includes('ai.mechanic.generate'));
const canUseAssistant = $derived(canGenerate && !disabled);
let expanded = $state(false);
let sourceText = $state('');
let response = $state<AiMechanicAuthoringResponse | null>(null);
let transcript = $state<AiMechanicTranscriptEntry[]>([]);
let answers = $state<Record<string, ClarificationAnswer | undefined>>({});
let pending = $state(false);
let errorMessage = $state('');
let activityMessage = $state('');
let confirmApply = $state(false);
let gapHandoff = $state<AiMechanicDeveloperHandoff | null>(null);
let gapReportPending = $state(false);
let gapReportSubmitted = $state(false);
let handoffMessage = $state('');
let handoffMessageKind = $state<'success' | 'error'>('success');
let resultRegion = $state<HTMLElement>();
let requestController: AbortController | undefined;
let trackedContextKey = $state('');
const contextIdentity = $derived(`${context.sourceKind}:${context.sourceName}`);
const contextKey = $derived(
`${idPrefix}-${context.sourceKind}-${context.sourceName}`
.toLocaleLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'mechanic'
);
const questions = $derived(
response?.status === 'clarification_required' ? response.questions : []
);
const allQuestionsAnswered = $derived(
questions.length > 0 && questions.every((question) => {
const answer = answers[question.id];
if (typeof answer === 'string') return answer.trim().length > 0;
if (typeof answer === 'number') return Number.isFinite(answer);
return typeof answer === 'boolean';
})
);
const pseudoScriptLines = $derived(
response?.status === 'draft_ready'
? response.pseudoScript.split('\n')
: []
);
const draftReadiness = $derived(
response?.status !== 'draft_ready'
? undefined
: response.draft.simulationReadiness === 'ready'
? 'READY'
: response.draft.simulationReadiness === 'requires_assumptions'
? 'REQUIRES ASSUMPTIONS'
: 'CAPTURED ONLY'
);
const currentDraftCounts = $derived(summarizeDraft(currentDraft));
const proposedDraftCounts = $derived(
response?.status === 'draft_ready' ? summarizeDraft(response.draft) : null
);
function summarizeDraft(draft: ConditionalMechanicDraftV1) {
return {
branches: draft.clauses.length,
pools: draft.pools.length,
controls: draft.scenarioInputs.length,
outcomes: draft.clauses.reduce((total, clause) => total + clause.outcomes.length, 0)
};
}
function countLabel(value: number, singular: string) {
return `${value} ${singular}${value === 1 ? '' : 's'}`;
}
function formatDraftCounts(counts: ReturnType<typeof summarizeDraft>) {
return [
countLabel(counts.branches, 'branch'),
countLabel(counts.pools, 'pool'),
countLabel(counts.controls, 'control'),
countLabel(counts.outcomes, 'outcome')
].join(' · ');
}
$effect(() => {
const nextKey = contextIdentity;
if (nextKey === trackedContextKey) return;
trackedContextKey = nextKey;
untrack(() => resetAssistant(context.sourceDescription ?? ''));
});
onDestroy(() => requestController?.abort());
function resetAssistant(description = context.sourceDescription ?? '') {
requestController?.abort();
sourceText = description;
response = null;
transcript = [];
answers = {};
pending = false;
errorMessage = '';
activityMessage = '';
confirmApply = false;
gapHandoff = null;
gapReportPending = false;
gapReportSubmitted = false;
handoffMessage = '';
handoffMessageKind = 'success';
}
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
answers = { ...answers, [questionId]: answer };
}
async function focusResult() {
await tick();
resultRegion?.focus();
}
async function requestDraft(nextTranscript = transcript) {
if (!canUseAssistant || pending) return;
if (!sourceText.trim()) {
errorMessage = 'Enter the mechanic wording from the game before requesting a draft.';
activityMessage = '';
return;
}
requestController?.abort();
requestController = new AbortController();
pending = true;
errorMessage = '';
activityMessage = '';
confirmApply = false;
gapHandoff = null;
gapReportSubmitted = false;
handoffMessage = '';
try {
const authoringRequest = {
context: { ...context, sourceDescription: sourceText.trim() },
currentDraft,
valueColumns,
allowAssignedAbility,
allowProtocolUpgradeScaling,
transcript: nextTranscript
};
const request = await fetch('/api/isac/mechanic-assistant', {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: requestController.signal,
body: JSON.stringify(authoringRequest)
});
const contentType = request.headers.get('content-type') ?? '';
const payload = contentType.includes('application/json')
? await request.json()
: null;
if (!request.ok) {
throw new Error(
typeof payload?.message === 'string'
? payload.message
: `The authoring assistant could not complete the request (${request.status}).`
);
}
if (
!payload
|| !['clarification_required', 'draft_ready', 'capability_gap'].includes(payload.status)
) {
throw new Error('The authoring assistant returned an unrecognized response.');
}
response = payload as AiMechanicAuthoringResponse;
if (response.status === 'capability_gap') {
gapHandoff = {
context: authoringRequest.context,
valueColumns: authoringRequest.valueColumns,
allowAssignedAbility: authoringRequest.allowAssignedAbility,
allowProtocolUpgradeScaling: authoringRequest.allowProtocolUpgradeScaling,
transcript: authoringRequest.transcript,
response
};
}
answers = {};
activityMessage = response.status === 'clarification_required'
? 'Clarification required before a reliable draft can be created.'
: response.status === 'draft_ready'
? 'Draft ready for review. Nothing has been applied or saved.'
: 'The current mechanic language cannot represent every requested behavior.';
await focusResult();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
errorMessage = error instanceof Error
? error.message
: 'The authoring assistant could not complete the request.';
await focusResult();
} finally {
pending = false;
}
}
function submitClarifications() {
if (!allQuestionsAnswered || response?.status !== 'clarification_required') return;
const entries = response.questions.map((question): AiMechanicTranscriptEntry => ({
questionId: question.id,
question: question.question,
answer: String(answers[question.id])
}));
const nextTranscript = [...transcript, ...entries];
transcript = nextTranscript;
void requestDraft(nextTranscript);
}
function restartAnalysis() {
response = null;
transcript = [];
answers = {};
errorMessage = '';
activityMessage = '';
confirmApply = false;
gapHandoff = null;
gapReportSubmitted = false;
handoffMessage = '';
void requestDraft([]);
}
function applyDraft() {
if (response?.status !== 'draft_ready') return;
onapply($state.snapshot(response.draft));
confirmApply = false;
activityMessage = 'AI draft applied to the manual editor. Review it before saving this item.';
}
async function copyDeveloperPrompt() {
if (!gapHandoff) return;
try {
await navigator.clipboard.writeText(buildMechanicDeveloperPrompt(gapHandoff));
handoffMessageKind = 'success';
handoffMessage = 'Developer prompt copied to the clipboard.';
} catch {
handoffMessageKind = 'error';
handoffMessage = 'Clipboard access was blocked. Check this browser’s clipboard permission and try again.';
}
}
async function submitGapReport() {
if (!gapHandoff || gapReportPending || gapReportSubmitted) return;
gapReportPending = true;
handoffMessage = '';
try {
const request = await fetch('/api/isac/mechanic-gap-report', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(gapHandoff)
});
const payload = request.headers.get('content-type')?.includes('application/json')
? await request.json()
: null;
if (!request.ok) {
throw new Error(
typeof payload?.message === 'string'
? payload.message
: `The mechanic gap could not be submitted (${request.status}).`
);
}
gapReportSubmitted = true;
handoffMessageKind = 'success';
handoffMessage = payload?.alreadySubmitted
? 'This mechanic gap was already submitted to the admin team.'
: `Mechanic gap submitted to ${Number(payload?.recipientCount ?? 0)} ${Number(payload?.recipientCount ?? 0) === 1 ? 'administrator' : 'administrators'}.`;
} catch (error) {
handoffMessageKind = 'error';
handoffMessage = error instanceof Error
? error.message
: 'The mechanic gap could not be submitted to the admin team.';
} finally {
gapReportPending = false;
}
}
</script>
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
<header>
<div>
<span>ISAC AUTHORING ASSIST</span>
<strong id={`${contextKey}-assistant-title`}>AI MECHANIC DRAFTING</strong>
</div>
<b class:available={canGenerate}>{canGenerate ? 'AUTHORIZED' : 'PERMISSION REQUIRED'}</b>
<button
type="button"
class="assistant-toggle"
aria-expanded={expanded}
aria-controls={`${contextKey}-assistant-workspace`}
disabled={!canGenerate}
onclick={() => expanded = !expanded}
>
{expanded ? 'CLOSE ASSISTANT' : 'OPEN ASSISTANT'}
</button>
</header>
{#if !canGenerate}
<p class="permission-note">
AI assistance is optional. Manual mechanic authoring remains available below.
Request <b>AI mechanic generation</b> permission from an administrator to use this helper.
</p>
{:else if !expanded}
<p class="assistant-summary">
Convert exact in-game wording into a reviewable mechanic draft. The assistant cannot save,
publish, or replace the manual editor.
</p>
{/if}
{#if expanded}
<div class="assistant-workspace" id={`${contextKey}-assistant-workspace`}>
<div class="source-heading">
<div>
<span>SOURCE WORDING</span>
<strong>{context.sourceName || 'UNTITLED MECHANIC'}</strong>
</div>
<small>Paste the wording exactly as it appears in the game.</small>
</div>
<label class="source-field">
<span>MECHANIC DESCRIPTION</span>
<textarea
rows="5"
bind:value={sourceText}
disabled={!canUseAssistant || pending}
placeholder="Enter the complete in-game talent or effect wording."
></textarea>
</label>
<div class="request-actions">
<p>
Generated data is a draft. Applying it never saves the parent record and does not
remove access to the manual controls.
</p>
<button
type="button"
class="primary request-button"
disabled={!canUseAssistant || pending || !sourceText.trim()}
aria-busy={pending}
onclick={() => response || transcript.length ? restartAnalysis() : requestDraft()}
>
{pending ? 'ANALYZING MECHANIC…' : transcript.length ? 'RESTART ANALYSIS' : 'GENERATE DRAFT'}
</button>
</div>
<div
class="result-region"
bind:this={resultRegion}
tabindex="-1"
aria-live="polite"
aria-busy={pending}
>
{#if errorMessage}
<div class="assistant-message error" role="alert">
<strong>REQUEST FAILED</strong>
<p>{errorMessage}</p>
</div>
{:else if response?.status === 'clarification_required'}
<div class="result-heading">
<div>
<span>CLARIFICATION REQUIRED</span>
<strong>{response.questions.length} {response.questions.length === 1 ? 'QUESTION' : 'QUESTIONS'}</strong>
</div>
<small>Answers are used only to refine this mechanic draft.</small>
</div>
<div class="question-stack">
{#each response.questions as question, index}
<section class="question-card">
<header>
<span>{String(index + 1).padStart(2, '0')}</span>
<div>
<strong>{question.question}</strong>
<small>{question.reason}</small>
</div>
</header>
{#if question.answerType === 'boolean'}
<SegmentedChoice
legend="ANSWER"
name={`${contextKey}-${question.id}`}
options={[
{ value: 'true', label: 'YES' },
{ value: 'false', label: 'NO' }
]}
value={answers[question.id] === undefined ? '' : String(answers[question.id])}
onchange={(values) => setAnswer(question.id, values[0] === 'true')}
/>
{:else if question.answerType === 'choice' && question.options?.length}
<SegmentedChoice
legend="ANSWER"
name={`${contextKey}-${question.id}`}
options={question.options.map((option) => ({ value: option, label: option.toUpperCase() }))}
value={typeof answers[question.id] === 'string' ? answers[question.id] as string : ''}
columns={Math.min(3, question.options.length)}
onchange={(values) => setAnswer(question.id, values[0])}
/>
{:else}
<label class="answer-field">
<span>ANSWER</span>
<input
type={question.answerType === 'number' ? 'number' : 'text'}
step={question.answerType === 'number' ? 'any' : undefined}
value={answers[question.id] ?? ''}
oninput={(event) => setAnswer(
question.id,
question.answerType === 'number'
? event.currentTarget.value === ''
? undefined
: Number(event.currentTarget.value)
: event.currentTarget.value
)}
/>
</label>
{/if}
</section>
{/each}
</div>
<div class="result-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
CANCEL QUESTIONS
</button>
<button
type="button"
class="primary"
disabled={!allQuestionsAnswered || pending}
aria-busy={pending}
onclick={submitClarifications}
>
{pending ? 'REFINING DRAFT…' : 'SUBMIT ANSWERS'}
</button>
</div>
{:else if response?.status === 'capability_gap'}
<div class="result-heading capability">
<div>
<span>DEVELOPER HANDOFF</span>
<strong>MECHANIC LANGUAGE GAP</strong>
</div>
<small>The assistant did not invent unsupported calculation behavior.</small>
</div>
<p class="gap-summary">{response.summary}</p>
<div class="gap-stack">
{#each response.gaps as gap}
<article class="gap-card">
<header><span>AREA</span><strong>{gap.area}</strong></header>
<dl>
<div><dt>REQUIRED BEHAVIOR</dt><dd>{gap.requiredBehavior}</dd></div>
<div><dt>WHY UNSUPPORTED</dt><dd>{gap.whyUnsupported}</dd></div>
<div><dt>DEVELOPER CHANGE</dt><dd>{gap.developerChange}</dd></div>
{#if gap.workaround}<div><dt>CURRENT WORKAROUND</dt><dd>{gap.workaround}</dd></div>{/if}
</dl>
</article>
{/each}
</div>
<div class="result-actions handoff-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
REVISE SOURCE WORDING
</button>
<div>
<button type="button" class="secondary" disabled={!gapHandoff} onclick={copyDeveloperPrompt}>
COPY DEVELOPER PROMPT
</button>
<button
type="button"
class="primary"
disabled={!gapHandoff || gapReportPending || gapReportSubmitted}
aria-busy={gapReportPending}
onclick={submitGapReport}
>
{gapReportPending
? 'SUBMITTING GAP…'
: gapReportSubmitted
? 'SUBMITTED TO ADMIN TEAM'
: 'SUBMIT TO ADMIN TEAM'}
</button>
</div>
</div>
{#if handoffMessage}
<p
class="handoff-message"
class:error={handoffMessageKind === 'error'}
role={handoffMessageKind === 'error' ? 'alert' : 'status'}
>{handoffMessage}</p>
{/if}
{:else if response?.status === 'draft_ready'}
<div class="result-heading ready">
<div>
<span>DRAFT READY</span>
<strong>REVIEW BEFORE APPLICATION</strong>
</div>
<small>No changes have been applied or saved.</small>
</div>
<p class="draft-summary">{response.summary}</p>
{#if response.assumptions.length || response.warnings.length}
<div class="review-notes">
{#if response.assumptions.length}
<section>
<strong>ASSUMPTIONS</strong>
<ul>{#each response.assumptions as assumption}<li>{assumption}</li>{/each}</ul>
</section>
{/if}
{#if response.warnings.length}
<section class="warnings">
<strong>REVIEW WARNINGS</strong>
<ul>{#each response.warnings as warning}<li>{warning}</li>{/each}</ul>
</section>
{/if}
</div>
{/if}
<PseudoScript
title="PROPOSED MECHANIC PROGRAM"
lines={pseudoScriptLines}
readiness={draftReadiness}
/>
{#if confirmApply}
<div class="apply-confirm" role="alert">
<div>
<strong>APPLY THIS DRAFT TO THE MANUAL EDITOR?</strong>
<p>
This replaces the current unsaved mechanic fields. It does not save or publish
the parent record.
</p>
{#if proposedDraftCounts}
<div class="replacement-summary">
<span><b>CURRENT</b>{formatDraftCounts(currentDraftCounts)}</span>
<span><b>PROPOSED</b>{formatDraftCounts(proposedDraftCounts)}</span>
</div>
{/if}
</div>
<div>
<button type="button" class="secondary" onclick={() => confirmApply = false}>
CANCEL
</button>
<button type="button" class="primary" onclick={applyDraft}>
CONFIRM APPLY
</button>
</div>
</div>
{:else}
<div class="result-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
DISCARD DRAFT
</button>
<button type="button" class="primary" onclick={() => confirmApply = true}>
APPLY TO EDITOR
</button>
</div>
{/if}
{:else if pending}
<div class="assistant-message pending">
<strong>ANALYZING MECHANIC</strong>
<p>Comparing the source wording with supported events, attributes, and value models.</p>
</div>
{/if}
</div>
{#if activityMessage}
<p class="activity-message" role="status">{activityMessage}</p>
{/if}
</div>
{/if}
</section>
<style>
.authoring-assistant{border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}
.authoring-assistant>header{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:var(--shd-gap-sm);padding:7px 9px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.authoring-assistant>header>div,.source-heading>div,.result-heading>div{display:grid;gap:2px}
.authoring-assistant>header span,.source-heading span,.result-heading span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}
.authoring-assistant>header b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.authoring-assistant>header b.available{border-color:color-mix(in srgb,var(--shd-info) 55%,var(--shd-border));color:var(--shd-info)}
.assistant-toggle{min-height:36px;padding:0 11px;border:1px solid var(--shd-info);background:transparent;color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
.assistant-toggle:hover,.assistant-toggle:focus-visible{background:color-mix(in srgb,var(--shd-info) 12%,var(--shd-surface))}
.assistant-toggle:disabled{cursor:not-allowed;opacity:.5}
.assistant-toggle:active,.request-button:active,.result-actions button:active,.apply-confirm button:active{transform:translateY(1px);filter:brightness(.84)}
.permission-note,.assistant-summary{margin:0;padding:9px 11px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.permission-note b{color:var(--shd-text)}
.locked{border-left-color:var(--shd-border)}
.assistant-workspace{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.source-heading,.result-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.source-heading small,.result-heading small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
.source-field,.answer-field{display:grid;gap:5px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.source-field textarea,.answer-field input{width:100%;box-sizing:border-box;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text);font:inherit}
.source-field textarea{min-height:118px;padding:10px;resize:vertical;font-size:var(--shd-type-body);font-weight:400;letter-spacing:0;line-height:1.5}
.answer-field input{min-height:42px;padding:0 10px;font-size:var(--shd-type-supporting);font-weight:400;letter-spacing:0}
.source-field :is(textarea,input):focus-visible,.answer-field input:focus-visible{border-color:var(--shd-orange);outline:1px solid color-mix(in srgb,var(--shd-orange) 55%,transparent);outline-offset:1px}
.request-actions,.result-actions,.apply-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
.handoff-actions>div{display:flex;gap:var(--shd-gap-xs)}
.request-actions p{max-width:70ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.request-actions button,.result-actions button,.apply-confirm button{min-height:40px;padding:0 13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
.request-actions button:disabled,.result-actions button:disabled{cursor:wait;opacity:.58}
.result-region{display:grid;gap:var(--shd-gap-sm);outline:none}
.result-region:focus-visible{outline:1px solid var(--shd-orange);outline-offset:3px}
.result-heading.capability{border-left:2px solid var(--shd-danger)}.result-heading.capability span{color:var(--shd-danger)}
.result-heading.ready{border-left:2px solid var(--shd-success)}.result-heading.ready span{color:var(--shd-success)}
.assistant-message{display:grid;gap:3px;padding:10px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.assistant-message p,.gap-summary,.draft-summary,.activity-message{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.assistant-message.error{border-left-color:var(--shd-danger)}.assistant-message.error strong{color:var(--shd-danger)}
.question-stack,.gap-stack{display:grid;gap:var(--shd-gap-xs)}
.question-card,.gap-card{display:grid;gap:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.question-card>header{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start}
.question-card>header>span{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900}
.question-card>header>div{display:grid;gap:3px}.question-card small{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.gap-summary,.draft-summary{padding:0 2px;color:var(--shd-text)}
.gap-card>header{display:flex;align-items:center;gap:8px}.gap-card>header span,.gap-card dt,.review-notes strong{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.gap-card dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--shd-border-soft);border-left:1px solid var(--shd-border-soft)}
.gap-card dl>div{display:grid;gap:4px;padding:8px;border-right:1px solid var(--shd-border-soft);border-bottom:1px solid var(--shd-border-soft)}
.gap-card dd{margin:0;color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
.review-notes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-xs)}
.review-notes section{padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.review-notes section.warnings{border-left-color:var(--shd-orange)}
.review-notes ul{display:grid;gap:5px;margin:6px 0 0;padding-left:18px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.apply-confirm{padding:9px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 8%,var(--shd-surface))}
.apply-confirm>div:first-child{display:grid;gap:3px}.apply-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.apply-confirm>div:last-child{display:flex;gap:7px}
.replacement-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;margin-top:4px}.replacement-summary span{display:grid;gap:2px;padding:5px 7px;border:1px solid var(--shd-border-soft);color:var(--shd-text);font-size:var(--shd-type-supporting)}.replacement-summary b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.activity-message{padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.handoff-message{margin:0;padding:7px 9px;border-left:2px solid var(--shd-success);background:var(--shd-surface-raised);color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
.handoff-message.error{border-left-color:var(--shd-danger);color:var(--shd-danger)}
@media(max-width:760px){
.authoring-assistant>header{grid-template-columns:1fr auto}.authoring-assistant>header b{justify-self:end}.assistant-toggle{grid-column:1/-1;width:100%;min-height:44px}
.source-heading,.result-heading,.request-actions,.result-actions,.apply-confirm{align-items:stretch;flex-direction:column}
.request-actions button,.result-actions button,.apply-confirm button{width:100%;min-height:44px}
.handoff-actions>div{display:grid;grid-template-columns:1fr;width:100%}
.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}
.replacement-summary{grid-template-columns:1fr}
.gap-card dl,.review-notes{grid-template-columns:1fr}
}
@media(prefers-reduced-motion:reduce){
.assistant-toggle,.request-button,.result-actions button,.apply-confirm button{transition:none}
}
</style>