feat(oxibadge): add onboarding flow with i18n and reader preferences
Check, format and test / build (push) Successful in 9m59s

Adds the first real screen: a setup form for the device's host/port,
tested against the real device (3 retries) and persisted to
preferences.toml before showing a placeholder "done" screen.

- Rust: test_connection/save_preferences/load_preferences commands;
  preferences stored as [[reader]] in TOML so more readers can be
  added later without a format change.
- Svelte: split the monolithic page into reusable components
  (Screen, Column, Field, Button, ErrorBanner, Hint) under
  src/lib/components, sharing design tokens from app.css.
- Automatic it/en i18n via svelte-i18n, locale detected from
  navigator.language.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 11:08:02 +02:00
parent 268a917a69
commit 99772b9e4a
19 changed files with 821 additions and 29 deletions
@@ -0,0 +1,137 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { _ } from 'svelte-i18n';
import Column from './Column.svelte';
import Field from './Field.svelte';
import Button from './Button.svelte';
import ErrorBanner from './ErrorBanner.svelte';
import Hint from './Hint.svelte';
let { onconnected }: { onconnected: (cfg: { host: string; port: number }) => void } = $props();
type Phase = 'idle' | 'testing' | 'err';
let host = $state('');
let port = $state(5005);
let phase = $state<Phase>('idle');
let errorCode = $state('');
const trimmedHost = $derived(host.trim());
const empty = $derived(trimmedHost === '');
const disabled = $derived(empty || phase === 'testing');
const btnLabel = $derived(
phase === 'testing'
? $_('onboarding.button.testing')
: phase === 'err'
? $_('onboarding.button.retry')
: $_('onboarding.button.idle')
);
function resetPhase() {
phase = 'idle';
errorCode = '';
}
async function testAndSave() {
if (disabled) return;
phase = 'testing';
errorCode = '';
try {
await invoke('test_connection', { host: trimmedHost, port });
await invoke('save_preferences', { host: trimmedHost, port });
onconnected({ host: trimmedHost, port });
} catch (err) {
errorCode = String(err);
phase = 'err';
}
}
</script>
<Column>
<header class="intro">
<h1>{$_('onboarding.title')}</h1>
<p>{$_('onboarding.subtitle')}</p>
</header>
<form
class="form"
onsubmit={(event) => {
event.preventDefault();
testAndSave();
}}
>
<div class="row">
<Field
id="ox-host"
label={$_('onboarding.host_label')}
bind:value={host}
placeholder="192.168.1.100"
oninput={resetPhase}
>
{#snippet icon()}
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="12" rx="2" /><path d="M8 20h8M12 16v4" />
</svg>
{/snippet}
</Field>
<Field
id="ox-port"
label={$_('onboarding.port_label')}
bind:value={port}
type="number"
placeholder="5005"
oninput={resetPhase}
/>
</div>
<Button type="submit" label={btnLabel} faded={empty} busy={phase === 'testing'} {disabled} />
{#if phase === 'err'}
<ErrorBanner title={$_('onboarding.error_title')} message={$_(`onboarding.errors.${errorCode}`)} />
{/if}
<Hint text={$_('onboarding.hint')} />
</form>
</Column>
<style>
.intro {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
h1 {
margin: 0;
font-family: var(--ox-font-display);
font-weight: 700;
font-size: 48px;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--ox-text-strong);
}
.intro p {
margin: 0;
max-width: 340px;
font-size: 15px;
line-height: 1.5;
color: var(--ox-text-muted);
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 108px;
gap: 12px;
align-items: start;
}
</style>