diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index af71cce..d9e1ccb 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -749,6 +749,11 @@ error, or danger indicators. explicit “None” option when the field is optional. - Primary save actions sit at the lower right. Cancel is secondary. - Destructive/archive actions are visually separated from save actions. +- Server validation returns errors for specific editor areas. Show a focused + error summary with links to each affected area, mark invalid native controls + with `aria-invalid`, and place the error text beside the relevant field or + section. Preserve all entered values. Color supports the text and border + treatment; it never carries the error state alone. ## Interaction rules diff --git a/src/app.css b/src/app.css index 79374be..327771e 100644 --- a/src/app.css +++ b/src/app.css @@ -2380,3 +2380,16 @@ grid-column:auto } } +/* Linked field and section validation for dense ISAC editors. */ +.validation-summary{display:grid;gap:7px;margin:10px 0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--shd-danger) 55%,var(--shd-border));border-left:2px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 8%,var(--shd-surface));scroll-margin-top:calc(var(--shd-header-height) + 12px)} +.validation-summary:focus-visible{outline:1px solid var(--shd-danger);outline-offset:2px} +.validation-summary>strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:1px} +.validation-summary ul{display:grid;gap:4px;margin:0;padding:0;list-style:none} +.validation-summary a{display:grid;grid-template-columns:minmax(120px,.32fr) minmax(0,1fr);gap:8px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.4} +.validation-summary a:hover,.validation-summary a:focus-visible{color:var(--shd-text)} +.validation-summary a span{color:var(--shd-danger);font-weight:900;text-transform:uppercase;letter-spacing:.6px} +.validation-area-error{scroll-margin-top:calc(var(--shd-header-height) + 12px)} +.validation-area-error.field,.validation-area-error>.field,.validation-area-error .icon-upload-field,.validation-area-error .mechanic-editor{border-color:var(--shd-danger)!important} +.field-error{display:block;margin:5px 0 0;color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.4} +.mechanic-field-error{padding-left:10px;border-left:2px solid var(--shd-danger)} +@media(max-width:560px){.validation-summary a{grid-template-columns:1fr;gap:1px}} diff --git a/src/lib/calculations/talent-description.test.ts b/src/lib/calculations/talent-description.test.ts new file mode 100644 index 0000000..e083e33 --- /dev/null +++ b/src/lib/calculations/talent-description.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import type { ConditionalMechanicDraftV1 } from './mechanic-editor'; +import { missingTalentDescriptionSlots, numberedTalentOutcomes } from './talent-description'; + +const draft = (outcomes: ConditionalMechanicDraftV1['clauses'][number]['outcomes']): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [{ + key: 'primary', + label: 'Primary', + event: 'enemy_killed', + if: { kind: 'true' }, + reducer: { kind: 'none' }, + mutations: [], + outcomes + }] +}); + +describe('talent description outcomes', () => { + it('accepts a numbered restoration outcome as a description value', () => { + const outcomes = numberedTalentOutcomes(draft([{ + key: 'restore-health', + label: 'Restore Health', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'health', + delivery: 'continuous', + durationSeconds: 3, + basis: 'max_health_percent', + descriptionSlot: 1, + variants: { + I: { value: 240 }, + II: { value: 336 } + } + }])); + + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ kind: 'restoration', descriptionSlot: 1 }); + expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]); + }); + + it('reports exact missing slots instead of relying on outcome count', () => { + const outcomes = numberedTalentOutcomes(draft([{ + key: 'damage', + label: 'Damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + basis: 'flat', + descriptionSlot: 2, + variants: { + I: { value: 10 }, + II: { value: 14 } + } + }])); + + expect(missingTalentDescriptionSlots('{1} then {2} and {3}.', outcomes)).toEqual([1, 3]); + }); +}); diff --git a/src/lib/calculations/talent-description.ts b/src/lib/calculations/talent-description.ts new file mode 100644 index 0000000..601d905 --- /dev/null +++ b/src/lib/calculations/talent-description.ts @@ -0,0 +1,38 @@ +import type { + ConditionalMechanicDraftV1, + ConditionalMechanicOutcomeDraft +} from '$lib/calculations/mechanic-editor'; + +const VALUE_BEARING_OUTCOME_KINDS = new Set([ + 'attribute_modifier', + 'direct_damage', + 'restoration', + 'resource_cost' +]); + +export const numberedTalentOutcomes = ( + draft: ConditionalMechanicDraftV1 +): ConditionalMechanicOutcomeDraft[] => { + const outcomes = draft.clauses + .flatMap((clause) => clause.outcomes) + .filter((outcome) => + VALUE_BEARING_OUTCOME_KINDS.has(outcome.kind) + && Number.isInteger(outcome.descriptionSlot) + && Number(outcome.descriptionSlot) > 0 + ) + .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); + + return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()]; +}; + +export const missingTalentDescriptionSlots = ( + template: string, + outcomes: ConditionalMechanicOutcomeDraft[] +) => { + const availableSlots = new Set(outcomes.map((outcome) => Number(outcome.descriptionSlot))); + return [...new Set( + [...template.matchAll(/\{(\d+)\}/g)] + .map((match) => Number(match[1])) + .filter((slot) => !availableSlots.has(slot)) + )]; +}; diff --git a/src/routes/isac/talents/+page.server.ts b/src/routes/isac/talents/+page.server.ts index 28026b4..0b061af 100644 --- a/src/routes/isac/talents/+page.server.ts +++ b/src/routes/isac/talents/+page.server.ts @@ -15,15 +15,14 @@ import { saveConditionalRuleInTransaction } from '$lib/server/conditional-effects'; import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; +import { + missingTalentDescriptionSlots, + numberedTalentOutcomes +} from '$lib/calculations/talent-description'; -type SharedEffect = { statId: string; valueI: number; valueII: number }; - -const descriptionOutcomes = (draft: ReturnType) => { - const outcomes = draft.clauses.flatMap((clause) => clause.outcomes) - .filter((outcome) => outcome.descriptionSlot) - .sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot)); - return [...new Map(outcomes.map((outcome) => [outcome.descriptionSlot, outcome])).values()]; -}; +type SharedEffect = { slot: number; statId?: string; valueI: number; valueII: number }; +type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes'; +type TalentValidationError = { area: TalentErrorArea; message: string }; export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); @@ -136,16 +135,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }; }; -const parseEffects = (raw: FormDataEntryValue | null): SharedEffect[] => { - const value = JSON.parse(String(raw ?? '[]')) as Array<{ statId: unknown; valueI: unknown; valueII: unknown }>; - if (!Array.isArray(value)) throw new Error(); - return value.map((effect) => ({ - statId: String(effect.statId), - valueI: Number(effect.valueI), - valueII: Number(effect.valueII) - })); -}; - const displayValue = (value: number, unit: unknown) => { const normalizedUnit = unit === 'flat' ? '' : String(unit ?? ''); return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`; @@ -169,18 +158,34 @@ const renderDescription = ( ) => template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => { if (expression === 'duration') return durationSeconds === null ? placeholder : renderMechanicValue(durationSeconds, 'second'); if (expression === 'max_stacks') return maxStacks === null ? placeholder : renderMechanicValue(maxStacks, 'stack'); - const effect = effects[Number(expression) - 1]; - return effect ? displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId)) : placeholder; + const effect = effects.find((item) => item.slot === Number(expression)); + return effect + ? displayValue( + tier === 'I' ? effect.valueI : effect.valueII, + effect.statId ? units.get(effect.statId) : undefined + ) + : placeholder; }); export const actions: Actions = { saveTalent: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); + const id = String(form.get('id') ?? ''); + const talentMode = id ? 'edit' : 'add'; + const reject = (status: number, errors: TalentValidationError[]) => fail(status, { + talentError: errors[0]?.message ?? 'The talent could not be saved.', + talentErrors: errors, + talentMode, + resultToken: randomUUID() + }); let iconMutation; try { iconMutation = await readIconMutation(form); } - catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; } - const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? ''); + catch (error) { + if (error instanceof GameDataIconValidationError) return reject(400, [{ area: 'icon', message: error.message }]); + throw error; + } + const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? ''); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim(); const db = await getDatabase(); const conditionalRuleRaw = form.get('conditionalRule'); @@ -195,36 +200,64 @@ export const actions: Actions = { allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) }); } catch (error) { - return fail(400, { talentError: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' }); + return reject(400, [{ + area: 'mechanic', + message: error instanceof Error ? error.message : 'The conditional mechanic is invalid.' + }]); } const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null; const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null; const maxStacks = conditionalRule.pools.length ? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) : null; - const sharedEffects: SharedEffect[] = descriptionOutcomes(conditionalRule) - .filter((effect) => effect.kind === 'attribute_modifier' && effect.attributeId) + const descriptionOutcomes = numberedTalentOutcomes(conditionalRule); + const sharedEffects: SharedEffect[] = descriptionOutcomes .map((effect) => ({ - statId: effect.attributeId ?? '', + slot: Number(effect.descriptionSlot), + ...(effect.kind === 'attribute_modifier' && effect.attributeId ? { statId: effect.attributeId } : {}), valueI: effect.variants.I.value, valueII: effect.variants.II.value })); - if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' }); - if (!descriptionTemplate || !conditionalRule.clauses.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, mechanic branch, and at least one numbered Attribute outcome are required.' }); - if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every outcome needs valid Tier I and Tier II values.' }); - const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); - if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' }); - if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' }); - if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' }); + const validationErrors: TalentValidationError[] = []; + if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) { + validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' }); + } + if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' }); + if (!conditionalRule.clauses.length) validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch.' }); + if (!sharedEffects.length) validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome.' }); + if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) { + validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' }); + } + const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes); + if (missingSlots.length) { + validationErrors.push({ + area: 'description', + message: `Description placeholder${missingSlots.length === 1 ? '' : 's'} ${missingSlots.map((slot) => `{${slot}}`).join(', ')} must reference numbered outcomes.` + }); + validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' }); + } + if (descriptionTemplate.includes('{duration}') && durationSeconds === null) { + validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' }); + validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' }); + } + if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) { + validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' }); + validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' }); + } + if (validationErrors.length) return reject(400, validationErrors); const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category; - if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' }); + if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) { + return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]); + } const statIds = conditionalRule.clauses.flatMap((clause) => clause.outcomes) .map((effect) => effect.attributeId) .filter((value): value is string => Boolean(value)); const statRows = statIds.length ? await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).all(...statIds) : []; - if (statRows.length !== new Set(statIds).size) return fail(400, { talentError: 'One or more selected attributes are unavailable.' }); + if (statRows.length !== new Set(statIds).size) { + return reject(400, [{ area: 'outcomes', message: 'One or more selected attributes are unavailable.' }]); + } const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit])); const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString(); const before = id ? { @@ -258,7 +291,7 @@ export const actions: Actions = { const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier); const tierId = String(tierRow?.id); await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId); - for (const outcome of descriptionOutcomes(conditionalRule)) { + for (const outcome of descriptionOutcomes) { if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue; await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run( randomUUID(), diff --git a/src/routes/isac/talents/+page.svelte b/src/routes/isac/talents/+page.svelte index 66bc3e3..255570f 100644 --- a/src/routes/isac/talents/+page.svelte +++ b/src/routes/isac/talents/+page.svelte @@ -9,11 +9,34 @@ import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte'; import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; + import { tick } from 'svelte'; let { data, form } = $props(); const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value })); let talent = $state(null); let handledResultToken = $state(''); let iconResetToken = $state(0); + let validationSummary = $state(null); + type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes'; + type TalentValidationError = { area: TalentErrorArea; message: string }; + const actionForm = $derived(form as Record | null | undefined); + const talentErrors = $derived( + Array.isArray(actionForm?.talentErrors) + ? actionForm.talentErrors as TalentValidationError[] + : actionForm?.talentError + ? [{ area: 'mechanic', message: String(actionForm.talentError) }] as TalentValidationError[] + : [] + ); + const errorsFor = (area: TalentErrorArea) => talentErrors.filter((error) => error.area === area); + const hasErrors = (areas: TalentErrorArea[]) => areas.some((area) => errorsFor(area).length > 0); + const areaLabel = (area: TalentErrorArea) => ({ + icon: 'Talent icon', + identity: 'Talent identity', + description: 'Description template', + mechanic: 'Mechanic program', + outcomes: 'Mechanic outcomes' + }[area]); + const areaTarget = (area: TalentErrorArea) => + area === 'outcomes' ? 'talent-mechanic' : `talent-${area}`; const blankRule = (): ConditionalMechanicDraftV1 => ({ version: 1, simulationReadiness: 'captured_only', @@ -81,6 +104,8 @@ if (form.talentSaved) { if (form.talentMode === 'add') iconResetToken += 1; talent = form.talentMode === 'add' ? blankTalent() : null; + } else if (talentErrors.length) { + void tick().then(() => validationSummary?.focus()); } }); @@ -95,21 +120,43 @@

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

-
- + {#if talentErrors.length} + + {/if} +
+
+ + {#each errorsFor('icon') as error}{error.message}{/each} +
+
+ + + {#each errorsFor('identity') as error}{error.message}{/each} +
+
+
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

- - {#if form?.talentError}

{form.talentError}

{/if} +
+ + {#each [...errorsFor('mechanic'), ...errorsFor('outcomes')] as error}{error.message}{/each} +