Compare commits

..

10 commits

Author SHA1 Message Date
lew
b05094b4d2
minor tweaks and fixes (#16)
* style: revive -^> resuscitate

* build(adapter): we can just use adapter-auto

* fix(subspecies): allows for custom subspecies names

* chore(+page): file input is stateful
2026-03-24 22:20:16 +00:00
lew
4c3348db5d revert: removed DO NOT BORGIFY as a default; it can be added manually by Fed Skrell 2026-03-24 04:15:51 +00:00
lew
52a417fc74 style: revive -^> resuscitate 2026-03-24 04:06:31 +00:00
lew
8eb709841e
Merge pull request #14 from Aurorastation/dev
build: swaps adapter
2026-03-24 03:46:43 +00:00
lew
70554b27f6 build: swaps adapter 2026-03-24 03:46:13 +00:00
lew
c9b7ab30ca
Merge pull request #13 from Aurorastation/dev
Deprecates the old WPF app, replacing it with a web app hosted at c.ily.rs.
2026-03-24 03:40:00 +00:00
lew
79fe404de5 feat: updated help text 2026-03-24 03:38:18 +00:00
lew
ed695d136c docs: README.md 2026-03-24 03:29:19 +00:00
lew
a87ee38839 feat: intro and some context 2026-03-24 03:18:16 +00:00
lew
38768ca963 feat: copied! text on share button 2026-03-24 02:39:36 +00:00
17 changed files with 184 additions and 84 deletions

View file

@ -1,42 +1,44 @@
# sv # Character Records Generator
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). A web-based character records tool for [Aurora Station](https://aurorastation.org/). Hosted at [c.ily.rs](https://c.ily.rs).
## Creating a project 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.
If you're seeing this, you've probably already done this step. Congrats! 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.
```sh Share links let the recipient see a preview of your records, with the option to import the character into their own roster.
# create a new project
npx sv create my-app 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.
```
For issues, your best chance of getting a reply is to make an issue here, or to ping @llywelwyn in Discord.
To recreate this project with the same configuration:
Cheers.
```sh
# recreate this project ## Development
npx sv@0.12.8 create --template minimal --types ts --no-install .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh ```sh
npm install
npm run dev npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
``` ```
## Building Build for production:
To create a production version of your app:
```sh ```sh
npm run build npm run build
``` ```
You can preview the production build with `npm run preview`. Validate the data files:
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ```sh
npm run validate
```
Run tests:
```sh
npx vitest run
```
## Where did the old WPF app go?
This used to be a WPF desktop app. The last version of that lives at [`03feee5`](https://github.com/Aurorastation/character-records-generator/tree/03feee572bc7085fd8f9c458490a5dcc642ce689).

View file

@ -38,8 +38,7 @@
<record type="medical"> <record type="medical">
<preamble>The following information is protected by doctor-patient confidentiality laws. Do not release without patient's consent.</preamble> <preamble>The following information is protected by doctor-patient confidentiality laws. Do not release without patient's consent.</preamble>
<field label="Opt-Outs" type="multi-select"> <field label="Opt-Outs" type="multi-select">
<option value="no-borg" label="Do NOT BORGIFY" /> <option value="no-revive" label="DO NOT RESUSCITATE" />
<option value="no-revive" label="DO NOT REVIVE" />
<option value="no-prosthetic" label="DO NOT GIVE PROSTHETICS" /> <option value="no-prosthetic" label="DO NOT GIVE PROSTHETICS" />
</field> </field>
<field label="Postmortem Instructions" type="textarea" /> <field label="Postmortem Instructions" type="textarea" />

13
package-lock.json generated
View file

@ -15,8 +15,7 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
@ -1112,16 +1111,6 @@
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.0.0"
} }
}, },
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.55.0", "version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",

View file

@ -13,8 +13,7 @@
"validate": "scripts/validate-xml.sh" "validate": "scripts/validate-xml.sh"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",

View file

