feat: persist authored mechanic revisions

This commit is contained in:
jb
2026-09-02 15:39:50 -04:00
parent 98f7f51e46
commit 7e392a1f37
56 changed files with 20720 additions and 3279 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+292 -156
View File
@@ -1,5 +1,16 @@
<script lang="ts">
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
import {
formatMechanicToken,
formatMechanicValue,
normalizePhase2OwnerPreview,
parseProposalPreviewPayload,
safePreviewValue,
isPreviewFieldHidden,
type AuthoredSourcePreview,
type AttachmentPreview,
type MechanicPreviewState,
type ProposalPreviewEntry
} from './ProposalChangePreview.helpers';
let {
module,
@@ -13,8 +24,10 @@
currentSnapshot?: Record<string, unknown> | null;
} = $props();
const entries = $derived(JSON.parse(payload) as Entry[]);
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
const parsedPayload = $derived(parseProposalPreviewPayload(payload));
const entries = $derived(parsedPayload.entries as ProposalPreviewEntry[]);
const proposed = $derived(parsedPayload.fields);
const phase2OwnerPreview = $derived(normalizePhase2OwnerPreview(module, action, payload, currentSnapshot));
const label = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
const jsonValue = (value: unknown) => {
@@ -43,16 +56,8 @@
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const specializationFields = $derived(
action === 'saveProgression'
? [
@@ -116,23 +121,80 @@
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
const phase2Record = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const phase2TargetDisplay = (side: 'current' | 'proposed') =>
side === 'current'
? phase2OwnerPreview?.canonicalTargetId
: phase2OwnerPreview?.canonicalTargetId ?? 'ASSIGNED ON APPROVAL';
const phase2HasCurrentTarget = $derived(Boolean(phase2OwnerPreview?.canonicalTargetId));
const ownerIdentityFields = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
const value = (key: string) => side === 'current' ? phase2Record?.[key] : proposed[key];
if (family === 'talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'CATEGORY', value: value('category') },
{ label: 'OWNER TYPE', value: side === 'current' ? (currentSnapshot?.authoredSource as any)?.owner?.type : 'talent' },
{ label: 'OWNER ID', value: phase2TargetDisplay(side) }
];
if (family === 'protocol-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'QUALITY', value: value('quality') },
{ label: 'ATTRIBUTE TYPE', value: side === 'current' ? phase2Record?.primary_attribute_type : proposed.attributeType },
{ label: 'PRIMARY ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.primaryEffect as any)?.stat_attribute_id : proposed.primaryStatId },
{ label: 'TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'skill-chip-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION ABILITY', value: side === 'current' ? phase2Record?.specialization_ability_id : proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.twoPiece as any)?.stat_attribute_id : proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', value: side === 'current' ? `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}` : `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'specialization-node') return [
{ label: 'NAME', value: value('name') },
{ label: 'FOCUS', value: side === 'current' ? phase2Record?.focus_id : proposed.focusId },
{ label: 'ROW', value: side === 'current' ? phase2Record?.row_number : proposed.rowNumber },
{ label: 'COLUMN', value: side === 'current' ? phase2Record?.column_number : proposed.columnNumber },
{ label: 'MAXIMUM RANK', value: side === 'current' ? phase2Record?.max_rank : proposed.maxRank },
{ label: 'RANK DEFINITIONS', value: side === 'current' ? (currentSnapshot?.ranks as any[])?.length ?? 0 : (jsonValue(proposed.ranksJson) as any[])?.length ?? 0 }
];
return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION', value: side === 'current' ? phase2Record?.specialization_id : proposed.specializationId },
{ label: 'DESCRIPTION TEMPLATE', value: side === 'current' ? phase2Record?.description_template : proposed.descriptionTemplate },
{ label: 'ATTACHMENTS', value: side === 'current' ? (currentSnapshot?.attachments as any[])?.length ?? 0 : phase2OwnerPreview?.proposedAttachments.length ?? 0 }
];
};
const ownerRelationshipRows = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
if (family === 'talent') {
if (side === 'proposed') return [`CATEGORY · ${shown(proposed.category)}`, `RECORD ID · ${shown(phase2TargetDisplay(side))}`];
const relationships = (currentSnapshot?.relationships ?? {}) as any;
return [
`WEAPON SCOPE · ${shown(relationships.weaponScope?.scope)}`,
`WEAPON SLOT · ${shown(relationships.weaponSlot?.slot)}`,
`OWNER WEAPON · ${shown(relationships.ownerWeapon?.id)}${relationships.ownerWeapon?.name ? ` · ${relationships.ownerWeapon.name}` : ''}`,
`GEAR TALENTS · ${(relationships.gearTalents ?? []).map((item: any) => item.gear_id).join(', ') || 'NONE'}`,
`WEAPON TALENT OPTIONS · ${(relationships.weaponTalentOptions ?? []).map((item: any) => `${item.weapon_id} · SLOT ${item.talent_slot}`).join(', ') || 'NONE'}`
];
}
if (family === 'protocol-talent') return side === 'current'
? [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'skill-chip-talent') return side === 'current'
? [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'specialization-node') return side === 'current'
? [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(phase2Record?.focus_id)}`]
: [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(proposed.focusId)}`];
return side === 'current'
? [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `ATTACHMENT IDS · ${(currentSnapshot?.attachments as any[] ?? []).map((item: any) => item.id).join(', ') || 'NONE'}`]
: [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `FAN-OUT ATTACHMENTS · ${phase2OwnerPreview?.proposedAttachments.length ?? 0}`];
};
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
const conditionalRuleSummary = (effect: any) => {
const rule = effect.conditionalRule ?? {};
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
const outcomes = clauses.reduce((total: number, clause: any) =>
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
};
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
const storedJson = (value: unknown, fallback: unknown) => {
if (typeof value !== 'string') return value ?? fallback;
try { return JSON.parse(value); } catch { return fallback; }
};
const attackProfileLines = (attack: Record<string, unknown> | null) => {
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
const delivery = String(attack.delivery ?? '').toLowerCase();
@@ -156,90 +218,100 @@
}
return lines;
};
const conditionalRuleLines = (source: unknown) => {
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
const wrapper = source as Record<string, any>;
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
const rule = persisted ? wrapper.rule : wrapper;
const inputs = persisted
? storedJson(rule.scenario_inputs_json, [])
: rule.scenarioInputs;
const pools = persisted ? wrapper.statePools : rule.pools;
const clauses = persisted ? wrapper.clauses : rule.clauses;
const lines = [
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
];
if (Array.isArray(inputs)) {
for (const input of inputs) {
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
}
}
if (Array.isArray(pools)) {
for (const pool of pools) {
const poolKey = pool.key ?? pool.state_key;
const scope = pool.scope ?? pool.state_scope;
const maximum = pool.max ?? pool.maximum_value;
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
}
}
if (Array.isArray(clauses)) {
for (const [index, clause] of clauses.entries()) {
const event = clause.event ?? clause.event_kind;
const recipe = clause.recipeKey ?? clause.recipe_key;
const reducer = persisted
? storedJson(clause.reducer_json, { kind: 'none' })
: clause.reducer;
const mutations = persisted
? storedJson(clause.mutations_json, [])
: clause.mutations;
const branchOutcomes = persisted
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
: clause.outcomes ?? [];
lines.push([
`BRANCH ${index + 1}`,
readableToken(event),
recipe ? `RECIPE ${readableToken(recipe)}` : '',
`REDUCER ${readableToken(reducer?.kind)}`,
clause.cooldownSeconds ?? clause.cooldown_seconds
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
: '',
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
`${branchOutcomes.length} OUTCOMES`
].filter(Boolean).join(' · '));
for (const outcome of branchOutcomes) {
if (persisted) {
lines.push([
`↳ ${readableToken(outcome.variant_key)}`,
readableToken(outcome.application_kind),
readableToken(outcome.effect_scope),
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
readableToken(outcome.application_phase)
].join(' · '));
continue;
}
const variants = outcome.variants && typeof outcome.variants === 'object'
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
).join(' · ')
: 'NO VARIANTS';
lines.push([
`↳ ${shown(outcome.label ?? outcome.key)}`,
readableToken(outcome.kind),
readableToken(outcome.scope),
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
variants,
readableToken(outcome.phase)
].join(' · '));
}
}
}
return lines;
};
</script>
{#snippet authoredSourceBlock(source: AuthoredSourcePreview | null)}
{#if source}
<section class="proposal-mechanic-section authored-source-preview">
<header><span>AUTHORED SOURCE · VERSION {shown(source.version)}</span><b>{source.variants.length} ORDERED VARIANTS</b></header>
<div class="proposal-source-template"><span>TEMPLATE</span><p>{source.template}</p></div>
{#if source.specialization}
<div class="proposal-source-identity">
<div><span>RANK DESCRIPTION</span><p>{shown(source.specialization.rankDescription)}</p></div>
<div><span>REUSABLE EFFECT ID</span><p>{shown(source.specialization.effectId)}</p></div>
<div><span>REUSABLE EFFECT TEMPLATE</span><p>{shown(source.specialization.effectTemplate)}</p></div>
</div>
{/if}
<div class="proposal-source-variants">
{#each source.variants as variant, variantIndex}
<article>
<header><span>VARIANT {variantIndex + 1} · {variant.key}</span><b>{variant.slots.length} SLOTS</b></header>
<p class="proposal-materialized-text">{variant.materialized || 'NOT MATERIALIZED'}</p>
{#each variant.slots as slot, slotIndex}
<small>SLOT {slotIndex + 1} · ORDER {slot.order} · {slot.key} = {shown(slot.value)}{slot.unit ? ` · UNIT ${slot.unit}` : ''}{slot.attributeId ? ` · ATTRIBUTE ${slot.attributeId}` : ''}</small>
{:else}<small>NO TEMPLATE SLOTS</small>{/each}
</article>
{:else}<p class="proposal-structured-empty">NO AUTHORED VARIANTS</p>{/each}
</div>
</section>
{/if}
{/snippet}
{#snippet mechanicStateBlock(state: MechanicPreviewState, heading: string)}
<section class="proposal-mechanic-section mechanic-state-preview">
<header><span>{heading}</span><b>{state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : state.modelDraft ? 'MODEL SUBMISSION' : 'NO MODEL SELECTED'}</b></header>
<div class="proposal-model-summary">
<div><span>SELECTION STATE</span><strong>{state.selectedRevision ? formatMechanicToken(state.selectedRevisionStatus) : state.modelDraft ? 'PENDING REVISION' : 'ABSENT'}</strong></div>
<div><span>READINESS</span><strong>{formatMechanicToken((state.selectedRevision ?? state.modelDraft)?.readiness)}</strong></div>
<div><span>SCENARIO INPUTS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.scenarioInputs.length ?? 0}</strong></div>
<div><span>STATE POOLS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.pools.length ?? 0}</strong></div>
<div><span>CLAUSES</span><strong>{(state.selectedRevision ?? state.modelDraft)?.clauses.length ?? 0}</strong></div>
</div>
{#if state.selectedRevision ?? state.modelDraft}
{@const model = (state.selectedRevision ?? state.modelDraft)!}
<div class="proposal-graph-summary">
<section><header><span>SCENARIO INPUTS</span><b>{model.scenarioInputs.length}</b></header>
{#each model.scenarioInputs as input}<p>INPUT · {input.label} · {formatMechanicToken(input.kind)} · {formatMechanicToken(input.readiness)}{input.min !== null || input.max !== null ? ` · RANGE ${shown(input.min)}–${shown(input.max)}` : ''}{input.defaultValue !== null ? ` · DEFAULT ${shown(input.defaultValue)}` : ''}</p>{:else}<p>NO SCENARIO INPUTS</p>{/each}
</section>
<section><header><span>STATE POOLS</span><b>{model.pools.length}</b></header>
{#each model.pools as pool}<p>POOL · {pool.key} · {pool.label} · KIND {formatMechanicToken(pool.stateKind)} · {formatMechanicToken(pool.scope)} · INITIAL {shown(pool.initial)} · RANGE {shown(pool.min)}–{shown(pool.max)}{pool.bestCaseValue !== null ? ` · BEST ${shown(pool.bestCaseValue)}` : ''}{pool.expiresAfterSeconds !== null ? ` · EXPIRES ${shown(pool.expiresAfterSeconds)}s` : ''}</p>{:else}<p>NO STATE POOLS</p>{/each}
</section>
<section><header><span>CLAUSE GRAPH</span><b>{model.clauses.length}</b></header>
{#each model.clauses as clause, clauseIndex}
<article class="proposal-clause">
<p><b>CLAUSE {clauseIndex + 1} · {clause.label}</b> · EVENT {formatMechanicToken(clause.event)}{clause.recipeKey ? ` · RECIPE ${formatMechanicToken(clause.recipeKey)}` : ''} · REDUCER {formatMechanicToken((clause.reducer as any)?.kind)}{clause.cooldownSeconds !== null ? ` · COOLDOWN ${shown(clause.cooldownSeconds)}s` : ''}</p>
<small>CONDITION · {formatMechanicValue(clause.condition)} · MUTATIONS {clause.mutations.length}</small>
{#each clause.mutations as mutation, mutationIndex}<small>MUTATION {mutationIndex + 1} · {formatMechanicValue(mutation)}</small>{/each}
{#each clause.outcomes as outcome, outcomeIndex}
<div class="proposal-outcome"><small>OUTCOME {outcomeIndex + 1} · {outcome.label} · {formatMechanicToken(outcome.kind)} · {formatMechanicToken(outcome.scope)} · {formatMechanicToken(outcome.phase)} · BASIS {formatMechanicToken(outcome.basis)}{outcome.attributeId ? ` · ATTRIBUTE ${outcome.attributeId}` : ''}{outcome.statePool ? ` · POOL ${outcome.statePool}` : ''}{outcome.resource ? ` · RESOURCE ${formatMechanicToken(outcome.resource)}` : ''}{outcome.perStack !== null ? ` · PER STACK ${outcome.perStack ? 'YES' : 'NO'}` : ''}</small>{#each outcome.variants as variant}<small>VARIANT {variant.key} · VALUE {formatMechanicValue(variant.value)}{variant.expression !== null ? ` · EXPRESSION ${formatMechanicValue(variant.expression)}` : ''}</small>{/each}</div>
{/each}
</article>
{:else}<p>NO CLAUSES</p>{/each}
</section>
</div>
{:else}<p class="proposal-structured-empty">NO IMMUTABLE MODEL REVISION IS SELECTED.</p>{/if}
</section>
{/snippet}
{#snippet projectionBlock(state: MechanicPreviewState)}
<section class="proposal-mechanic-section proposal-projection-preview">
<header><span>PUBLIC PROJECTION</span><b>{formatMechanicToken(state.projection.status)}</b></header>
{#if state.projection.variants.length}
<p class="proposal-projection-meta">PROJECTION SOURCE · {state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : 'AUTHORED FALLBACK'}</p>
{#each state.projection.variants as variant}<p class="proposal-projection-variant"><b>{variant.key}</b><span>STATUS {formatMechanicToken(variant.status)} · DISPLAY SOURCE {formatMechanicToken(variant.displaySource)}</span><strong>{variant.text || 'NO PUBLIC TEXT'}</strong></p>{/each}
{:else}<p class="proposal-structured-empty">NO PUBLIC PROJECTION AVAILABLE.</p>{/if}
</section>
{/snippet}
{#snippet mechanicBlocks(state: MechanicPreviewState, heading: string)}
{@render authoredSourceBlock(state.authoredSource)}
{@render mechanicStateBlock(state, heading)}
{@render projectionBlock(state)}
{/snippet}
{#snippet attachmentBlock(attachments: AttachmentPreview[], heading: string)}
<section class="proposal-mechanic-section attachment-preview">
<header><span>{heading}</span><b>{attachments.length} ATTACHMENTS</b></header>
{#each attachments as attachment, attachmentIndex}
<article>
<header><span>ATTACHMENT {attachmentIndex + 1} · {attachment.attachmentId}</span><b>RANK {shown(attachment.rank)} · ORDER {shown(attachment.effectOrder)}</b></header>
<p>NODE ID · {shown(attachment.nodeId)} · COMBAT EFFECT ID · {shown(attachment.effectId)}</p>
{@render mechanicBlocks(attachment.mechanic, 'ATTACHMENT MECHANIC')}
</article>
{:else}<p class="proposal-structured-empty">NO ATTACHMENTS</p>{/each}
</section>
{/snippet}
<section class="proposal-change-preview" aria-label="Proposed data comparison">
<div class="proposal-preview-head">
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
@@ -281,6 +353,42 @@
</div>
</article>
</div>
{:else if phase2OwnerPreview}
<div class="proposal-compare-grid phase2-owner-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {phase2OwnerPreview.family.toUpperCase()}</span><b>{phase2HasCurrentTarget ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if phase2HasCurrentTarget}
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('current', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>LIVE</b></header>{#each ownerRelationshipRows('current', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.currentMechanic, 'MECHANIC MODEL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)) as effect}<small>{effectSummary(effect)}</small>{/each}<small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.currentAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT' : 'NODE ATTACHMENTS')}
{/if}
{:else}<div class="proposal-empty-state"><b>NO LIVE {phase2OwnerPreview.family.toUpperCase()} RECORD</b><span>The proposed owner state will be created on approval.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {phase2OwnerPreview.family.toUpperCase()}</span><b>AFTER</b></header>
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('proposed', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>PROPOSED</b></header>{#each ownerRelationshipRows('proposed', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if phase2OwnerPreview.selectionIntent}<p class="proposal-selection-intent">MODEL REQUEST · {formatMechanicToken(phase2OwnerPreview.selectionIntent)}</p>{/if}
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.proposedMechanic, 'MECHANIC MODEL AFTER APPROVAL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.proposedAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT AFTER APPROVAL' : 'NODE ATTACHMENTS AFTER APPROVAL')}
{/if}
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
@@ -292,35 +400,6 @@
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
</article>
</div>
{:else if module === 'calculations'}
<div class="proposal-compare-grid calculation-proposal-preview">
<article class="proposal-editor-preview before">
@@ -333,7 +412,7 @@
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
@@ -352,12 +431,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
@@ -385,18 +458,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
@@ -411,19 +472,94 @@
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each entries as entry}
{@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''}
{#if !isPreviewFieldHidden(entry.name)}
<div>
<span>{label(entry.name)}</span>
{#if entry.kind === 'file'}
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
{:else if jsonValue(entry.value)}
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{entry.value}</small>
{:else if jsonValue(safeValue)}
<strong>{Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{safeValue}</small>
{:else}
<strong>{shown(entry.value)}</strong>
<strong>{shown(safeValue)}</strong>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
</section>
<style>
.phase2-owner-preview { align-items: start; }
.owner-identity-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-relationships,
.proposal-mechanic-section { display: grid; gap: 5px; padding: 0 8px 8px; }
.proposal-relationships > header,
.proposal-mechanic-section > header,
.proposal-source-variants article > header,
.attachment-preview article > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 29px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-relationships > header span,
.proposal-relationships > header b,
.proposal-mechanic-section > header span,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header span,
.proposal-source-variants article > header b,
.attachment-preview article > header span,
.attachment-preview article > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-relationships > header b,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header b,
.attachment-preview article > header b { color: var(--shd-orange); }
.proposal-relationships p,
.proposal-selection-intent,
.proposal-mechanic-section > p,
.proposal-mechanic-section > article > p,
.proposal-source-variants article > small,
.attachment-preview article > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-source-template { display: grid; gap: 4px; padding: 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity { display: grid; gap: 5px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.proposal-source-identity > div { display: grid; gap: 4px; min-width: 0; padding: 7px 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-identity p { margin: 0; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-template span,
.proposal-model-summary span,
.proposal-projection-meta,
.proposal-projection-variant span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-template p,
.proposal-materialized-text { margin: 0; color: var(--shd-text); font-size: var(--shd-type-body); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-variants,
.proposal-graph-summary { display: grid; gap: 5px; }
.proposal-source-variants article { display: grid; gap: 4px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-source-variants article > small { background: #111415; }
.proposal-model-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.proposal-model-summary > div { display: grid; gap: 4px; min-width: 0; padding: 7px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-model-summary strong { color: var(--shd-text); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-graph-summary > section { display: grid; gap: 4px; }
.proposal-graph-summary > section > header { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-graph-summary > section > header span,
.proposal-graph-summary > section > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-graph-summary > section > header b { color: var(--shd-orange); }
.proposal-graph-summary > section > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause { display: grid; gap: 3px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-clause > p,
.proposal-clause > small,
.proposal-outcome > small { margin: 0; padding: 5px 6px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause > p { color: var(--shd-text); }
.proposal-outcome { display: grid; gap: 3px; margin-left: 8px; }
.proposal-projection-preview { gap: 5px; }
.proposal-projection-meta { margin: 0; padding: 0 2px; }
.proposal-projection-variant { display: grid; grid-template-columns: 42px 1fr; gap: 5px; margin: 0; padding: 6px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-projection-variant > b { color: var(--shd-orange); font-size: var(--shd-type-telemetry); }
.proposal-projection-variant > strong { grid-column: 1 / -1; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; }
.attachment-preview { padding-top: 0; }
.attachment-preview > article { display: grid; gap: 5px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.attachment-preview > article .proposal-mechanic-section { padding-right: 0; padding-left: 0; }
.proposal-structured-empty { margin: 0; padding: 8px; border: 1px dashed var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); text-align: center; }
@media (max-width: 800px) {
.owner-identity-fields, .proposal-model-summary, .proposal-source-identity { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-model-summary > div:last-child { grid-column: span 2; }
}
</style>
@@ -0,0 +1,355 @@
<script lang="ts">
import type {
MechanicAuthoredSlot,
MechanicAuthoredVariant,
} from '$lib/calculations/mechanic-authored-description';
export type AuthoredVariantLabel = { key: string; label: string };
let {
template = $bindable(),
variants = $bindable(),
variantLabels,
idPrefix = 'authored-description',
disabled = false,
}: {
template: string;
variants: MechanicAuthoredVariant[];
variantLabels: AuthoredVariantLabel[];
idPrefix?: string;
disabled?: boolean;
} = $props();
const tokenPattern = /\{([^{}]+)\}/g;
const allowedNamedTokens = new Set(['duration', 'max_stacks']);
const tokens = $derived.by(() => {
const ordered: string[] = [];
for (const match of template.matchAll(tokenPattern)) {
if (!ordered.includes(match[1])) ordered.push(match[1]);
}
return ordered;
});
const invalidTokens = $derived(
tokens.filter(
(token) =>
!allowedNamedTokens.has(token) && !/^[1-9]\d*$/.test(token),
),
);
function materialized(
sourceTemplate: string,
slots: MechanicAuthoredSlot[],
) {
const values = new Map(slots.map((slot) => [slot.key, slot.value]));
return sourceTemplate.replace(tokenPattern, (placeholder, token: string) =>
values.get(token) || placeholder,
);
}
function reconcileVariants(
sourceTemplate: string,
sourceVariants: MechanicAuthoredVariant[],
) {
return variantLabels.map((requiredVariant) => {
const existing = sourceVariants.find(
(variant) => variant.key === requiredVariant.key,
);
const existingSlots = new Map(
(existing?.slots ?? []).map((slot) => [slot.key, slot]),
);
const slots = tokens.map((token, order) => ({
...(existingSlots.get(token) ?? { key: token, value: '', order }),
key: token,
order,
}));
return {
key: requiredVariant.key,
materialized: materialized(sourceTemplate, slots),
slots,
};
});
}
function updateTemplate(value: string) {
template = value;
variants = reconcileVariants(value, variants);
}
function updateSlot(variantKey: string, token: string, value: string) {
variants = variants.map((variant) => {
if (variant.key !== variantKey) return variant;
const slots = tokens.map((currentToken, order) => {
const existing = variant.slots.find(
(slot) => slot.key === currentToken,
);
return {
...(existing ?? { key: currentToken, value: '', order }),
key: currentToken,
value: currentToken === token ? value : (existing?.value ?? ''),
order,
};
});
return {
...variant,
slots,
materialized: materialized(template, slots),
};
});
}
function slotValue(variantKey: string, token: string) {
return (
variants
.find((variant) => variant.key === variantKey)
?.slots.find((slot) => slot.key === token)?.value ?? ''
);
}
function preview(variantKey: string) {
return (
variants.find((variant) => variant.key === variantKey)?.materialized ??
template
);
}
</script>
<section class="authored-editor" aria-labelledby={`${idPrefix}-title`}>
<header>
<div>
<p class="eyebrow">PUBLIC FALLBACK SOURCE</p>
<h4 id={`${idPrefix}-title`}>AUTHORED DESCRIPTION</h4>
<p>Define exact public prose independently from combat modeling.</p>
</div>
<span class="variant-count">
{variantLabels.length} {variantLabels.length === 1 ? 'VARIANT' : 'VARIANTS'}
</span>
</header>
<label class="field template-field">
SHARED DESCRIPTION TEMPLATE
<textarea
id={`${idPrefix}-template`}
value={template}
oninput={(event) => updateTemplate(event.currentTarget.value)}
placeholder={'After {1} hits, gain {2}% Weapon Damage for {duration} seconds.'}
{disabled}
required
></textarea>
<small>
Use numbered tokens such as <code>{'{1}'}</code>, plus
<code>{'{duration}'}</code> and <code>{'{max_stacks}'}</code>. Keep fixed
units and Attribute wording in the template. Enter only the numbers that
change by variant below; the combat model assigns Attribute semantics.
</small>
</label>
{#if invalidTokens.length}
<p class="token-error" role="alert">
Unsupported {invalidTokens.length === 1 ? 'token' : 'tokens'}:
{invalidTokens.map((token) => `{${token}}`).join(', ')}
</p>
{/if}
{#if tokens.length}
<div class="matrix-scroll">
<table>
<thead>
<tr>
<th scope="col">AUTHORED VALUE</th>
{#each variantLabels as variant}
<th scope="col">{variant.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each tokens as token}
<tr>
<th scope="row"><code>{`{${token}}`}</code></th>
{#each variantLabels as variant}
<td>
<label class="sr-only" for={`${idPrefix}-${variant.key}-${token}`}>
{variant.label} value for {`{${token}}`}
</label>
<input
id={`${idPrefix}-${variant.key}-${token}`}
type="number"
step="any"
inputmode="decimal"
value={slotValue(variant.key, token)}
oninput={(event) =>
updateSlot(variant.key, token, event.currentTarget.value)}
{disabled}
required
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="empty-tokens">
Add a supported token to the shared template to define variant values.
</p>
{/if}
<div class="previews">
{#each variantLabels as variant}
<article>
<span>{variant.label} EXACT PREVIEW</span>
<p>{preview(variant.key) || 'Description preview'}</p>
</article>
{/each}
</div>
</section>
<style>
.authored-editor {
border: 1px solid var(--border, #333b44);
background: color-mix(in srgb, var(--surface, #12171d) 94%, transparent);
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--border, #333b44);
}
header h4,
header p {
margin: 0;
}
header h4 {
margin-top: 0.2rem;
}
header > div > p:last-child {
margin-top: 0.35rem;
color: var(--muted, #97a3af);
}
.variant-count {
flex: 0 0 auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.template-field {
margin: 1rem 1.1rem 0;
}
.template-field code,
th code {
color: var(--info, #6cb9ed);
}
.matrix-scroll {
margin: 1rem 1.1rem;
overflow-x: auto;
}
table {
width: 100%;
min-width: 34rem;
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--border, #333b44);
padding: 0.55rem;
text-align: left;
}
thead th {
background: var(--surface-dark, #0d1217);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
letter-spacing: 0.08em;
}
tbody th {
width: 10rem;
background: color-mix(in srgb, var(--surface, #12171d) 80%, black);
}
td input {
width: 100%;
min-width: 8rem;
}
.previews {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
padding: 0 1.1rem 1.1rem;
}
.previews article {
padding: 0.8rem 0.9rem;
border-left: 3px solid var(--info, #6cb9ed);
background: var(--surface-dark, #0d1217);
}
.previews span {
color: var(--info, #6cb9ed);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.previews p {
margin: 0.4rem 0 0;
white-space: pre-wrap;
}
.token-error,
.empty-tokens {
margin: 1rem 1.1rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.token-error {
border-color: var(--danger, #b7524c);
color: var(--danger-light, #ff8b84);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 40rem) {
header {
align-items: stretch;
flex-direction: column;
}
.variant-count {
align-self: flex-start;
}
}
</style>