feat(fend): skeleton implementation of the form from XML

This commit is contained in:
Lewis Wynne 2026-03-23 18:36:31 +00:00
parent ebc522f568
commit 3c6a31f86b
20 changed files with 563 additions and 2 deletions

View file

@ -1 +1,31 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
:root {
--bg: #fafafa;
--bg-card: #ffffff;
--bg-input: #ffffff;
--border: #d4d4d4;
--text: #171717;
--text-muted: #737373;
--accent: #0066cc;
--accent-hover: #0052a3;
}
.dark {
--bg: #0a0a0a;
--bg-card: #171717;
--bg-input: #0a0a0a;
--border: #2e2e2e;
--text: #e5e5e5;
--text-muted: #a3a3a3;
--accent: #4da6ff;
--accent-hover: #80bfff;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
}

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { Trash2 } from 'lucide-svelte';
import { roster } from '$lib/state.svelte';
import { slugify } from '$lib/utils/slugify';
function displayName(char: { data: Record<string, unknown> }): string {
const name = char.data[slugify('Name')];
return (name as string) || 'Unnamed Character';
}
</script>
<div class="flex items-center gap-1">
<select
value={roster.active?.id ?? ''}
onchange={(e) => roster.setActive((e.target as HTMLSelectElement).value)}
class="rounded px-2 py-1 text-sm"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
>
{#each roster.characters as char}
<option value={char.id}>{displayName(char)}</option>
{/each}
</select>
{#if roster.active}
<button
onclick={async () => {
if (roster.active && confirm('Delete this character?')) {
await roster.remove(roster.active.id);
}
}}
class="p-1 rounded hover:opacity-80"
style="color: var(--text-muted);"
title="Delete character"
>
<Trash2 size={14} />
</button>
{/if}
</div>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { Sun, Moon } from 'lucide-svelte';
import { theme } from '$lib/theme.svelte';
import { roster } from '$lib/state.svelte';
import { presets } from '$lib/presets';
import CharacterSwitcher from './CharacterSwitcher.svelte';
async function createCharacter() {
await roster.create(presets[0]);
}
</script>
<header class="flex items-center gap-3 px-4 py-3 border-b" style="border-color: var(--border); background: var(--bg-card);">
<h1 class="font-bold whitespace-nowrap">Aurora Records</h1>
{#if roster.characters.length > 0}
<CharacterSwitcher />
{/if}
<div class="ml-auto flex items-center gap-3">
<span class="text-sm" style="color: var(--text-muted);">
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
</span>
<button
onclick={createCharacter}
class="px-3 py-1 rounded text-sm border hover:opacity-80"
style="border-color: var(--border);"
>
New Character
</button>
<button onclick={() => theme.toggle()} class="p-1 rounded hover:opacity-80" title="Toggle theme">
{#if theme.dark}
<Sun size={18} />
{:else}
<Moon size={18} />
{/if}
</button>
</div>
</header>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import type { CheckboxField } from '$lib/types';
let { field, value = [], onChange }: { field: CheckboxField; value: string[]; onChange: (v: string[]) => void } = $props();
function toggle(optValue: string) {
if (value.includes(optValue)) {
onChange(value.filter((v) => v !== optValue));
} else {
onChange([...value, optValue]);
}
}
</script>
<fieldset>
<legend class="text-sm font-medium">{field.label}</legend>
<div class="mt-1 flex flex-col gap-1">
{#each field.options as opt}
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value.includes(opt.value)}
onchange={() => toggle(opt.value)}
/>
{opt.label}
</label>
{/each}
</div>
</fieldset>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { CitizenshipField } from '$lib/types';
import { citizenships } from '$lib/data';
let { field, value = '', onChange }: { field: CitizenshipField; value: string; onChange: (v: string) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<select
{value}
onchange={(e) => 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);"
>
<option value=""></option>
{#each citizenships as c}
<option value={c.name}>{c.name}</option>
{/each}
</select>
</label>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { DateField } from '$lib/types';
let { field, value = '', onChange }: { field: DateField; value: string; onChange: (v: string) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<input
type="text"
{value}
placeholder={field.placeholder}
oninput={(e) => 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);"
/>
</label>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { FieldDef } from '$lib/types';
import TextField from './TextField.svelte';
import TextareaField from './TextareaField.svelte';
import ListField from './ListField.svelte';
import NumberField from './NumberField.svelte';
import SelectField from './SelectField.svelte';
import CheckboxField from './CheckboxField.svelte';
import DateField from './DateField.svelte';
import HeightField from './HeightField.svelte';
import WeightField from './WeightField.svelte';
import SpeciesField from './SpeciesField.svelte';
import SubspeciesField from './SubspeciesField.svelte';
import CitizenshipField from './CitizenshipField.svelte';
import LanguagesField from './LanguagesField.svelte';
let { field, value, onChange, data }: {
field: FieldDef;
value: any;
onChange: (v: any) => void;
data: Record<string, unknown>;
} = $props();
</script>
{#if field.type === 'text'}
<TextField {field} {value} {onChange} />
{:else if field.type === 'textarea'}
<TextareaField {field} {value} {onChange} />
{:else if field.type === 'list'}
<ListField {field} {value} {onChange} />
{:else if field.type === 'number'}
<NumberField {field} {value} {onChange} />
{:else if field.type === 'select'}
<SelectField {field} {value} {onChange} />
{:else if field.type === 'multi-select'}
<CheckboxField field={{ ...field, type: 'checkbox' }} {value} {onChange} />
{:else if field.type === 'checkbox'}
<CheckboxField {field} {value} {onChange} />
{:else if field.type === 'date'}
<DateField {field} {value} {onChange} />
{:else if field.type === 'height'}
<HeightField {field} {value} {onChange} />
{:else if field.type === 'weight'}
<WeightField {field} {value} {onChange} />
{:else if field.type === 'species'}
<SpeciesField {field} {value} {onChange} />
{:else if field.type === 'subspecies'}
<SubspeciesField {field} {value} {onChange} {data} />
{:else if field.type === 'citizenship'}
<CitizenshipField {field} {value} {onChange} />
{:else if field.type === 'languages'}
<LanguagesField {field} {value} {onChange} />
{/if}

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { HeightField } from '$lib/types';
import { cmToFeetInches } from '$lib/utils/conversions';
let { field, value, onChange }: { field: HeightField; value: number | undefined; onChange: (v: number) => void } = $props();
let converted = $derived(value ? cmToFeetInches(value) : '');
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<div class="flex items-center gap-2 mt-1">
<input
type="number"
value={value ?? ''}
min={0}
oninput={(e) => 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);"
/>
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);">
cm {converted ? `(${converted})` : ''}
</span>
</div>
</label>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import type { LanguagesField } from '$lib/types';
import { languages } from '$lib/data';
let { field, value = ['Tau Ceti Basic'], onChange }: {
field: LanguagesField;
value: string[];
onChange: (v: string[]) => void;
} = $props();
function toggle(name: string) {
if (value.includes(name)) {
onChange(value.filter((v) => v !== name));
} else {
onChange([...value, name]);
}
}
</script>
<fieldset>
<legend class="text-sm font-medium">{field.label}</legend>
<div class="mt-1 flex flex-col gap-1">
{#each languages as lang}
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={value.includes(lang.name)}
onchange={() => toggle(lang.name)}
/>
{lang.name}
</label>
{/each}
</div>
</fieldset>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { ListField } from '$lib/types';
let { field, value = '', onChange }: { field: ListField; value: string; onChange: (v: string) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<textarea
{value}
placeholder="One entry per line"
oninput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
rows={3}
class="mt-1 block w-full rounded px-3 py-2 text-sm resize-y"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
></textarea>
</label>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { NumberField } from '$lib/types';
let { field, value, onChange }: { field: NumberField; value: number | undefined; onChange: (v: number) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<div class="flex items-center gap-2 mt-1">
<input
type="number"
value={value ?? ''}
min={field.min}
max={field.max}
oninput={(e) => 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}
<span class="text-sm" style="color: var(--text-muted);">{field.unit}</span>
{/if}
</div>
</label>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { SelectField } from '$lib/types';
let { field, value = '', onChange }: { field: SelectField; value: string; onChange: (v: string) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<select
{value}
onchange={(e) => 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);"
>
<option value=""></option>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</label>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import type { SpeciesField } from '$lib/types';
import { species } from '$lib/data';
let { field, value = '', onChange }: { field: SpeciesField; value: string; onChange: (v: string) => void } = $props();
let selected = $derived(species.find((s) => s.id === value));
</script>
<div class="block">
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<select
{value}
onchange={(e) => 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);"
>
<option value=""></option>
{#each species as sp}
<option value={sp.id}>{sp.name}</option>
{/each}
</select>
</label>
{#if selected?.description}
<p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p>
{/if}
</div>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import type { SubspeciesField } from '$lib/types';
import { species } from '$lib/data';
import { slugify } from '$lib/utils/slugify';
let { field, value = '', onChange, data }: {
field: SubspeciesField;
value: string;
onChange: (v: string) => void;
data: Record<string, unknown>;
} = $props();
let currentSpecies = $derived(species.find((s) => s.id === data[slugify('Species')]));
let subs = $derived(currentSpecies?.subspecies ?? []);
let label = $derived(currentSpecies?.subspeciesLabel ?? field.label);
let selected = $derived(subs.find((s) => s.id === value));
</script>
{#if subs.length > 0}
<div class="block">
<label class="block">
<span class="text-sm font-medium">{label}</span>
<select
{value}
onchange={(e) => 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);"
>
<option value=""></option>
{#each subs as sub}
<option value={sub.id}>{sub.name}</option>
{/each}
</select>
</label>
{#if selected?.description}
<p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p>
{/if}
</div>
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { TextField } from '$lib/types';
let { field, value = '', onChange }: { field: TextField; value: string; onChange: (v: string) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<input
type="text"
{value}
placeholder={field.placeholder}
oninput={(e) => 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);"
/>
</label>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { TextareaField } from '$lib/types';
let { field, value = '', onChange }: { field: TextareaField; value: string; onChange: (v: string) => void } = $props();
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<textarea
{value}
placeholder={field.placeholder}
oninput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
rows={3}
class="mt-1 block w-full rounded px-3 py-2 text-sm resize-y"
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
></textarea>
</label>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { WeightField } from '$lib/types';
import { kgToLb } from '$lib/utils/conversions';
let { field, value, onChange }: { field: WeightField; value: number | undefined; onChange: (v: number) => void } = $props();
let converted = $derived(value ? Math.round(kgToLb(value)) : '');
</script>
<label class="block">
<span class="text-sm font-medium">{field.label}</span>
<div class="flex items-center gap-2 mt-1">
<input
type="number"
value={value ?? ''}
min={0}
oninput={(e) => 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);"
/>
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);">
kg {converted ? `(${converted} lb)` : ''}
</span>
</div>
</label>

25
src/lib/theme.svelte.ts Normal file
View file

@ -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);
}

View file

@ -1,6 +1,19 @@
<script> <script>
import '../app.css'; import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/theme.svelte';
import { roster } from '$lib/state.svelte';
let { children } = $props(); let { children } = $props();
let ready = $state(false);
onMount(async () => {
theme.init();
await roster.load();
ready = true;
});
</script> </script>
{@render children()} {#if ready}
{@render children()}
{/if}

View file

@ -1 +1,50 @@
<h1 class="text-2xl font-bold p-8">Aurora Character Records Generator</h1> <script lang="ts">
import Header from '$lib/components/Header.svelte';
import DynamicField from '$lib/components/fields/DynamicField.svelte';
import { roster } from '$lib/state.svelte';
import { presets } from '$lib/presets';
import { slugify } from '$lib/utils/slugify';
</script>
<div class="min-h-screen flex flex-col">
<Header />
<main class="flex-1 p-4">
{#if roster.active}
{@const char = roster.active}
<div class="max-w-xl mx-auto flex flex-col gap-6">
{#each char.template.records as record}
<section>
<h2 class="text-sm font-semibold uppercase tracking-wide mb-3" style="color: var(--text-muted);">
{record.type}
</h2>
<div class="flex flex-col gap-4">
{#each record.fields as field}
<DynamicField
{field}
value={char.data[slugify(field.label)]}
data={char.data}
onChange={(v) => {
char.data[slugify(field.label)] = v;
roster.scheduleSave(char);
}}
/>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<div class="flex flex-col items-center justify-center gap-4 h-full min-h-[60vh]">
<p style="color: var(--text-muted);">No characters yet.</p>
<button
onclick={() => roster.create(presets[0])}
class="px-3 py-1 rounded text-sm border hover:opacity-80"
style="border-color: var(--border);"
>
Get Started
</button>
</div>
{/if}
</main>
</div>