@ -17,6 +17,7 @@
--text-muted: #737373; --text-muted: #737373;
--accent: #0066cc; --accent: #0066cc;
--accent-hover: #0052a3; --accent-hover: #0052a3;
--error: #dc2626;
} }
.dark { .dark {
@ -28,6 +29,7 @@
--text-muted: #a3a3a3; --text-muted: #a3a3a3;
--accent: #4da6ff; --accent: #4da6ff;
--accent-hover: #80bfff; --accent-hover: #80bfff;
--error: #f87171;
} }
body { body {
@ -35,3 +37,9 @@ body {
color: var(--text); color: var(--text);
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
} }
.field-error input,
.field-error select,
.field-error textarea {
border-color: var(--error) !important;
}

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aurora Records</title> <title>Character Records</title>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Sun, Moon, Trash2, Plus, Upload } from 'lucide-svelte'; import { Sun, Moon, Trash2, Plus, Upload, CircleHelp } from 'lucide-svelte';
import { theme } from '$lib/theme.svelte'; import { theme } from '$lib/theme.svelte';
import { roster } from '$lib/state.svelte'; import { roster } from '$lib/state.svelte';
import { presets } from '$lib/presets'; import { presets } from '$lib/presets';
@ -8,11 +8,13 @@
import TemplatePicker from './TemplatePicker.svelte'; import TemplatePicker from './TemplatePicker.svelte';
import ShareMenu from './ShareMenu.svelte'; import ShareMenu from './ShareMenu.svelte';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import HelpText from './HelpText.svelte';
let { onImport }: { onImport?: (json: string) => void } = $props(); let { onImport }: { onImport?: (json: string) => void } = $props();
let confirmDelete = $state(false); let confirmDelete = $state(false);
let showPicker = $state(false); let showPicker = $state(false);
let showHelp = $state(false);
let openDropdown = $state<'add' | 'share' | null>(null); let openDropdown = $state<'add' | 'share' | null>(null);
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
@ -62,7 +64,7 @@
<header class="border-b shrink-0" style="border-color: var(--border); background: var(--bg-card);"> <header class="border-b shrink-0" style="border-color: var(--border); background: var(--bg-card);">
<div class="flex items-center gap-2 px-4 py-3 max-w-7xl mx-auto w-full"> <div class="flex items-center gap-2 px-4 py-3 max-w-7xl mx-auto w-full">
<h1 class="font-bold whitespace-nowrap">Aurora Records</h1> <h1 class="font-bold whitespace-nowrap">Character Records</h1>
{#if roster.characters.length > 0} {#if roster.characters.length > 0}
<CharacterSwitcher /> <CharacterSwitcher />
@ -109,6 +111,10 @@
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if} {#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
</span> </span>
<button onclick={() => { showHelp = true; }} class="flex items-center justify-center w-[30px] h-[30px] rounded hover:opacity-80" title="About">
<CircleHelp size={18} />
</button>
<button onclick={() => theme.toggle()} class="flex items-center justify-center w-[30px] h-[30px] rounded hover:opacity-80" title="Toggle theme"> <button onclick={() => theme.toggle()} class="flex items-center justify-center w-[30px] h-[30px] rounded hover:opacity-80" title="Toggle theme">
{#if theme.dark} {#if theme.dark}
<Sun size={18} /> <Sun size={18} />
@ -147,4 +153,19 @@
</Modal> </Modal>
{/if} {/if}
{#if showHelp}
<Modal onClose={() => { showHelp = false; }}>
<HelpText />
<div class="flex justify-end mt-4">
<button
onclick={() => { showHelp = false; }}
class="px-3 py-1 rounded text-sm border hover:opacity-80"
style="border-color: var(--border); color: var(--text);"
>
Got it
</button>
</div>
</Modal>
{/if}
<svelte:window onclick={() => { openDropdown = null; }} /> <svelte:window onclick={() => { openDropdown = null; }} />

View file

@ -0,0 +1,17 @@
<div class="text-sm flex flex-col gap-4" style="color: var(--text-muted);">
<p>
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.
</p>
<p>
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.
</p>
<p>
Share links let the recipient see a preview of your records, with the option to import the character into their own roster.
</p>
<p>
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.
</p>
<p>
Cheers.
</p>
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { ChevronDown } from 'lucide-svelte'; import { ChevronDown } from 'lucide-svelte';
import type { RecordDef } from '$lib/types'; import type { FieldDef, RecordDef } from '$lib/types';
import DynamicField from './fields/DynamicField.svelte'; import DynamicField from './fields/DynamicField.svelte';
import { slugify } from '$lib/utils/slugify'; import { slugify } from '$lib/utils/slugify';
@ -12,15 +12,22 @@
} = $props(); } = $props();
let expanded = $state(false); let expanded = $state(false);
let touched: Record<string, boolean> = $state({});
function isFieldEmpty(v: unknown): boolean {
if (v === undefined || v === null || v === '' || v === 0) return true;
if (Array.isArray(v) && v.length === 0) return true;
return false;
}
function isRequired(field: FieldDef): boolean {
if (field.type === 'separator') return false;
return !!field.required;
}
let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator')); let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator'));
let filled = $derived( let filled = $derived(
dataFields.filter((f) => { dataFields.filter((f) => !isFieldEmpty(data[slugify(f.label)])).length
const v = data[slugify(f.label)];
if (v === undefined || v === null || v === '' || v === 0) return false;
if (Array.isArray(v) && v.length === 0) return false;
return true;
}).length
); );
</script> </script>
@ -55,12 +62,26 @@
{#if expanded} {#if expanded}
<div transition:slide={{ duration: 150 }} class="px-4 pb-4 flex flex-col gap-4"> <div transition:slide={{ duration: 150 }} class="px-4 pb-4 flex flex-col gap-4">
{#each record.fields as field} {#each record.fields as field}
{@const key = slugify(field.label)}
{@const hasError = isRequired(field) && touched[key] && isFieldEmpty(data[key])}
<div
class={hasError ? 'field-error' : ''}
onfocusout={(e) => {
if (isRequired(field) && !(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
touched[key] = true;
}
}}
>
<DynamicField <DynamicField
{field} {field}
value={data[slugify(field.label)]} value={data[key]}
{data} {data}
onChange={(v) => onFieldChange(slugify(field.label), v)} onChange={(v) => onFieldChange(key, v)}
/> />
{#if hasError}
<p class="text-xs mt-1" style="color: var(--error);">This field is required</p>
{/if}
</div>
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -36,12 +36,12 @@
<span class="relative"> <span class="relative">
<button <button
onclick={(e) => { e.stopPropagation(); onToggle(); }} onclick={(e) => { e.stopPropagation(); onToggle(); }}
class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80" 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);" style="border-color: var(--border);"
title="Share & export" title="Share & export"
> >
{#if copied} {#if copied}
<Check size={14} /> <Check size={14} /> <span class="text-xs">Copied!</span>
{:else} {:else}
<Share2 size={14} /> <Share2 size={14} />
{/if} {/if}

View file

@ -22,7 +22,7 @@ describe('exportCharacter', () => {
expect(parsed.version).toBe(1); expect(parsed.version).toBe(1);
expect(parsed.templateId).toBe('preset:standard'); expect(parsed.templateId).toBe('preset:standard');
expect(parsed.template).toBeDefined(); expect(parsed.template).toBeDefined();
expect(parsed.template.name).toBe('Standard'); expect(parsed.template.name).toBe('General');
expect(parsed.data).toEqual({ expect(parsed.data).toEqual({
name: 'Yury Zakharov', name: 'Yury Zakharov',
species: 'human', species: 'human',

View file

@ -101,6 +101,11 @@ describe('formatFieldOutput', () => {
expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar'); expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar');
}); });
it('formats custom subspecies with dynamic label', () => {
const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
expect(formatFieldOutput(field, 'asdfg', stubSpecies, 'tajara')).toBe('Ethnicity: asdfg');
});
it('returns null for empty subspecies', () => { it('returns null for empty subspecies', () => {
const field: FieldDef = { label: 'Subspecies', type: 'subspecies' }; const field: FieldDef = { label: 'Subspecies', type: 'subspecies' };
expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull(); expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull();

View file

@ -48,7 +48,7 @@ export function formatFieldOutput(
const sp = speciesData.find((s) => s.id === currentSpecies); const sp = speciesData.find((s) => s.id === currentSpecies);
if (!sp) return null; if (!sp) return null;
const sub = sp.subspecies.find((s) => s.id === value); const sub = sp.subspecies.find((s) => s.id === value);
return sub ? `${sp.subspeciesLabel}: ${sub.name}` : null; return sub ? `${sp.subspeciesLabel}: ${sub.name}` : `${sp.subspeciesLabel}: ${value}`;
} }
case 'languages': { case 'languages': {

View file

@ -41,7 +41,7 @@ describe('character URL encoding', () => {
const encoded = encodeCharacterURL(testCharacter); const encoded = encodeCharacterURL(testCharacter);
const decoded = decodeCharacterURL(encoded); const decoded = decodeCharacterURL(encoded);
expect(decoded.data).toEqual(testCharacter.data); expect(decoded.data).toEqual(testCharacter.data);
expect(decoded.template.name).toBe('Standard'); expect(decoded.template.name).toBe('General');
}); });
it('uses short encoding for preset templates', () => { it('uses short encoding for preset templates', () => {

View file

@ -40,8 +40,8 @@ describe('isBlankCharacter', () => {
expect(isBlankCharacter(makeChar({ 'spoken-languages': [] }))).toBe(true); expect(isBlankCharacter(makeChar({ 'spoken-languages': [] }))).toBe(true);
}); });
it('returns true when languages is just the default', () => { it('returns false when languages has any value', () => {
expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic'] }))).toBe(true); expect(isBlankCharacter(makeChar({ 'spoken-languages': ['Tau Ceti Basic'] }))).toBe(false);
}); });
it('returns false when languages has custom values', () => { it('returns false when languages has custom values', () => {

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';
import HelpText from '$lib/components/HelpText.svelte';
import SchemaForm from '$lib/components/SchemaForm.svelte'; import SchemaForm from '$lib/components/SchemaForm.svelte';
import OutputPanel from '$lib/components/OutputPanel.svelte'; import OutputPanel from '$lib/components/OutputPanel.svelte';
import ImportModal from '$lib/components/ImportModal.svelte'; import ImportModal from '$lib/components/ImportModal.svelte';
@ -11,8 +12,10 @@
let importData = $state<string | null>(null); let importData = $state<string | null>(null);
let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null); let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null);
let importError = $state<string | null>(null);
let mobileView = $state<'edit' | 'preview' | 'split'>('split'); let mobileView = $state<'edit' | 'preview' | 'split'>('split');
let showPicker = $state(false); let showPicker = $state(false);
let emptyFileInput = $state<HTMLInputElement>();
function checkHash() { function checkHash() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
@ -35,8 +38,9 @@
function handleFileImport(json: string) { function handleFileImport(json: string) {
try { try {
fileImportData = parseCharacterFile(json); fileImportData = parseCharacterFile(json);
importError = null;
} catch { } catch {
// TODO: show error to user importError = 'Could not read that file. Check that it\u2019s a valid character export.';
} }
} }
@ -44,12 +48,28 @@
fileImportData = null; fileImportData = null;
} }
async function handleEmptyFileImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
handleFileImport(text);
input.value = '';
}
const modes = ['edit', 'preview', 'split'] as const; const modes = ['edit', 'preview', 'split'] as const;
</script> </script>
<div class="h-dvh flex flex-col overflow-hidden"> <div class="h-dvh flex flex-col overflow-hidden">
<Header onImport={handleFileImport} /> <Header onImport={handleFileImport} />
{#if importError}
<div class="px-4 py-2 text-sm flex items-center justify-center gap-2 border-b" style="background: color-mix(in srgb, var(--error) 8%, var(--bg)); color: var(--error); border-color: var(--border);">
<span>{importError}</span>
<button onclick={() => { importError = null; }} class="underline hover:opacity-80">Dismiss</button>
</div>
{/if}
{#if fileImportData} {#if fileImportData}
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<ImportModal fileData={fileImportData} onClose={closeFileImport} /> <ImportModal fileData={fileImportData} onClose={closeFileImport} />
@ -109,19 +129,40 @@
{/if} {/if}
</main> </main>
{:else} {:else}
<main class="flex-1 flex flex-col items-center justify-center gap-4"> <main class="flex-1 flex items-center justify-center p-6">
<p style="color: var(--text-muted);">No characters yet.</p> <div class="max-w-md w-full flex flex-col gap-6">
<HelpText />
<div class="flex gap-3">
<button <button
onclick={() => { onclick={() => {
if (presets.length === 1) roster.create(presets[0]); if (presets.length === 1) roster.create(presets[0]);
else showPicker = true; else showPicker = true;
}} }}
class="px-3 py-1 rounded text-sm border hover:opacity-80" class="flex-1 px-4 py-2 rounded text-sm font-medium hover:opacity-90"
style="border-color: var(--border);" style="background: var(--accent); color: white;"
> >
Get Started New Character
</button> </button>
<button
onclick={() => emptyFileInput.click()}
class="px-4 py-2 rounded text-sm border hover:opacity-80"
style="border-color: var(--border); color: var(--text-muted);"
>
Import
</button>
</div>
</div>
</main> </main>
<input
bind:this={emptyFileInput}
type="file"
accept=".json"
class="hidden"
onchange={handleEmptyFileImport}
/>
{#if showPicker} {#if showPicker}
<TemplatePicker onClose={() => { showPicker = false; }} /> <TemplatePicker onClose={() => { showPicker = false; }} />
{/if} {/if}

View file

@ -1,11 +1,9 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
kit: { kit: {
adapter: adapter({ adapter: adapter()
fallback: 'index.html'
})
} }
}; };