Files
shd.build/src/lib/components/ProposalChangePreview.svelte
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

429 lines
27 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">
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
let {
module,
action,
payload,
currentSnapshot = null
}: {
module: string;
action: string;
payload: string;
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 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) => {
if (typeof value !== 'string' || (!value.startsWith('[') && !value.startsWith('{'))) return null;
try { return JSON.parse(value); } catch { return null; }
};
const attributeValue = (side: 'current' | 'proposed', key: string) => {
if (side === 'current') return currentSnapshot?.[key];
const names: Record<string, string> = {
calculation_type: 'calculationType',
gear_min: 'gearMin',
gear_max: 'gearMax',
weapon_min: 'weaponMin',
weapon_max: 'weaponMax',
cap_value: 'capValue'
};
return proposed[names[key] ?? key];
};
const specializationKind = $derived(
action === 'saveProgression' ? 'PROGRESSION PROFILE'
: action === 'saveSpecialization' ? 'SPECIALIZATION'
: action === 'saveFocus' ? 'FOCUS'
: action === 'saveAbility' ? 'CLASS ABILITY'
: action === 'saveCombatEffect' ? 'SHARED COMBAT EFFECT'
: action === 'saveFocusAbilities' ? 'FOCUS ABILITY SET'
: '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'
? [
{ label: 'XP POINTS', current: specializationRecord?.xp_points, proposed: proposed.xpPoints },
{ label: 'SHD TECH CACHE POINTS', current: specializationRecord?.shd_cache_points, proposed: proposed.cachePoints }
]
: action === 'saveSpecialization'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveFocus'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'PRIMARY ATTRIBUTE', current: specializationRecord?.primary_attribute, proposed: proposed.primaryAttribute },
{ label: 'DISPLAY ORDER', current: specializationRecord?.display_order, proposed: proposed.displayOrder },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveAbility'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ label: 'COOLDOWN · SECONDS', current: specializationRecord?.cooldown_seconds, proposed: proposed.cooldownSeconds },
{ label: 'DESCRIPTION', current: specializationRecord?.description, proposed: proposed.description }
]
: action === 'saveCombatEffect'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
]
: action === 'saveNode'
? [
{ label: 'NAME', current: specializationRecord?.name, proposed: proposed.name },
{ label: 'FOCUS', current: specializationRecord?.focus_id, proposed: proposed.focusId },
{ label: 'ROW', current: specializationRecord?.row_number, proposed: proposed.rowNumber },
{ label: 'COLUMN', current: specializationRecord?.column_number, proposed: proposed.columnNumber },
{ label: 'MAXIMUM RANK', current: specializationRecord?.max_rank, proposed: proposed.maxRank },
{ label: 'DESCRIPTION TEMPLATE', current: specializationRecord?.description_template, proposed: proposed.descriptionTemplate }
]
: [
{ label: 'FOCUS', current: specializationRecord?.name ?? specializationRecord?.id, proposed: proposed.id },
{ label: 'ABILITY SLOT 1 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 1)?.ability_id, proposed: proposed.abilityId1 },
{ label: 'ABILITY SLOT 2 · COMBAT SKILLS', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 2)?.ability_id, proposed: proposed.abilityId2 },
{ label: 'ABILITY SLOT 3 · TREE', current: (currentSnapshot?.abilities as any[])?.find((item) => Number(item.ability_slot) === 3)?.ability_id, proposed: proposed.abilityId3 }
]
);
const proposedEffects = $derived((jsonValue(proposed.effectsJson) ?? []) as any[]);
const proposedRanks = $derived((jsonValue(proposed.ranksJson) ?? []) as any[]);
const currentAbilityEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentAbilityAttack = $derived((currentSnapshot?.attack ?? null) as Record<string, unknown> | null);
const proposedAbilityAttack = $derived((jsonValue(proposed.attackJson) ?? null) as Record<string, unknown> | null);
const currentNodeRanks = $derived((currentSnapshot?.ranks ?? []) as any[]);
const currentNodeEffects = $derived((currentSnapshot?.effects ?? []) as any[]);
const currentNodeCosts = $derived((currentSnapshot?.costs ?? []) as any[]);
const currentRankProfiles = $derived((currentSnapshot?.rankProfiles ?? []) as any[]);
const calculationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
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 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();
const baseDamage = attack.baseDamage ?? attack.base_damage;
const damageType = attack.damageType ?? attack.damage_type;
const hits = attack.hitsPerActivation ?? attack.hits_per_activation;
const castSeconds = attack.castSeconds ?? attack.cast_seconds;
const targetCap = attack.targetCap ?? attack.target_cap;
const chargeCount = attack.chargeCount ?? attack.charge_count;
const chargePolicy = attack.chargePolicy ?? attack.charge_policy;
const lines = [
`BASE DAMAGE · ${shown(baseDamage)} · ${readableToken(damageType)}`,
`DELIVERY · ${readableToken(delivery)} · ${shown(hits)} HIT${Number(hits) === 1 ? '' : 'S'} PER ACTIVATION · TARGET CAP ${shown(targetCap)}`,
`TIMING · ${shown(castSeconds)}s CAST`,
`CHARGES · ${shown(chargeCount)} · ${readableToken(chargePolicy)}`
];
if (delivery === 'periodic') {
const interval = attack.tickIntervalSeconds ?? attack.tick_interval_seconds;
const duration = attack.damageDurationSeconds ?? attack.damage_duration_seconds;
lines.splice(3, 0, `PERIODIC WINDOW · EVERY ${shown(interval)}s FOR ${shown(duration)}s`);
}
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>
<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>
<b>{currentSnapshot ? 'BEFORE / AFTER' : 'NEW RECORD'}</b>
</div>
{#if module === 'attributes' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE</span><b>{currentSnapshot ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if currentSnapshot}
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('current', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
</div>
{:else}
<div class="proposal-empty-state"><b>NEW ATTRIBUTE DEFINITION</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
<div class="preview-field description"><span>DESCRIPTION · OPTIONAL</span><strong>{shown(attributeValue('proposed', 'description'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
</div>
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
<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">
<header><span>CURRENT LIVE · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>{calculationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if calculationRecord}<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(calculationRecord.name)}</strong></div><div><span>STATE</span><strong>{shown(calculationRecord.status)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{(currentSnapshot?.stages as any[])?.length ?? 0}</strong></div><div><span>WEAPON PROFILES</span><strong>{(currentSnapshot?.profiles as any[])?.length ?? 0}</strong></div><div><span>SKILL PROFILES</span><strong>{(currentSnapshot?.skillProfiles as any[])?.length ?? 0}</strong></div>{/if}</div>{:else}<div class="proposal-empty-state"><b>NEW {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</b><span>No live registry record will be replaced.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {action === 'saveRevision' ? 'CALCULATION REVISION' : 'CALIBRATION CASE'}</span><b>AFTER</b></header>
<div class="proposal-field-summary"><div><span>NAME</span><strong>{shown(proposed.name)}</strong></div>{#if action === 'saveRevision'}<div><span>STAGES</span><strong>{proposedCalculationStages.length}</strong></div><div><span>WEAPON PROFILES</span><strong>{proposedCalculationProfiles.length}</strong></div><div><span>SKILL PROFILES</span><strong>{proposedSkillCalculationProfiles.length}</strong></div><div><span>BASE REVISION</span><strong>{shown(proposed.basedOnRevisionId)}</strong></div>{:else}<div><span>STATE</span><strong>{shown(proposed.status)}</strong></div><div><span>MEASURED WEAPON</span><strong>{proposed.selectedWeapon === '1' ? 'SECONDARY' : 'PRIMARY'}</strong></div><div><span>CORE OBSERVATIONS</span><strong>{['bodyHit','criticalBodyHit','headshot','criticalHeadshot','magazineSeconds','reloadSeconds'].filter((field) => proposed[field]).length} / 6</strong></div>{/if}</div>
{#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)}
<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>
{#if specializationRecord}
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{currentAbilityEffects.length}</b></header>
{#each currentAbilityEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
<div class="proposal-structured-list">
<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}
<section><header><span>RANK {profile.rank}</span><b>{profile.point_cost} POINTS</b></header><p>{Number(profile.credit_cost).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
{:else}
<div class="proposal-empty-state"><b>NEW {specializationKind}</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · {specializationKind}</span><b>AFTER</b></header>
<div class="proposal-field-summary specialization-fields">
{#each specializationFields as field}
<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>
{/each}
</div>
{#if action === 'saveAbility'}
<div class="proposal-structured-list">
<header><span>STRUCTURED EFFECTS</span><b>{proposedEffects.length}</b></header>
{#each proposedEffects as effect}<p>{effectSummary(effect)}</p>{:else}<p>NO STRUCTURED EFFECTS</p>{/each}
</div>
<div class="proposal-structured-list">
<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}
<section><header><span>RANK {rank}</span><b>{shown(proposed[`rank${rank}PointCost`])} POINTS</b></header><p>{Number(proposed[`rank${rank}CreditCost`] || 0).toLocaleString()} CREDITS</p></section>
{/each}
</div>
{/if}
</article>
</div>
{:else}
<div class="proposal-editor-preview generic after">
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each entries as entry}
<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}
<strong>{shown(entry.value)}</strong>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</section>