+
+ Pick a template and fill in the form. Each section covers a different record. Blank fields are omitted from the output automatically, so no rush to finish everything.
+
+
+ Characters save to your browser. You can also export to a file or generate a share link: the link itself encodes the full set of records, so functionally it's a save file.
+
+
+ Share links let the recipient see a preview of your records, with the option to import the character into their own roster.
+
+
+ This tool is entirely data-driven in XML, and it's already set up for template sharing. A visual template editor is coming soon, so anybody can create their own templates and share them between one another.
+
+
+ Cheers.
+
+
diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte
new file mode 100644
index 0000000..2296da8
--- /dev/null
+++ b/src/lib/components/ImportModal.svelte
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+ { e.stopPropagation(); showTemplateSwitcher = !showTemplateSwitcher; }}
+ class="hover:underline"
+ style="color: var(--text-muted);"
+ >
+ {character.template.name} template
+
+ {#if showTemplateSwitcher}
+
+ {#each presets as preset}
+ { e.stopPropagation(); switchTemplate(preset); }}
+ class="block w-full text-left px-3 py-2 text-sm hover:opacity-80"
+ style={preset.id === character.template.id ? 'color: var(--accent);' : 'color: var(--text);'}
+ >
+ {preset.name}
+ {#if preset.description}
+ {preset.description}
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {#if pendingMigration}
+ { showMigrationModal = true; }}
+ class="hover:underline"
+ style="color: var(--accent);"
+ >
+ update available
+
+ {/if}
+
+
+
+ {#if suggestion}
+
+
+ {suggestion.reason}
+ Switching will keep your existing data.
+
+
+ switchTemplate(suggestion!.template)}
+ class="px-3 py-1 rounded text-sm border hover:opacity-80"
+ style="border-color: var(--accent); color: var(--accent);"
+ >
+ Switch to {suggestion.template.name}
+
+ { dismissed = suggestion!.template.id; }}
+ class="px-3 py-1 rounded text-sm hover:opacity-80"
+ style="color: var(--text-muted);"
+ >
+ Dismiss
+
+
+
+ {/if}
+
+ {#each character.template.records as record}
+
{
+ character.data[key] = value;
+ if (speciesKeys.has(key)) {
+ for (const depKey of speciesDependentKeys) {
+ character.data[depKey] = '';
+ }
+ }
+ roster.scheduleSave(character);
+ }}
+ />
+ {/each}
+
+
+{#if showMigrationModal && pendingMigration}
+ {
+ if (showTemplateSwitcher) {
+ showTemplateSwitcher = false;
+ }
+}} />
diff --git a/src/lib/components/ShareMenu.svelte b/src/lib/components/ShareMenu.svelte
new file mode 100644
index 0000000..2da40a2
--- /dev/null
+++ b/src/lib/components/ShareMenu.svelte
@@ -0,0 +1,70 @@
+
+
+
+ { e.stopPropagation(); onToggle(); }}
+ class="flex items-center justify-center h-[30px] rounded border hover:opacity-80 {copied ? 'gap-1 px-2' : 'w-[30px]'}"
+ style="border-color: var(--border);"
+ title="Share & export"
+ >
+ {#if copied}
+ Copied!
+ {:else}
+
+ {/if}
+
+
+ {#if open}
+
+
+ {#if copied}
+ Copied!
+ {:else}
+ Copy share link
+ {/if}
+
+
+ Export to file
+
+
+ {/if}
+
diff --git a/src/lib/components/TemplatePicker.svelte b/src/lib/components/TemplatePicker.svelte
new file mode 100644
index 0000000..5089c74
--- /dev/null
+++ b/src/lib/components/TemplatePicker.svelte
@@ -0,0 +1,31 @@
+
+
+
+ New Character
+
+ {#each presets as preset}
+ pick(preset)}
+ class="text-left px-3 py-2 rounded border hover:opacity-80"
+ style="border-color: var(--border);"
+ >
+ {preset.name}
+ {#if preset.description}
+ {preset.description}
+ {/if}
+
+ {/each}
+
+
diff --git a/src/lib/components/fields/CheckboxField.svelte b/src/lib/components/fields/CheckboxField.svelte
new file mode 100644
index 0000000..8964798
--- /dev/null
+++ b/src/lib/components/fields/CheckboxField.svelte
@@ -0,0 +1,73 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+
+ {#each field.options as opt}
+
+ toggle(opt.value)}
+ />
+ {opt.label}
+
+ {/each}
+ {#each customValues as cv}
+
+ removeCustom(cv)} />
+ {cv}
+ removeCustom(cv)} class="hover:opacity-60">
+
+
+
+ {/each}
+
+
+ { if (e.key === 'Enter') { e.preventDefault(); addCustom(); } }}
+ class="rounded px-3 py-1.5 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+
+ Add
+
+
+
diff --git a/src/lib/components/fields/CitizenshipField.svelte b/src/lib/components/fields/CitizenshipField.svelte
new file mode 100644
index 0000000..d15c9a2
--- /dev/null
+++ b/src/lib/components/fields/CitizenshipField.svelte
@@ -0,0 +1,69 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+ {#if isCustom}
+
+ onChange((e.target as HTMLInputElement).value)}
+ class="block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+ { custom = false; onChange(''); }}
+ class="text-sm whitespace-nowrap hover:opacity-80"
+ style="color: var(--text-muted);"
+ >
+ Cancel
+
+
+ {:else}
+ handleSelect((e.target as HTMLSelectElement).value)}
+ class="mt-1 block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ >
+ —
+ {#each filtered as c}
+ {c.name}
+ {/each}
+ Other...
+
+ {/if}
+
diff --git a/src/lib/components/fields/DateField.svelte b/src/lib/components/fields/DateField.svelte
new file mode 100644
index 0000000..aa5002e
--- /dev/null
+++ b/src/lib/components/fields/DateField.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+ onChange((e.target as HTMLInputElement).value)}
+ class="mt-1 block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+
diff --git a/src/lib/components/fields/DynamicField.svelte b/src/lib/components/fields/DynamicField.svelte
new file mode 100644
index 0000000..3893c85
--- /dev/null
+++ b/src/lib/components/fields/DynamicField.svelte
@@ -0,0 +1,59 @@
+
+
+{#if field.type === 'name'}
+
+{:else if field.type === 'text'}
+
+{:else if field.type === 'textarea'}
+
+{:else if field.type === 'list'}
+
+{:else if field.type === 'number'}
+
+{:else if field.type === 'select'}
+
+{:else if field.type === 'multi-select'}
+
+{:else if field.type === 'checkbox'}
+
+{:else if field.type === 'date'}
+
+{:else if field.type === 'height'}
+
+{:else if field.type === 'weight'}
+
+{:else if field.type === 'species'}
+
+{:else if field.type === 'subspecies'}
+
+{:else if field.type === 'citizenship'}
+
+{:else if field.type === 'languages'}
+
+{:else if field.type === 'separator'}
+
+{/if}
diff --git a/src/lib/components/fields/HeightField.svelte b/src/lib/components/fields/HeightField.svelte
new file mode 100644
index 0000000..a0e308e
--- /dev/null
+++ b/src/lib/components/fields/HeightField.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+
+ onChange(Number((e.target as HTMLInputElement).value))}
+ class="w-24 rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+
+ cm{#if converted} ({converted}){/if}
+
+
+
diff --git a/src/lib/components/fields/LanguagesField.svelte b/src/lib/components/fields/LanguagesField.svelte
new file mode 100644
index 0000000..d69b2a7
--- /dev/null
+++ b/src/lib/components/fields/LanguagesField.svelte
@@ -0,0 +1,24 @@
+
+
+
diff --git a/src/lib/components/fields/ListField.svelte b/src/lib/components/fields/ListField.svelte
new file mode 100644
index 0000000..4a0e6a0
--- /dev/null
+++ b/src/lib/components/fields/ListField.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+
+
diff --git a/src/lib/components/fields/MultiSelectField.svelte b/src/lib/components/fields/MultiSelectField.svelte
new file mode 100644
index 0000000..d055993
--- /dev/null
+++ b/src/lib/components/fields/MultiSelectField.svelte
@@ -0,0 +1,100 @@
+
+
+
+
{field.label}{#if field.required} * {/if}
+
+
+ {#each value as val}
+
+ {displayLabel(val)}
+ remove(val)} class="hover:opacity-60">
+
+
+
+ {/each}
+
+
+
+
{ open = true; }}
+ onblur={() => { setTimeout(() => { open = false; }, 150); }}
+ onkeydown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (available.length) add(available[0].value);
+ else addCustom();
+ }
+ }}
+ class="block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+ {#if open && (available.length || input.trim())}
+
+ {#each available as opt}
+
+ { e.preventDefault(); add(opt.value); }}
+ class="block w-full text-left px-3 py-1.5 text-sm hover:opacity-80"
+ style="color: var(--text);"
+ >
+ {opt.label}
+
+
+ {/each}
+ {#if input.trim() && !field.options.some((o) => o.label.toLowerCase() === input.trim().toLowerCase())}
+
+ { e.preventDefault(); addCustom(); }}
+ class="block w-full text-left px-3 py-1.5 text-sm"
+ style="color: var(--text-muted);"
+ >
+ Add "{input.trim()}"
+
+
+ {/if}
+
+ {/if}
+
+
diff --git a/src/lib/components/fields/NumberField.svelte b/src/lib/components/fields/NumberField.svelte
new file mode 100644
index 0000000..3e8986b
--- /dev/null
+++ b/src/lib/components/fields/NumberField.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+
+ onChange(Number((e.target as HTMLInputElement).value))}
+ class="block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+ {#if field.unit}
+ {field.unit}
+ {/if}
+
+
diff --git a/src/lib/components/fields/SelectField.svelte b/src/lib/components/fields/SelectField.svelte
new file mode 100644
index 0000000..2fa6951
--- /dev/null
+++ b/src/lib/components/fields/SelectField.svelte
@@ -0,0 +1,54 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+ {#if isCustom}
+
+ onChange((e.target as HTMLInputElement).value)}
+ class="block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+ { custom = false; onChange(''); }}
+ class="text-sm whitespace-nowrap hover:opacity-80"
+ style="color: var(--text-muted);"
+ >
+ Cancel
+
+
+ {:else}
+ handleSelect((e.target as HTMLSelectElement).value)}
+ class="mt-1 block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ >
+ —
+ {#each field.options as opt}
+ {opt.label}
+ {/each}
+ Other...
+
+ {/if}
+
diff --git a/src/lib/components/fields/SeparatorField.svelte b/src/lib/components/fields/SeparatorField.svelte
new file mode 100644
index 0000000..573f435
--- /dev/null
+++ b/src/lib/components/fields/SeparatorField.svelte
@@ -0,0 +1,15 @@
+
+
+{#if field.label}
+
+
+ {field.label}
+
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/fields/SpeciesField.svelte b/src/lib/components/fields/SpeciesField.svelte
new file mode 100644
index 0000000..4cdb979
--- /dev/null
+++ b/src/lib/components/fields/SpeciesField.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+ {field.label}{#if field.required} * {/if}
+ onChange((e.target as HTMLSelectElement).value)}
+ class="mt-1 block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ >
+ —
+ {#each species as sp}
+ {sp.name}
+ {/each}
+
+
+ {#if selected?.description}
+
{selected.description}
+ {/if}
+
diff --git a/src/lib/components/fields/SubspeciesField.svelte b/src/lib/components/fields/SubspeciesField.svelte
new file mode 100644
index 0000000..d0a36d0
--- /dev/null
+++ b/src/lib/components/fields/SubspeciesField.svelte
@@ -0,0 +1,73 @@
+
+
+{#if subs.length > 0 || isCustom}
+
+
+ {label}{#if field.required} * {/if}
+ {#if isCustom}
+
+ onChange((e.target as HTMLInputElement).value)}
+ class="block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+ { custom = false; onChange(''); }}
+ class="text-sm whitespace-nowrap hover:opacity-80"
+ style="color: var(--text-muted);"
+ >
+ Cancel
+
+
+ {:else}
+ handleSelect((e.target as HTMLSelectElement).value)}
+ class="mt-1 block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ >
+ —
+ {#each subs as sub}
+ {sub.name}
+ {/each}
+ Other...
+
+ {/if}
+
+ {#if selected?.description}
+
{selected.description}
+ {/if}
+
+{/if}
diff --git a/src/lib/components/fields/TextField.svelte b/src/lib/components/fields/TextField.svelte
new file mode 100644
index 0000000..f10a138
--- /dev/null
+++ b/src/lib/components/fields/TextField.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+ onChange((e.target as HTMLInputElement).value)}
+ class="mt-1 block w-full rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+
diff --git a/src/lib/components/fields/TextareaField.svelte b/src/lib/components/fields/TextareaField.svelte
new file mode 100644
index 0000000..16446d6
--- /dev/null
+++ b/src/lib/components/fields/TextareaField.svelte
@@ -0,0 +1,17 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+
+
diff --git a/src/lib/components/fields/WeightField.svelte b/src/lib/components/fields/WeightField.svelte
new file mode 100644
index 0000000..063fe94
--- /dev/null
+++ b/src/lib/components/fields/WeightField.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {field.label}{#if field.required} * {/if}
+
+ onChange(Number((e.target as HTMLInputElement).value))}
+ class="w-24 rounded px-3 py-2 text-sm"
+ style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
+ />
+
+ kg{#if converted} ({converted} lb){/if}
+
+
+
diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts
new file mode 100644
index 0000000..6d05e8c
--- /dev/null
+++ b/src/lib/data/index.ts
@@ -0,0 +1,13 @@
+import { parseSpecies, parseCitizenships, parseLanguages } from './parse';
+import citizenshipsXml from '../../../data/citizenships.xml?raw';
+import languagesXml from '../../../data/languages.xml?raw';
+
+const speciesModules = import.meta.glob('../../../data/species/*.xml', {
+ query: '?raw',
+ import: 'default',
+ eager: true
+});
+
+export const species = Object.values(speciesModules).map((xml) => parseSpecies(xml as string));
+export const citizenships = parseCitizenships(citizenshipsXml);
+export const languages = parseLanguages(languagesXml);
diff --git a/src/lib/data/parse.ts b/src/lib/data/parse.ts
new file mode 100644
index 0000000..0379da4
--- /dev/null
+++ b/src/lib/data/parse.ts
@@ -0,0 +1,122 @@
+import { XMLParser } from 'fast-xml-parser';
+import type { SpeciesData, CitizenshipData, LanguageData } from './types';
+import type { Template, RecordDef, FieldDef, SelectOption } from '../types';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ isArray: (name) => ['entry', 'ref', 'field', 'record', 'option', 'citizenship', 'language'].includes(name),
+ trimValues: true
+});
+
+function extractRefs(container: any): string[] {
+ if (!container?.ref) return [];
+ return container.ref.map((r: any) => r['@_id']);
+}
+
+export function parseSpecies(xml: string): SpeciesData {
+ const root = parser.parse(xml).species;
+ const subspecies = root.subspecies?.entry ?? [];
+
+ return {
+ id: root['@_id'],
+ name: root['@_name'],
+ description: root.description?.trim(),
+ subspeciesLabel: root['@_subspeciesLabel'],
+ languages: extractRefs(root.languages),
+ citizenships: extractRefs(root.citizenships),
+ subspecies: subspecies.map((e: any) => ({
+ id: e['@_id'],
+ name: e['@_name'],
+ description: e.description?.trim()
+ }))
+ };
+}
+
+export function parseCitizenships(xml: string): CitizenshipData[] {
+ const root = parser.parse(xml).citizenships;
+ return root.citizenship.map((c: any) => ({
+ id: c['@_id'],
+ name: c['@_name'],
+ description: c.description?.trim()
+ }));
+}
+
+export function parseLanguages(xml: string): LanguageData[] {
+ const root = parser.parse(xml).languages;
+ return root.language.map((l: any) => ({
+ id: l['@_id'],
+ name: l['@_name'],
+ description: l.description?.trim()
+ }));
+}
+
+function parseOptions(field: any): SelectOption[] {
+ if (!field.option) return [];
+ return field.option.map((o: any) => ({
+ value: o['@_value'],
+ label: o['@_label']
+ }));
+}
+
+function parseField(raw: any): FieldDef {
+ const base = {
+ label: raw['@_label'],
+ ...(raw['@_required'] === 'true' && { required: true }),
+ ...(raw['@_from'] && { from: raw['@_from'] })
+ };
+ const type = raw['@_type'];
+
+ switch (type) {
+ case 'name':
+ case 'text':
+ case 'textarea':
+ case 'date':
+ return { ...base, type, placeholder: raw['@_placeholder'] };
+ case 'list':
+ case 'height':
+ case 'weight':
+ case 'species':
+ case 'subspecies':
+ case 'citizenship':
+ case 'languages':
+ return { ...base, type };
+ case 'separator':
+ return { type: 'separator', label: raw['@_label'] ?? '' };
+ case 'number':
+ return {
+ ...base,
+ type,
+ min: raw['@_min'] != null ? Number(raw['@_min']) : undefined,
+ max: raw['@_max'] != null ? Number(raw['@_max']) : undefined,
+ unit: raw['@_unit']
+ };
+ case 'select':
+ case 'multi-select':
+ case 'checkbox':
+ return { ...base, type, options: parseOptions(raw) };
+ default:
+ return { ...base, type: 'text' };
+ }
+}
+
+export function parseTemplate(xml: string, id: string): Template {
+ const root = parser.parse(xml).template;
+
+ const records: RecordDef[] = root.record.map((r: any) => ({
+ type: r['@_type'],
+ preamble: r.preamble?.trim(),
+ note: r.note?.trim(),
+ fields: r.field.map(parseField)
+ }));
+
+ const speciesAttr = root['@_species'];
+ return {
+ id,
+ name: root['@_name'],
+ description: root.description ?? '',
+ schemaVersion: Number(root['@_schemaVersion'] ?? 1),
+ ...(speciesAttr && { species: speciesAttr.split(',').map((s: string) => s.trim()) }),
+ records
+ };
+}
diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts
new file mode 100644
index 0000000..cbeff51
--- /dev/null
+++ b/src/lib/data/types.ts
@@ -0,0 +1,21 @@
+export interface SpeciesData {
+ id: string;
+ name: string;
+ description?: string;
+ subspeciesLabel: string;
+ subspecies: { id: string; name: string; description?: string }[];
+ languages: string[];
+ citizenships: string[];
+}
+
+export interface CitizenshipData {
+ id: string;
+ name: string;
+ description?: string;
+}
+
+export interface LanguageData {
+ id: string;
+ name: string;
+ description?: string;
+}
diff --git a/src/lib/file.test.ts b/src/lib/file.test.ts
new file mode 100644
index 0000000..d475bf6
--- /dev/null
+++ b/src/lib/file.test.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect } from 'vitest';
+import { exportCharacter, parseCharacterFile } from './file';
+import { presets } from './presets';
+import type { Character } from './types';
+
+const standardPreset = presets.find((p) => p.id === 'preset:standard')!;
+
+const testCharacter: Character = {
+ id: 'abc-123',
+ template: standardPreset,
+ data: {
+ name: 'Yury Zakharov',
+ species: 'human',
+ 'employment-history': 'Shaft Miner'
+ }
+};
+
+describe('exportCharacter', () => {
+ it('returns valid JSON with version, templateId, template, and data', () => {
+ const json = exportCharacter(testCharacter);
+ const parsed = JSON.parse(json);
+ expect(parsed.version).toBe(1);
+ expect(parsed.templateId).toBe('preset:standard');
+ expect(parsed.template).toBeDefined();
+ expect(parsed.template.name).toBe('General');
+ expect(parsed.data).toEqual({
+ name: 'Yury Zakharov',
+ species: 'human',
+ 'employment-history': 'Shaft Miner'
+ });
+ });
+
+ it('strips template id from embedded template', () => {
+ const json = exportCharacter(testCharacter);
+ const parsed = JSON.parse(json);
+ expect(parsed.template).not.toHaveProperty('id');
+ });
+
+ it('prunes empty values from data', () => {
+ const char: Character = {
+ ...testCharacter,
+ data: { name: 'Yury Zakharov', species: '', 'hair-color': '' }
+ };
+ const json = exportCharacter(char);
+ const parsed = JSON.parse(json);
+ expect(parsed.data).toEqual({ name: 'Yury Zakharov' });
+ });
+
+ it('omits templateId for non-preset templates', () => {
+ const char: Character = {
+ ...testCharacter,
+ template: {
+ id: 'custom:test',
+ name: 'Custom',
+ description: 'Test',
+ schemaVersion: 1,
+ records: []
+ }
+ };
+ const json = exportCharacter(char);
+ const parsed = JSON.parse(json);
+ expect(parsed.templateId).toBeUndefined();
+ expect(parsed.template.name).toBe('Custom');
+ });
+});
+
+describe('parseCharacterFile', () => {
+ it('resolves preset template by templateId', () => {
+ const json = exportCharacter(testCharacter);
+ const result = parseCharacterFile(json);
+ expect(result.template).toHaveProperty('id', 'preset:standard');
+ expect(result.data.name).toBe('Yury Zakharov');
+ });
+
+ it('falls back to embedded template for unknown preset', () => {
+ const payload = {
+ version: 1,
+ templateId: 'preset:nonexistent',
+ template: { name: 'Fallback', description: '', schemaVersion: 1, records: [] },
+ data: { name: 'Test' }
+ };
+ const result = parseCharacterFile(JSON.stringify(payload));
+ expect(result.template.name).toBe('Fallback');
+ expect(result.template).not.toHaveProperty('id');
+ });
+
+ it('uses embedded template when no templateId', () => {
+ const payload = {
+ version: 1,
+ template: { name: 'Custom', description: '', schemaVersion: 1, records: [] },
+ data: { name: 'Test' }
+ };
+ const result = parseCharacterFile(JSON.stringify(payload));
+ expect(result.template.name).toBe('Custom');
+ });
+
+ it('throws on invalid JSON', () => {
+ expect(() => parseCharacterFile('not json')).toThrow();
+ });
+
+ it('throws on missing data field', () => {
+ const payload = { version: 1, template: { name: 'X', description: '', schemaVersion: 1, records: [] } };
+ expect(() => parseCharacterFile(JSON.stringify(payload))).toThrow();
+ });
+
+ it('throws on missing template and templateId', () => {
+ const payload = { version: 1, data: { name: 'Test' } };
+ expect(() => parseCharacterFile(JSON.stringify(payload))).toThrow();
+ });
+});
diff --git a/src/lib/file.ts b/src/lib/file.ts
new file mode 100644
index 0000000..5a60c17
--- /dev/null
+++ b/src/lib/file.ts
@@ -0,0 +1,53 @@
+import type { Character, Template } from './types';
+import { pruneEmpty } from './sharing';
+import { presets } from './presets';
+import { slugify } from './utils/slugify';
+
+interface CharacterFilePayload {
+ version: number;
+ templateId?: string;
+ template: Omit;
+ data: Record;
+}
+
+export function exportCharacter(char: Character): string {
+ const isPreset = char.template.id.startsWith('preset:');
+ const { id, ...templateWithoutId } = char.template;
+ const payload: CharacterFilePayload = {
+ version: 1,
+ template: templateWithoutId,
+ data: pruneEmpty(char.data)
+ };
+ if (isPreset) {
+ payload.templateId = char.template.id;
+ }
+ return JSON.stringify(payload, null, 2);
+}
+
+export function parseCharacterFile(json: string): { template: Template | Omit; data: Record } {
+ const payload = JSON.parse(json);
+ if (!payload.data || typeof payload.data !== 'object') {
+ throw new Error('Invalid character file: missing data');
+ }
+ if (!payload.template && !payload.templateId) {
+ throw new Error('Invalid character file: missing template');
+ }
+ if (payload.templateId) {
+ const preset = presets.find((p) => p.id === payload.templateId);
+ if (preset) {
+ return { template: preset, data: payload.data };
+ }
+ }
+ if (payload.template) {
+ return { template: payload.template, data: payload.data };
+ }
+ throw new Error('Invalid character file: could not resolve template');
+}
+
+export function characterFileName(char: Character): string {
+ const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name');
+ const key = nameField ? slugify(nameField.label) : 'name';
+ const name = char.data[key] as string | undefined;
+ if (!name || !name.trim()) return 'character.json';
+ return name.trim().replace(/[^a-zA-Z0-9'-]/g, '-').replace(/-+/g, '-') + '.json';
+}
diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts
new file mode 100644
index 0000000..824bfbf
--- /dev/null
+++ b/src/lib/output.test.ts
@@ -0,0 +1,272 @@
+import { describe, it, expect } from 'vitest';
+import { formatFieldOutput, generateRecord } from './output';
+import type { FieldDef, Template } from './types';
+import type { SpeciesData } from './data/types';
+
+const stubSpecies: SpeciesData[] = [
+ {
+ id: 'human',
+ name: 'Human',
+ description: '',
+ subspeciesLabel: 'Variant',
+ subspecies: [{ id: 'offworlder', name: 'Offworlder', description: '' }],
+ languages: ['tau-ceti-basic'],
+ citizenships: ['biesel']
+ },
+ {
+ id: 'tajara',
+ name: 'Tajara',
+ description: '',
+ subspeciesLabel: 'Ethnicity',
+ subspecies: [
+ { id: 'hharar', name: 'Hharar', description: '' },
+ { id: 'zhan-khazan', name: 'Zhan-Khazan', description: '' }
+ ],
+ languages: ['siik-maas'],
+ citizenships: ['pra']
+ }
+];
+
+describe('formatFieldOutput', () => {
+ it('formats text fields', () => {
+ const field: FieldDef = { label: 'Pronouns', type: 'text' };
+ expect(formatFieldOutput(field, 'she/her')).toBe('Pronouns: she/her');
+ });
+
+ it('returns null for empty text', () => {
+ const field: FieldDef = { label: 'Pronouns', type: 'text' };
+ expect(formatFieldOutput(field, '')).toBeNull();
+ expect(formatFieldOutput(field, undefined)).toBeNull();
+ });
+
+ it('formats textarea with header', () => {
+ const field: FieldDef = { label: 'Distinguishing Features', type: 'textarea' };
+ expect(formatFieldOutput(field, 'Scar across left eye')).toBe(
+ 'Distinguishing Features:\nScar across left eye'
+ );
+ });
+
+ it('returns null for empty textarea', () => {
+ const field: FieldDef = { label: 'Distinguishing Features', type: 'textarea' };
+ expect(formatFieldOutput(field, '')).toBeNull();
+ });
+
+ it('formats list as bullet points', () => {
+ const field: FieldDef = { label: 'Employment History', type: 'list' };
+ expect(formatFieldOutput(field, 'Shaft Miner')).toBe(
+ 'Employment History:\n - Shaft Miner'
+ );
+ });
+
+ it('returns null for empty list', () => {
+ const field: FieldDef = { label: 'Employment History', type: 'list' };
+ expect(formatFieldOutput(field, '')).toBeNull();
+ });
+
+ it('filters blank lines from list', () => {
+ const field: FieldDef = { label: 'Employment History', type: 'list' };
+ expect(formatFieldOutput(field, 'Line 1\n\nLine 2\n')).toBe(
+ 'Employment History:\n - Line 1\n - Line 2'
+ );
+ });
+
+ it('formats height with conversion', () => {
+ const field: FieldDef = { label: 'Height', type: 'height' };
+ expect(formatFieldOutput(field, 180)).toBe('Height: 180 cm (5\'11")');
+ });
+
+ it('returns null for zero/undefined height', () => {
+ const field: FieldDef = { label: 'Height', type: 'height' };
+ expect(formatFieldOutput(field, 0)).toBeNull();
+ expect(formatFieldOutput(field, undefined)).toBeNull();
+ });
+
+ it('formats weight with conversion', () => {
+ const field: FieldDef = { label: 'Weight', type: 'weight' };
+ expect(formatFieldOutput(field, 75)).toBe('Weight: 75 kg (165 lb)');
+ });
+
+ it('returns null for zero/undefined weight', () => {
+ const field: FieldDef = { label: 'Weight', type: 'weight' };
+ expect(formatFieldOutput(field, 0)).toBeNull();
+ });
+
+ it('formats species with display name', () => {
+ const field: FieldDef = { label: 'Species', type: 'species' };
+ expect(formatFieldOutput(field, 'tajara', stubSpecies)).toBe('Species: Tajara');
+ });
+
+ it('formats subspecies with dynamic label', () => {
+ const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
+ expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar');
+ });
+
+ it('returns null for empty subspecies', () => {
+ const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
+ expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull();
+ });
+
+ it('formats languages as comma list', () => {
+ const field: FieldDef = { label: 'Spoken Languages', type: 'languages' };
+ expect(formatFieldOutput(field, ['Tau Ceti Basic', 'Siik\'maas'])).toBe(
+ 'Spoken Languages: Tau Ceti Basic, Siik\'maas'
+ );
+ });
+
+ it('returns null for empty languages', () => {
+ const field: FieldDef = { label: 'Spoken Languages', type: 'languages' };
+ expect(formatFieldOutput(field, [])).toBeNull();
+ });
+
+ it('formats checkbox as bullet list of selected', () => {
+ const field: FieldDef = {
+ label: 'Opt-Outs',
+ type: 'checkbox',
+ options: [
+ { value: 'no-borg', label: 'Do Not Borgify' },
+ { value: 'no-revive', label: 'Do Not Revive' },
+ { value: 'no-prosthetic', label: 'Do Not Prostheticize' }
+ ]
+ };
+ expect(formatFieldOutput(field, ['no-borg', 'no-revive'])).toBe(
+ 'Opt-Outs:\n - Do Not Borgify\n - Do Not Revive'
+ );
+ });
+
+ it('returns null for empty checkbox', () => {
+ const field: FieldDef = {
+ label: 'Opt-Outs',
+ type: 'checkbox',
+ options: [{ value: 'no-borg', label: 'Do Not Borgify' }]
+ };
+ expect(formatFieldOutput(field, [])).toBeNull();
+ });
+
+ it('formats select fields', () => {
+ const field: FieldDef = {
+ label: 'Citizenship',
+ type: 'select',
+ options: [{ value: 'biesel', label: 'Republic of Biesel' }]
+ };
+ expect(formatFieldOutput(field, 'biesel')).toBe('Citizenship: Republic of Biesel');
+ });
+
+ it('formats date fields', () => {
+ const field: FieldDef = { label: 'Date of Birth', type: 'date' };
+ expect(formatFieldOutput(field, 'March 15th, 2438')).toBe('Date of Birth: March 15th, 2438');
+ });
+
+ it('formats number fields', () => {
+ const field: FieldDef = { label: 'Age', type: 'number' };
+ expect(formatFieldOutput(field, 30)).toBe('Age: 30');
+ });
+
+ it('formats citizenship type', () => {
+ const field: FieldDef = { label: 'Citizenship', type: 'citizenship' };
+ expect(formatFieldOutput(field, 'Republic of Biesel')).toBe('Citizenship: Republic of Biesel');
+ });
+
+ it('formats multi-select as comma list', () => {
+ const field: FieldDef = {
+ label: 'Other Skills',
+ type: 'multi-select',
+ options: [
+ { value: 'engineering', label: 'Engineering' },
+ { value: 'medical', label: 'Medical' }
+ ]
+ };
+ expect(formatFieldOutput(field, ['engineering', 'medical'])).toBe(
+ 'Other Skills: Engineering, Medical'
+ );
+ });
+});
+
+const testTemplate: Template = {
+ id: 'test',
+ name: 'Test Template',
+ description: '',
+ schemaVersion: 1,
+ records: [
+ {
+ type: 'public',
+ fields: [
+ { label: 'Name', type: 'text' },
+ { label: 'Species', type: 'species' },
+ { label: 'Pronouns', type: 'text' }
+ ]
+ },
+ {
+ type: 'employment',
+ preamble: 'This information has been verified by employment agents.',
+ fields: [
+ { label: 'Employment History', type: 'list' },
+ { label: 'Formal Education', type: 'list' }
+ ]
+ },
+ {
+ type: 'medical',
+ preamble: 'Protected by doctor-patient confidentiality.',
+ fields: [
+ {
+ label: 'Opt-Outs',
+ type: 'checkbox',
+ options: [{ value: 'no-borg', label: 'Do Not Borgify' }]
+ },
+ { label: 'Allergies', type: 'list' }
+ ]
+ },
+ {
+ type: 'security',
+ preamble: 'This information has been verified by employment agents.',
+ fields: [
+ { label: 'Attitude Towards SCC', type: 'textarea' },
+ { label: 'Arrest History', type: 'list' }
+ ]
+ }
+ ]
+};
+
+describe('generateRecord', () => {
+ it('includes public header and employment body', () => {
+ const data = {
+ name: 'Yury Zakharov',
+ species: 'tajara',
+ 'employment-history': 'Janitor'
+ };
+ const out = generateRecord(testTemplate, data, 'employment', stubSpecies);
+ expect(out).toContain('/// PUBLIC RECORD ///');
+ expect(out).toContain('Name: Yury Zakharov');
+ expect(out).toContain('Species: Tajara');
+ expect(out).toContain('/// EMPLOYMENT RECORD ///');
+ expect(out).toContain('This information has been verified by employment agents.');
+ expect(out).toContain(' - Janitor');
+ expect(out).toContain('LAST UPDATED:');
+ });
+
+ it('shows NO RECORD FOUND when body is empty', () => {
+ const data = { name: 'Yury Zakharov' };
+ const out = generateRecord(testTemplate, data, 'medical', stubSpecies);
+ expect(out).toContain('/// NO MEDICAL RECORD FOUND ///');
+ });
+
+ it('includes preamble in medical record', () => {
+ const data = {
+ name: 'Yury Zakharov',
+ allergies: 'Peanuts'
+ };
+ const out = generateRecord(testTemplate, data, 'medical', stubSpecies);
+ expect(out).toContain('Protected by doctor-patient confidentiality.');
+ expect(out).toContain(' - Peanuts');
+ });
+
+ it('includes preamble in security record', () => {
+ const data = {
+ name: 'Yury Zakharov',
+ 'attitude-towards-scc': 'Loyal employee'
+ };
+ const out = generateRecord(testTemplate, data, 'security', stubSpecies);
+ expect(out).toContain('/// SECURITY RECORD ///');
+ expect(out).toContain('This information has been verified by employment agents.');
+ expect(out).toContain('Loyal employee');
+ });
+});
diff --git a/src/lib/output.ts b/src/lib/output.ts
new file mode 100644
index 0000000..da0647c
--- /dev/null
+++ b/src/lib/output.ts
@@ -0,0 +1,164 @@
+import type { FieldDef, Template } from './types';
+import type { SpeciesData } from './data/types';
+import { cmToFeetInches, kgToLb } from './utils/conversions';
+import { formatICDate } from './utils/dates';
+import { slugify } from './utils/slugify';
+
+export function formatFieldOutput(
+ field: FieldDef,
+ value: unknown,
+ speciesData?: SpeciesData[],
+ currentSpecies?: string
+): string | null {
+ switch (field.type) {
+ case 'name':
+ case 'text':
+ case 'date':
+ case 'citizenship':
+ return value ? `${field.label}: ${value}` : null;
+
+ case 'textarea':
+ return value ? `${field.label}:\n${value}` : null;
+
+ case 'list': {
+ const lines = splitLines(value as string);
+ return lines.length ? `${field.label}:\n${formatBullets(lines)}` : null;
+ }
+
+ case 'number':
+ return value != null && value !== 0 ? `${field.label}: ${value}` : null;
+
+ case 'height':
+ return value ? `${field.label}: ${value} cm (${cmToFeetInches(value as number)})` : null;
+
+ case 'weight': {
+ if (!value) return null;
+ const lb = Math.round(kgToLb(value as number));
+ return `${field.label}: ${value} kg (${lb} lb)`;
+ }
+
+ case 'species': {
+ if (!value || !speciesData) return null;
+ const sp = speciesData.find((s) => s.id === value);
+ return sp ? `${field.label}: ${sp.name}` : `${field.label}: ${value}`;
+ }
+
+ case 'subspecies': {
+ if (!value || !speciesData || !currentSpecies) return null;
+ const sp = speciesData.find((s) => s.id === currentSpecies);
+ if (!sp) return null;
+ const sub = sp.subspecies.find((s) => s.id === value);
+ return sub ? `${sp.subspeciesLabel}: ${sub.name}` : null;
+ }
+
+ case 'languages': {
+ const arr = value as string[] | undefined;
+ return arr?.length ? `${field.label}: ${arr.join(', ')}` : null;
+ }
+
+ case 'checkbox': {
+ const selected = value as string[] | undefined;
+ if (!selected?.length) return null;
+ const labels = selected
+ .map((v) => field.options.find((o) => o.value === v)?.label ?? v)
+ return `${field.label}:\n${formatBullets(labels)}`;
+ }
+
+ case 'select': {
+ if (!value) return null;
+ const opt = field.options.find((o) => o.value === value);
+ return `${field.label}: ${opt?.label ?? value}`;
+ }
+
+ case 'multi-select': {
+ const vals = value as string[] | undefined;
+ if (!vals?.length) return null;
+ const labels = vals.map((v) => field.options.find((o) => o.value === v)?.label ?? v);
+ return `${field.label}: ${labels.join(', ')}`;
+ }
+ }
+}
+
+export function generateRecord(
+ template: Template,
+ data: Record,
+ recordType: string,
+ speciesData?: SpeciesData[]
+): string {
+ const publicRecord = template.records.find((r) => r.type === 'public');
+ const targetRecord = template.records.find((r) => r.type === recordType);
+ const currentSpecies = data['species'] as string | undefined;
+
+ const parts: string[] = [];
+
+ // Public section
+ if (publicRecord) {
+ const publicLines = renderFields(publicRecord.fields, data, speciesData, currentSpecies);
+ if (publicLines.length) {
+ parts.push('/// PUBLIC RECORD ///');
+ parts.push(publicLines.join('\n'));
+ }
+ }
+
+ // Target record section
+ if (targetRecord) {
+ const bodyLines = renderFields(targetRecord.fields, data, speciesData, currentSpecies);
+ const typeLabel = recordType.toUpperCase();
+
+ if (!bodyLines.length) {
+ parts.push(`/// NO ${typeLabel} RECORD FOUND ///`);
+ } else {
+ parts.push(`/// ${typeLabel} RECORD ///`);
+ if (targetRecord.preamble) {
+ parts.push(targetRecord.preamble);
+ }
+ parts.push(bodyLines.join('\n\n'));
+ }
+ }
+
+ parts.push(`LAST UPDATED: ${formatICDate(new Date())}`);
+ return parts.join('\n\n');
+}
+
+function renderFields(
+ fields: FieldDef[],
+ data: Record,
+ speciesData?: SpeciesData[],
+ currentSpecies?: string
+): string[] {
+ // Split fields into groups by separator boundaries
+ const groups: FieldDef[][] = [[]];
+ for (const field of fields) {
+ if (field.type === 'separator') {
+ groups.push([]);
+ } else {
+ groups[groups.length - 1].push(field);
+ }
+ }
+
+ const rendered = groups.map((group) => {
+ const lines: string[] = [];
+ for (const field of group) {
+ const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies);
+ if (out) lines.push(out);
+ }
+ return lines;
+ });
+
+ const result: string[] = [];
+ for (const lines of rendered) {
+ if (!lines.length) continue;
+ if (result.length) result.push('');
+ result.push(...lines);
+ }
+ return result;
+}
+
+function splitLines(text: string | undefined): string[] {
+ if (!text) return [];
+ return text.split('\n').map((l) => l.trim()).filter(Boolean);
+}
+
+function formatBullets(items: string[]): string {
+ return items.map((item) => ` - ${item}`).join('\n');
+}
diff --git a/src/lib/presets.ts b/src/lib/presets.ts
new file mode 100644
index 0000000..ab0b49a
--- /dev/null
+++ b/src/lib/presets.ts
@@ -0,0 +1,12 @@
+import { parseTemplate } from './data/parse';
+
+const templateModules = import.meta.glob('../../data/templates/*.xml', {
+ query: '?raw',
+ import: 'default',
+ eager: true
+});
+
+export const presets = Object.entries(templateModules).map(([path, xml]) => {
+ const filename = path.split('/').pop()!.replace('.xml', '');
+ return parseTemplate(xml as string, `preset:${filename}`);
+});
diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts
new file mode 100644
index 0000000..19b138d
--- /dev/null
+++ b/src/lib/schema.test.ts
@@ -0,0 +1,198 @@
+import { describe, it, expect } from 'vitest';
+import { buildCharacterSchema } from './schema';
+import type { Template } from './types';
+
+function makeTemplate(fields: any[]): Template {
+ return {
+ id: 'test',
+ name: 'Test',
+ description: '',
+ schemaVersion: 1,
+ records: [{ type: 'public', fields }]
+ };
+}
+
+describe('buildCharacterSchema', () => {
+ it('validates text fields as optional strings', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Pronouns', type: 'text' }])
+ );
+ expect(schema.parse({})).toEqual({});
+ expect(schema.parse({ pronouns: 'She/her' })).toEqual({ pronouns: 'She/her' });
+ });
+
+ it('validates textarea fields as optional strings', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ { label: 'Distinguishing Features', type: 'textarea' }
+ ])
+ );
+ expect(schema.parse({ 'distinguishing-features': 'Scar across left eye' })).toEqual({
+ 'distinguishing-features': 'Scar across left eye'
+ });
+ expect(schema.parse({})).toEqual({});
+ });
+
+ it('validates list fields as optional strings', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ { label: 'Employment History', type: 'list' }
+ ])
+ );
+ expect(schema.parse({ 'employment-history': 'NanoTrasen Intern\nShaft Miner' })).toEqual({
+ 'employment-history': 'NanoTrasen Intern\nShaft Miner'
+ });
+ expect(schema.parse({})).toEqual({});
+ });
+
+ it('validates date fields as optional strings', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ { label: 'Date of Birth', type: 'date', placeholder: 'March 15th, 2438' }
+ ])
+ );
+ expect(schema.parse({ 'date-of-birth': 'March 15th, 2438' })).toEqual({
+ 'date-of-birth': 'March 15th, 2438'
+ });
+ });
+
+ it('validates select fields as optional strings', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ {
+ label: 'Citizenship',
+ type: 'select',
+ options: [{ value: 'biesel', label: 'Republic of Biesel' }]
+ }
+ ])
+ );
+ expect(schema.parse({ citizenship: 'biesel' })).toEqual({ citizenship: 'biesel' });
+ });
+
+ it('validates number fields as optional numbers', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Age', type: 'number', min: 0, max: 999 }])
+ );
+ expect(schema.parse({ age: 30 })).toEqual({ age: 30 });
+ expect(schema.parse({})).toEqual({});
+ });
+
+ it('validates height as optional number', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Height', type: 'height' }])
+ );
+ expect(schema.parse({ height: 180 })).toEqual({ height: 180 });
+ });
+
+ it('validates weight as optional number', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Weight', type: 'weight' }])
+ );
+ expect(schema.parse({ weight: 75 })).toEqual({ weight: 75 });
+ });
+
+ it('validates name as optional string', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Name', type: 'text' }])
+ );
+ expect(schema.parse({ name: 'Ka\'Akaix\'Lak Zo\'ra' })).toEqual({
+ name: 'Ka\'Akaix\'Lak Zo\'ra'
+ });
+ expect(schema.parse({})).toEqual({});
+ });
+
+ it('validates multi-select as optional string array', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ {
+ label: 'Spoken Languages',
+ type: 'multi-select',
+ options: [
+ { value: 'tau-ceti-basic', label: 'Tau Ceti Basic' },
+ { value: 'sol-common', label: 'Sol Common' }
+ ]
+ }
+ ])
+ );
+ expect(schema.parse({ 'spoken-languages': ['tau-ceti-basic', 'sol-common'] })).toEqual({
+ 'spoken-languages': ['tau-ceti-basic', 'sol-common']
+ });
+ expect(schema.parse({})).toEqual({});
+ });
+
+ it('validates checkbox as optional string array', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ {
+ label: 'Opt-Outs',
+ type: 'checkbox',
+ options: [
+ { value: 'no-borg', label: 'Do Not Borgify' },
+ { value: 'no-revive', label: 'Do Not Revive' },
+ { value: 'no-prosthetic', label: 'Do Not Prostheticize' }
+ ]
+ }
+ ])
+ );
+ expect(schema.parse({ 'opt-outs': ['no-borg', 'no-revive'] })).toEqual({
+ 'opt-outs': ['no-borg', 'no-revive']
+ });
+ });
+
+ it('validates languages as optional string array', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ { label: 'Spoken Languages', type: 'languages' }
+ ])
+ );
+ expect(
+ schema.parse({ 'spoken-languages': ['Tau Ceti Basic', 'Siik\'maas'] })
+ ).toEqual({
+ 'spoken-languages': ['Tau Ceti Basic', 'Siik\'maas']
+ });
+ });
+
+ it('validates species as optional string', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Species', type: 'species' }])
+ );
+ expect(schema.parse({ species: 'tajara' })).toEqual({ species: 'tajara' });
+ });
+
+ it('validates subspecies as optional string', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Subspecies', type: 'subspecies' }])
+ );
+ expect(schema.parse({ subspecies: 'zhan-khazan' })).toEqual({
+ subspecies: 'zhan-khazan'
+ });
+ });
+
+ it('validates citizenship type as optional string', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Citizenship', type: 'citizenship' }])
+ );
+ expect(schema.parse({ citizenship: 'sol-alliance' })).toEqual({
+ citizenship: 'sol-alliance'
+ });
+ });
+
+ it('allows all fields to be missing', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([
+ { label: 'Name', type: 'text' },
+ { label: 'Height', type: 'height' },
+ { label: 'Spoken Languages', type: 'languages' },
+ { label: 'Skin Color', type: 'text' }
+ ])
+ );
+ expect(schema.parse({})).toEqual({});
+ });
+
+ it('rejects wrong types', () => {
+ const schema = buildCharacterSchema(
+ makeTemplate([{ label: 'Height', type: 'height' }])
+ );
+ expect(() => schema.parse({ height: 'tall' })).toThrow();
+ });
+});
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
new file mode 100644
index 0000000..fce4df7
--- /dev/null
+++ b/src/lib/schema.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import type { FieldDef, Template } from './types';
+import { slugify } from './utils/slugify';
+
+function zodForField(field: FieldDef): z.ZodTypeAny {
+ switch (field.type) {
+ case 'name':
+ case 'text':
+ case 'textarea':
+ case 'list':
+ case 'date':
+ case 'select':
+ case 'species':
+ case 'subspecies':
+ case 'citizenship':
+ return z.string().optional();
+
+ case 'number':
+ case 'height':
+ case 'weight':
+ return z.number().optional();
+
+ case 'multi-select':
+ case 'checkbox':
+ case 'languages':
+ return z.array(z.string()).optional();
+ }
+}
+
+export function buildCharacterSchema(template: Template): z.ZodObject> {
+ const shape: Record = {};
+ for (const record of template.records) {
+ for (const field of record.fields) {
+ if (field.type === 'separator') continue;
+ shape[slugify(field.label)] = zodForField(field);
+ }
+ }
+ return z.object(shape).partial();
+}
diff --git a/src/lib/sharing.test.ts b/src/lib/sharing.test.ts
new file mode 100644
index 0000000..f34d0a9
--- /dev/null
+++ b/src/lib/sharing.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect } from 'vitest';
+import {
+ encodeCharacterURL,
+ decodeCharacterURL,
+ encodeTemplateURL,
+ decodeTemplateURL
+} from './sharing';
+import { presets } from './presets';
+import type { Character, Template } from './types';
+
+const standardPreset = presets.find((p) => p.id === 'preset:standard')!;
+
+const testCharacter: Character = {
+ id: 'abc-123',
+ template: standardPreset,
+ data: {
+ name: 'Yury Zakharov',
+ species: 'human',
+ 'employment-history': 'Shaft Miner'
+ }
+};
+
+const customTemplate: Template = {
+ id: 'custom:test',
+ name: 'Custom',
+ description: 'A custom template.',
+ schemaVersion: 1,
+ records: [
+ {
+ type: 'public',
+ fields: [
+ { label: 'Name', type: 'text' },
+ { label: 'Species', type: 'species' }
+ ]
+ }
+ ]
+};
+
+describe('character URL encoding', () => {
+ it('round-trips preset character data', () => {
+ const encoded = encodeCharacterURL(testCharacter);
+ const decoded = decodeCharacterURL(encoded);
+ expect(decoded.data).toEqual(testCharacter.data);
+ expect(decoded.template.name).toBe('General');
+ });
+
+ it('uses short encoding for preset templates', () => {
+ const encoded = encodeCharacterURL(testCharacter);
+ const customChar = { ...testCharacter, template: customTemplate };
+ const customEncoded = encodeCharacterURL(customChar);
+ expect(encoded.length).toBeLessThan(customEncoded.length);
+ });
+
+ it('round-trips custom template character', () => {
+ const char: Character = { ...testCharacter, template: customTemplate };
+ const encoded = encodeCharacterURL(char);
+ const decoded = decodeCharacterURL(encoded);
+ expect(decoded.data).toEqual(testCharacter.data);
+ expect(decoded.template.name).toBe('Custom');
+ });
+
+ it('starts with c1. prefix', () => {
+ const encoded = encodeCharacterURL(testCharacter);
+ expect(encoded.startsWith('c1.')).toBe(true);
+ });
+
+ it('prunes empty values from data', () => {
+ const char: Character = {
+ ...testCharacter,
+ data: { name: 'Yury Zakharov', species: '', 'hair-color': '' }
+ };
+ const encoded = encodeCharacterURL(char);
+ const decoded = decodeCharacterURL(encoded);
+ expect(decoded.data).toEqual({ name: 'Yury Zakharov' });
+ });
+});
+
+describe('template URL encoding', () => {
+ it('round-trips template structure', () => {
+ const encoded = encodeTemplateURL(customTemplate);
+ const decoded = decodeTemplateURL(encoded);
+ expect(decoded.name).toBe('Custom');
+ expect(decoded.records).toEqual(customTemplate.records);
+ });
+
+ it('starts with t1. prefix', () => {
+ const encoded = encodeTemplateURL(customTemplate);
+ expect(encoded.startsWith('t1.')).toBe(true);
+ });
+
+ it('strips id', () => {
+ const encoded = encodeTemplateURL(customTemplate);
+ const decoded = decodeTemplateURL(encoded);
+ expect(decoded).not.toHaveProperty('id');
+ });
+});
+
+describe('unicode support', () => {
+ it('round-trips unicode content', () => {
+ const char: Character = {
+ ...testCharacter,
+ data: { name: "Ka'Akaix'Lak Zo'ra", species: 'vaurca' }
+ };
+ const encoded = encodeCharacterURL(char);
+ const decoded = decodeCharacterURL(encoded);
+ expect(decoded.data.name).toBe("Ka'Akaix'Lak Zo'ra");
+ });
+});
+
+describe('error handling', () => {
+ it('throws on invalid character input', () => {
+ expect(() => decodeCharacterURL('c1.invaliddata!!!')).toThrow();
+ });
+
+ it('throws on wrong prefix', () => {
+ const encoded = encodeCharacterURL(testCharacter);
+ expect(() => decodeTemplateURL(encoded)).toThrow();
+ });
+});
diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts
new file mode 100644
index 0000000..ca6345a
--- /dev/null
+++ b/src/lib/sharing.ts
@@ -0,0 +1,75 @@
+import pako from 'pako';
+import type { Character, Template } from './types';
+import { presets } from './presets';
+
+function toBase64url(bytes: Uint8Array): string {
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function fromBase64url(str: string): Uint8Array {
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+export function pruneEmpty(data: Record): Record {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(data)) {
+ if (v === '' || v === undefined || v === null) continue;
+ if (Array.isArray(v) && v.length === 0) continue;
+ out[k] = v;
+ }
+ return out;
+}
+
+export function encodeCharacterURL(char: Character): string {
+ const isPreset = char.template.id.startsWith('preset:');
+ const payload: any = {
+ data: pruneEmpty(char.data)
+ };
+ if (isPreset) {
+ payload.templateId = char.template.id;
+ } else {
+ payload.template = stripId(char.template);
+ }
+ const json = JSON.stringify(payload);
+ const compressed = pako.deflate(new TextEncoder().encode(json));
+ return 'c1.' + toBase64url(compressed);
+}
+
+export function decodeCharacterURL(encoded: string): { template: Template | Omit; data: Record } {
+ if (!encoded.startsWith('c1.')) throw new Error('Invalid character URL prefix');
+ const bytes = fromBase64url(encoded.slice(3));
+ const json = new TextDecoder().decode(pako.inflate(bytes));
+ const payload = JSON.parse(json);
+
+ if (payload.templateId) {
+ const preset = presets.find((p) => p.id === payload.templateId);
+ if (!preset) throw new Error(`Unknown template: ${payload.templateId}`);
+ return { template: preset, data: payload.data };
+ }
+ return { template: payload.template, data: payload.data };
+}
+
+export function encodeTemplateURL(template: Template): string {
+ const payload = stripId(template);
+ const json = JSON.stringify(payload);
+ const compressed = pako.deflate(new TextEncoder().encode(json));
+ return 't1.' + toBase64url(compressed);
+}
+
+export function decodeTemplateURL(encoded: string): Omit {
+ if (!encoded.startsWith('t1.')) throw new Error('Invalid template URL prefix');
+ const bytes = fromBase64url(encoded.slice(3));
+ const json = new TextDecoder().decode(pako.inflate(bytes));
+ return JSON.parse(json);
+}
+
+function stripId(obj: Record): Record {
+ const { id, ...rest } = obj;
+ return rest;
+}
diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts
new file mode 100644
index 0000000..85d48d3
--- /dev/null
+++ b/src/lib/state.svelte.ts
@@ -0,0 +1,147 @@
+import { getAllCharacters, saveCharacter, deleteCharacter } from './storage';
+import { isBlankCharacter } from './utils/blank';
+import { slugify } from './utils/slugify';
+import type { Character, Template } from './types';
+
+let characters = $state([]);
+let activeId = $state(null);
+let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
+let saveTimer: ReturnType | null = null;
+let statusTimer: ReturnType | null = null;
+
+const SINGLETON_TYPES = new Set([
+ 'name', 'species', 'subspecies', 'citizenship', 'languages', 'height', 'weight'
+]);
+
+function allFields(template: Template) {
+ return template.records.flatMap((r) => r.fields).filter((f) => f.type !== 'separator');
+}
+
+function migrateData(char: Character, preset: Template) {
+ for (const record of preset.records) {
+ for (const field of record.fields) {
+ if (!field.from) continue;
+ const newKey = slugify(field.label);
+ if (char.data[newKey] !== undefined) continue;
+ const oldNames = field.from.split(',').map((s) => s.trim());
+ for (const oldName of oldNames) {
+ const oldKey = slugify(oldName);
+ if (char.data[oldKey] !== undefined) {
+ char.data[newKey] = char.data[oldKey];
+ delete char.data[oldKey];
+ break;
+ }
+ }
+ }
+ }
+
+ const oldByType = new Map();
+ for (const f of allFields(char.template)) {
+ if (SINGLETON_TYPES.has(f.type)) {
+ oldByType.set(f.type, slugify(f.label));
+ }
+ }
+ for (const f of allFields(preset)) {
+ if (!SINGLETON_TYPES.has(f.type)) continue;
+ const newKey = slugify(f.label);
+ if (char.data[newKey] !== undefined) continue;
+ const oldKey = oldByType.get(f.type);
+ if (oldKey && oldKey !== newKey && char.data[oldKey] !== undefined) {
+ char.data[newKey] = char.data[oldKey];
+ }
+ }
+}
+
+export const roster = {
+ get characters() { return characters; },
+ get active() { return characters.find((c) => c.id === activeId) ?? null; },
+ get saveStatus() { return saveStatus; },
+
+ async migrateToPreset(char: Character, preset: Template) {
+ migrateData(char, preset);
+ if (preset.species?.length === 1) {
+ char.data[slugify('Species')] = preset.species[0];
+ }
+ char.template = $state.snapshot(preset);
+ await saveCharacter($state.snapshot(char));
+ },
+
+ async load() {
+ const all = await getAllCharacters();
+ const kept: Character[] = [];
+
+ for (const char of all) {
+ if (isBlankCharacter(char)) {
+ await deleteCharacter(char.id);
+ } else {
+ kept.push(char);
+ }
+ }
+
+ characters = kept;
+
+ const stored = localStorage.getItem('activeCharacterId');
+ if (stored && characters.some((c) => c.id === stored)) {
+ activeId = stored;
+ } else if (characters.length) {
+ activeId = characters[0].id;
+ }
+ },
+
+ async create(template: Template, data: Record = {}) {
+ const initial: Record = { ...data };
+ if (template.species?.length === 1) {
+ initial[slugify('Species')] ??= template.species[0];
+ }
+ const char: Character = {
+ id: crypto.randomUUID(),
+ template: $state.snapshot(template),
+ data: initial
+ };
+ characters.push(char);
+ activeId = char.id;
+ localStorage.setItem('activeCharacterId', char.id);
+ await saveCharacter($state.snapshot(char));
+ return char;
+ },
+
+ async remove(id: string) {
+ characters = characters.filter((c) => c.id !== id);
+ await deleteCharacter(id);
+ if (activeId === id) {
+ activeId = characters[0]?.id ?? null;
+ if (activeId) localStorage.setItem('activeCharacterId', activeId);
+ else localStorage.removeItem('activeCharacterId');
+ }
+ },
+
+ async duplicate(id: string) {
+ const source = characters.find((c) => c.id === id);
+ if (!source) return;
+ const copy: Character = {
+ id: crypto.randomUUID(),
+ template: $state.snapshot(source.template),
+ data: $state.snapshot(source.data)
+ };
+ characters.push(copy);
+ activeId = copy.id;
+ localStorage.setItem('activeCharacterId', copy.id);
+ await saveCharacter($state.snapshot(copy));
+ },
+
+ setActive(id: string) {
+ activeId = id;
+ localStorage.setItem('activeCharacterId', id);
+ },
+
+ scheduleSave(char: Character) {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(async () => {
+ saveStatus = 'saving';
+ await saveCharacter($state.snapshot(char));
+ saveStatus = 'saved';
+ if (statusTimer) clearTimeout(statusTimer);
+ statusTimer = setTimeout(() => { saveStatus = 'idle'; }, 1500);
+ }, 300);
+ }
+};
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..02e4671
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,46 @@
+import { openDB, type DBSchema } from 'idb';
+import type { Character, Template } from './types';
+
+interface RecordsDB extends DBSchema {
+ characters: { key: string; value: Character };
+ templates: { key: string; value: Template };
+}
+
+const dbPromise = openDB('aurora-records', 1, {
+ upgrade(db) {
+ db.createObjectStore('characters', { keyPath: 'id' });
+ db.createObjectStore('templates', { keyPath: 'id' });
+ }
+});
+
+export async function getAllCharacters() {
+ return (await dbPromise).getAll('characters');
+}
+
+export async function getCharacter(id: string) {
+ return (await dbPromise).get('characters', id);
+}
+
+export async function saveCharacter(char: Character) {
+ await (await dbPromise).put('characters', char);
+}
+
+export async function deleteCharacter(id: string) {
+ await (await dbPromise).delete('characters', id);
+}
+
+export async function getAllTemplates() {
+ return (await dbPromise).getAll('templates');
+}
+
+export async function getTemplate(id: string) {
+ return (await dbPromise).get('templates', id);
+}
+
+export async function saveTemplate(tmpl: Template) {
+ await (await dbPromise).put('templates', tmpl);
+}
+
+export async function deleteTemplate(id: string) {
+ await (await dbPromise).delete('templates', id);
+}
diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts
new file mode 100644
index 0000000..e177190
--- /dev/null
+++ b/src/lib/theme.svelte.ts
@@ -0,0 +1,25 @@
+let dark = $state(false);
+
+export const theme = {
+ get dark() { return dark; },
+
+ init() {
+ const stored = localStorage.getItem('theme');
+ if (stored) {
+ dark = stored === 'dark';
+ } else {
+ dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+ apply();
+ },
+
+ toggle() {
+ dark = !dark;
+ localStorage.setItem('theme', dark ? 'dark' : 'light');
+ apply();
+ }
+};
+
+function apply() {
+ document.documentElement.classList.toggle('dark', dark);
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..4aaf7a3
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,125 @@
+export interface SelectOption {
+ value: string;
+ label: string;
+}
+
+export interface BaseFieldDef {
+ label: string;
+ required?: boolean;
+ from?: string;
+}
+
+export interface NameField extends BaseFieldDef {
+ type: 'name';
+ placeholder?: string;
+}
+
+export interface TextField extends BaseFieldDef {
+ type: 'text';
+ placeholder?: string;
+}
+
+export interface TextareaField extends BaseFieldDef {
+ type: 'textarea';
+ placeholder?: string;
+}
+
+export interface ListField extends BaseFieldDef {
+ type: 'list';
+}
+
+export interface NumberField extends BaseFieldDef {
+ type: 'number';
+ min?: number;
+ max?: number;
+ unit?: string;
+}
+
+export interface SelectField extends BaseFieldDef {
+ type: 'select';
+ options: SelectOption[];
+}
+
+export interface MultiSelectField extends BaseFieldDef {
+ type: 'multi-select';
+ options: SelectOption[];
+}
+
+export interface CheckboxField extends BaseFieldDef {
+ type: 'checkbox';
+ options: SelectOption[];
+}
+
+export interface DateField extends BaseFieldDef {
+ type: 'date';
+ placeholder?: string;
+}
+
+export interface HeightField extends BaseFieldDef {
+ type: 'height';
+}
+
+export interface WeightField extends BaseFieldDef {
+ type: 'weight';
+}
+
+export interface SpeciesField extends BaseFieldDef {
+ type: 'species';
+}
+
+export interface SubspeciesField extends BaseFieldDef {
+ type: 'subspecies';
+}
+
+export interface CitizenshipField extends BaseFieldDef {
+ type: 'citizenship';
+}
+
+export interface LanguagesField extends BaseFieldDef {
+ type: 'languages';
+}
+
+export interface SeparatorField {
+ type: 'separator';
+ label: string;
+}
+
+export type FieldDef =
+ | NameField
+ | TextField
+ | TextareaField
+ | ListField
+ | NumberField
+ | SelectField
+ | MultiSelectField
+ | CheckboxField
+ | DateField
+ | HeightField
+ | WeightField
+ | SpeciesField
+ | SubspeciesField
+ | CitizenshipField
+ | LanguagesField
+ | SeparatorField;
+
+export interface RecordDef {
+ type: string;
+ preamble?: string;
+ note?: string;
+ fields: FieldDef[];
+}
+
+export interface Template {
+ id: string;
+ name: string;
+ description: string;
+ schemaVersion: number;
+ species?: string[];
+ records: RecordDef[];
+}
+
+export interface Character {
+ id: string;
+ template: Template;
+ data: Record;
+}
diff --git a/src/lib/utils/blank.test.ts b/src/lib/utils/blank.test.ts
new file mode 100644
index 0000000..8aaebc6
--- /dev/null
+++ b/src/lib/utils/blank.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest';
+import { isBlankCharacter } from './blank';
+import type { Character } from '../types';
+
+const template = {
+ id: 'preset:standard',
+ name: 'Standard',
+ description: '',
+ schemaVersion: 1,
+ records: [
+ {
+ type: 'public',
+ fields: [
+ { label: 'Name', type: 'text' as const },
+ { label: 'Species', type: 'species' as const },
+ { label: 'Spoken Languages', type: 'languages' as const }
+ ]
+ }
+ ]
+};
+
+function makeChar(data: Record): Character {
+ return { id: 'test', template, data };
+}
+
+describe('isBlankCharacter', () => {
+ it('returns true for empty data', () => {
+ expect(isBlankCharacter(makeChar({}))).toBe(true);
+ });
+
+ it('returns true when all values are empty strings', () => {
+ expect(isBlankCharacter(makeChar({ name: '', species: '' }))).toBe(true);
+ });
+
+ it('returns false when any string has a value', () => {
+ expect(isBlankCharacter(makeChar({ name: 'Yury Zakharov' }))).toBe(false);
+ });
+
+ it('returns true for empty arrays', () => {
+ expect(isBlankCharacter(makeChar({ 'spoken-languages': [] }))).toBe(true);
+ });
+
+ it('returns false when languages has any value', () => {
+ expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic'] }))).toBe(false);
+ });
+
+ it('returns false when languages has custom values', () => {
+ expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic', "Siik'maas"] }))).toBe(false);
+ });
+
+ it('returns true for zero numbers', () => {
+ expect(isBlankCharacter(makeChar({ height: 0, weight: 0 }))).toBe(true);
+ });
+
+ it('returns false for non-zero numbers', () => {
+ expect(isBlankCharacter(makeChar({ height: 180 }))).toBe(false);
+ });
+});
diff --git a/src/lib/utils/blank.ts b/src/lib/utils/blank.ts
new file mode 100644
index 0000000..9501903
--- /dev/null
+++ b/src/lib/utils/blank.ts
@@ -0,0 +1,14 @@
+import type { Character } from '../types';
+
+export function isBlankCharacter(char: Character): boolean {
+ if (!char.data) return true;
+ for (const value of Object.values(char.data)) {
+ if (value === '' || value === undefined || value === null || value === 0) continue;
+ if (Array.isArray(value)) {
+ if (value.length === 0) continue;
+ return false;
+ }
+ return false;
+ }
+ return true;
+}
diff --git a/src/lib/utils/conversions.test.ts b/src/lib/utils/conversions.test.ts
new file mode 100644
index 0000000..1a7c532
--- /dev/null
+++ b/src/lib/utils/conversions.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { cmToFeetInches, feetInchesToCm, kgToLb, lbToKg } from './conversions';
+
+describe('cmToFeetInches', () => {
+ it('converts 180 cm', () => {
+ expect(cmToFeetInches(180)).toBe('5\'11"');
+ });
+
+ it('converts 152 cm', () => {
+ expect(cmToFeetInches(152)).toBe('5\'0"');
+ });
+
+ it('converts 0 cm', () => {
+ expect(cmToFeetInches(0)).toBe('0\'0"');
+ });
+
+ it('converts 30 cm (just inches)', () => {
+ expect(cmToFeetInches(30)).toBe('1\'0"');
+ });
+});
+
+describe('feetInchesToCm', () => {
+ it('converts 5\'11" back', () => {
+ expect(feetInchesToCm(5, 11)).toBeCloseTo(180.34, 0);
+ });
+
+ it('converts 0\'0"', () => {
+ expect(feetInchesToCm(0, 0)).toBe(0);
+ });
+});
+
+describe('kgToLb', () => {
+ it('converts 75 kg', () => {
+ expect(kgToLb(75)).toBeCloseTo(165.35, 0);
+ });
+
+ it('converts 0 kg', () => {
+ expect(kgToLb(0)).toBe(0);
+ });
+});
+
+describe('lbToKg', () => {
+ it('converts 165 lb', () => {
+ expect(lbToKg(165)).toBeCloseTo(74.84, 0);
+ });
+
+ it('converts 0 lb', () => {
+ expect(lbToKg(0)).toBe(0);
+ });
+});
diff --git a/src/lib/utils/conversions.ts b/src/lib/utils/conversions.ts
new file mode 100644
index 0000000..9655ccf
--- /dev/null
+++ b/src/lib/utils/conversions.ts
@@ -0,0 +1,22 @@
+const CM_PER_INCH = 2.54;
+const INCHES_PER_FOOT = 12;
+const LB_PER_KG = 2.20462;
+
+export function cmToFeetInches(cm: number): string {
+ const totalInches = Math.round(cm / CM_PER_INCH);
+ const feet = Math.floor(totalInches / INCHES_PER_FOOT);
+ const inches = totalInches % INCHES_PER_FOOT;
+ return `${feet}'${inches}"`;
+}
+
+export function feetInchesToCm(feet: number, inches: number): number {
+ return (feet * INCHES_PER_FOOT + inches) * CM_PER_INCH;
+}
+
+export function kgToLb(kg: number): number {
+ return kg * LB_PER_KG;
+}
+
+export function lbToKg(lb: number): number {
+ return lb / LB_PER_KG;
+}
diff --git a/src/lib/utils/dates.test.ts b/src/lib/utils/dates.test.ts
new file mode 100644
index 0000000..06b148e
--- /dev/null
+++ b/src/lib/utils/dates.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { icYear, formatICDate } from './dates';
+
+describe('icYear', () => {
+ it('adds 442 to the real year', () => {
+ expect(icYear(2026)).toBe(2468);
+ });
+
+ it('works for other years', () => {
+ expect(icYear(2000)).toBe(2442);
+ });
+});
+
+describe('formatICDate', () => {
+ it('formats with ordinal suffixes', () => {
+ expect(formatICDate(new Date(2026, 2, 1))).toBe('March 1st, 2468');
+ expect(formatICDate(new Date(2026, 2, 2))).toBe('March 2nd, 2468');
+ expect(formatICDate(new Date(2026, 2, 3))).toBe('March 3rd, 2468');
+ expect(formatICDate(new Date(2026, 2, 4))).toBe('March 4th, 2468');
+ });
+
+ it('handles 11th, 12th, 13th', () => {
+ expect(formatICDate(new Date(2026, 0, 11))).toBe('January 11th, 2468');
+ expect(formatICDate(new Date(2026, 0, 12))).toBe('January 12th, 2468');
+ expect(formatICDate(new Date(2026, 0, 13))).toBe('January 13th, 2468');
+ });
+
+ it('handles 21st, 22nd, 23rd', () => {
+ expect(formatICDate(new Date(2026, 5, 21))).toBe('June 21st, 2468');
+ expect(formatICDate(new Date(2026, 5, 22))).toBe('June 22nd, 2468');
+ expect(formatICDate(new Date(2026, 5, 23))).toBe('June 23rd, 2468');
+ });
+});
diff --git a/src/lib/utils/dates.ts b/src/lib/utils/dates.ts
new file mode 100644
index 0000000..c68a1df
--- /dev/null
+++ b/src/lib/utils/dates.ts
@@ -0,0 +1,24 @@
+const IC_OFFSET = 442;
+
+const MONTHS = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+];
+
+function ordinal(n: number): string {
+ if (n >= 11 && n <= 13) return n + 'th';
+ switch (n % 10) {
+ case 1: return n + 'st';
+ case 2: return n + 'nd';
+ case 3: return n + 'rd';
+ default: return n + 'th';
+ }
+}
+
+export function icYear(realYear: number): number {
+ return realYear + IC_OFFSET;
+}
+
+export function formatICDate(date: Date): string {
+ return `${MONTHS[date.getMonth()]} ${ordinal(date.getDate())}, ${icYear(date.getFullYear())}`;
+}
diff --git a/src/lib/utils/slugify.ts b/src/lib/utils/slugify.ts
new file mode 100644
index 0000000..0a822e7
--- /dev/null
+++ b/src/lib/utils/slugify.ts
@@ -0,0 +1,3 @@
+export function slugify(label: string): string {
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+}
diff --git a/src/lib/utils/template-diff.ts b/src/lib/utils/template-diff.ts
new file mode 100644
index 0000000..f825b44
--- /dev/null
+++ b/src/lib/utils/template-diff.ts
@@ -0,0 +1,51 @@
+import type { Template } from '../types';
+
+export interface TemplateDiff {
+ addedFields: string[];
+ removedFields: string[];
+ renamedFields: { from: string; to: string }[];
+ addedRecords: string[];
+ removedRecords: string[];
+}
+
+export function diffTemplates(old: Template, current: Template): TemplateDiff {
+ const oldRecordTypes = new Set(old.records.map((r) => r.type));
+ const newRecordTypes = new Set(current.records.map((r) => r.type));
+
+ const oldFields = new Set(old.records.flatMap((r) => r.fields.map((f) => f.label)));
+ const newFields = new Set(current.records.flatMap((r) => r.fields.map((f) => f.label)));
+
+ // Detect renames via `from` attribute
+ const renamedFields: { from: string; to: string }[] = [];
+ const renamedOld = new Set();
+ const renamedNew = new Set();
+
+ for (const record of current.records) {
+ for (const field of record.fields) {
+ if (!field.from) continue;
+ const fromNames = field.from.split(',').map((s) => s.trim());
+ const match = fromNames.find((f) => oldFields.has(f));
+ if (match && !newFields.has(match)) {
+ renamedFields.push({ from: match, to: field.label });
+ renamedOld.add(match);
+ renamedNew.add(field.label);
+ }
+ }
+ }
+
+ return {
+ addedFields: [...newFields].filter((f) => !oldFields.has(f) && !renamedNew.has(f)),
+ removedFields: [...oldFields].filter((f) => !newFields.has(f) && !renamedOld.has(f)),
+ renamedFields,
+ addedRecords: [...newRecordTypes].filter((r) => !oldRecordTypes.has(r)),
+ removedRecords: [...oldRecordTypes].filter((r) => !newRecordTypes.has(r))
+ };
+}
+
+export function hasChanges(diff: TemplateDiff): boolean {
+ return diff.addedFields.length > 0
+ || diff.removedFields.length > 0
+ || diff.renamedFields.length > 0
+ || diff.addedRecords.length > 0
+ || diff.removedRecords.length > 0;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..75bdeca
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,19 @@
+
+
+{#if ready}
+ {@render children()}
+{/if}
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/src/routes/+layout.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..781b0a2
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,170 @@
+
+
+
+
+
+ {#if importError}
+
+ {importError}
+ { importError = null; }} class="underline hover:opacity-80">Dismiss
+
+ {/if}
+
+ {#if fileImportData}
+
+
+
+ {:else if importData}
+
+
+
+ {:else if roster.active}
+ {@const char = roster.active}
+
+
+ {#each modes as mode}
+ { mobileView = mode; }}
+ class="px-3 py-0.5 text-sm capitalize"
+ style={mobileView === mode
+ ? 'color: var(--text); font-weight: 500;'
+ : 'color: var(--text-muted);'}
+ >
+ {mode}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if mobileView === 'edit'}
+
+ {:else if mobileView === 'preview'}
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
+ {:else}
+
+
+
+
+
+ {
+ if (presets.length === 1) roster.create(presets[0]);
+ else showPicker = true;
+ }}
+ class="flex-1 px-4 py-2 rounded text-sm font-medium hover:opacity-90"
+ style="background: var(--accent); color: white;"
+ >
+ New Character
+
+ emptyFileInput.click()}
+ class="px-4 py-2 rounded text-sm border hover:opacity-80"
+ style="border-color: var(--border); color: var(--text-muted);"
+ >
+ Import
+
+
+
+
+
+
+
+ {#if showPicker}
+
{ showPicker = false; }} />
+ {/if}
+ {/if}
+
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..0b663ed
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-static';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ adapter: adapter({
+ fallback: 'index.html'
+ })
+ }
+};
+
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..2c2ed3c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "rewriteRelativeImportExtensions": true,
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // To make changes to top-level options such as include and exclude, we recommend extending
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..e57805b
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,11 @@
+import tailwindcss from '@tailwindcss/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ test: {
+ include: ['src/**/*.test.ts'],
+ environment: 'jsdom'
+ }
+});