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
+51
View File
@@ -0,0 +1,51 @@
@import '@fontsource/geist/latin-400.css';
@import '@fontsource/geist/latin-500.css';
@import '@fontsource/geist/latin-600.css';
@import '@fontsource/bricolage-grotesque/latin-700.css';
@import '@fontsource/geist-mono/latin-400.css';
:root {
--ox-bg: #121212;
--ox-text: #f2f2f2;
--ox-text-strong: #ffffff;
--ox-text-muted: #a3a3a3;
--ox-text-subtle: #8a8a8a;
--ox-label: #9a9a9a;
--ox-placeholder: #5e5e5e;
--ox-border: #333333;
--ox-border-hover: #4a4a4a;
--ox-accent: #f74c00;
--ox-accent-soft: #ff7a3d;
--ox-accent-ring: rgba(247, 76, 0, 0.16);
--ox-on-accent: #1a0700;
--ox-error-bg: #261316;
--ox-error-border: #5a2229;
--ox-error-text: #f5b5b9;
--ox-error-title: #ffd6d8;
--ox-error-icon: #ff6b74;
--ox-radius: 12px;
--ox-control-h: 52px;
--ox-font-display: 'Bricolage Grotesque', 'Geist', system-ui, sans-serif;
--ox-font-body: 'Geist', system-ui, sans-serif;
--ox-font-mono: 'Geist Mono', ui-monospace, monospace;
}
body {
margin: 0;
background: var(--ox-bg);
color: var(--ox-text);
font-family: var(--ox-font-body);
}
svg {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
let {
label,
type = 'button',
faded = false,
busy = false,
disabled = false,
onclick
}: {
label: string;
type?: 'button' | 'submit';
faded?: boolean;
busy?: boolean;
disabled?: boolean;
onclick?: () => void;
} = $props();
</script>
<button {type} class="btn" class:faded class:busy {disabled} {onclick}>
<span>{label}</span>
{#if busy}
<span class="spinner"></span>
{:else}
<svg width="18" height="18" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"
><path d="M5 12h14M13 6l6 6-6 6" /></svg
>
{/if}
</button>
<style>
.btn {
all: unset;
box-sizing: border-box;
height: var(--ox-control-h);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: var(--ox-radius);
background: var(--ox-accent);
color: var(--ox-on-accent);
font-family: var(--ox-font-body);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition:
filter 0.15s,
transform 0.1s,
opacity 0.2s;
}
.btn:not(:disabled):hover {
filter: brightness(1.08);
}
.btn:not(:disabled):active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
.btn:disabled {
cursor: not-allowed;
}
.btn.faded {
opacity: 0.35;
}
.btn.busy {
opacity: 0.7;
cursor: progress;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(26, 7, 0, 0.35);
border-top-color: var(--ox-on-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="column">
{@render children()}
</div>
<style>
.column {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 36px;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
let { title, message }: { title: string; message: string } = $props();
</script>
<div class="banner" role="alert">
<svg width="20" height="20" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
<path
d="M10.3 3.9L2.4 17.5A2 2 0 0 0 4.1 20.5h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
/><path d="M12 9.5v4M12 17h.01" />
</svg>
<div class="banner-text">
<strong>{title}</strong>
<span>{message}</span>
</div>
</div>
<style>
.banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: var(--ox-radius);
font-size: 14px;
line-height: 1.45;
background: var(--ox-error-bg);
border: 1px solid var(--ox-error-border);
color: var(--ox-error-text);
}
.banner svg {
color: var(--ox-error-icon);
margin-top: 1px;
}
.banner-text {
display: flex;
flex-direction: column;
gap: 3px;
}
.banner-text strong {
font-weight: 600;
color: var(--ox-error-title);
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
id,
label,
value = $bindable(),
type = 'text',
placeholder = '',
oninput,
icon
}: {
id: string;
label: string;
value: string | number;
type?: string;
placeholder?: string;
oninput?: () => void;
icon?: Snippet;
} = $props();
</script>
<div class="field">
<label for={id}>{label}</label>
{#if icon}{@render icon()}{/if}
<input {id} {type} {placeholder} bind:value {oninput} autocomplete="off" spellcheck="false" />
</div>
<style>
.field {
position: relative;
height: var(--ox-control-h);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px;
background: var(--ox-bg);
border: 1px solid var(--ox-border);
border-radius: var(--ox-radius);
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.field:hover {
border-color: var(--ox-border-hover);
}
.field:focus-within {
border-color: var(--ox-accent);
box-shadow: 0 0 0 4px var(--ox-accent-ring);
}
.field label {
position: absolute;
top: -9px;
left: 12px;
padding: 0 6px;
background: var(--ox-bg);
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--ox-label);
transition: color 0.15s;
}
.field:focus-within label {
color: var(--ox-accent-soft);
}
.field :global(svg) {
color: #6e6e6e;
}
.field input {
all: unset;
box-sizing: border-box;
min-width: 0;
width: 100%;
font-family: var(--ox-font-mono);
font-size: 16px;
color: var(--ox-text);
caret-color: var(--ox-accent);
}
.field input::placeholder {
color: var(--ox-placeholder);
}
.field input[type='number']::-webkit-outer-spin-button,
.field input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let { text }: { text: string } = $props();
</script>
<p class="hint">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
</svg>
<span>{text}</span>
</p>
<style>
.hint {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--ox-text-subtle);
text-align: center;
}
</style>
@@ -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>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<main class="screen">
{@render children()}
</main>
<style>
.screen {
min-height: 100vh;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
{
"loading": "Loading...",
"onboarding": {
"title": "Welcome!",
"subtitle": "Connect Oxibadge to your time clock by entering its IP address and port.",
"host_label": "IP address",
"port_label": "Port",
"hint": "You can find the address in the time clock's network settings.",
"button": {
"idle": "Test connection",
"testing": "Connecting...",
"retry": "Retry"
},
"error_title": "Could not reach the time clock",
"errors": {
"connection-refused": "Connection refused",
"timed-out": "Connection timed out",
"dns-failure": "Invalid or unreachable address",
"unknown": "Connection error"
}
},
"done": {
"message": "Nothing to do here."
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"loading": "Caricamento...",
"onboarding": {
"title": "Benvenuto!",
"subtitle": "Collega Oxibadge al tuo timbratore inserendo il suo indirizzo IP e la porta.",
"host_label": "Indirizzo IP",
"port_label": "Porta",
"hint": "Trovi l'indirizzo nelle impostazioni di rete del timbratore.",
"button": {
"idle": "Testa connessione",
"testing": "Connessione in corso...",
"retry": "Riprova"
},
"error_title": "Impossibile raggiungere il timbratore",
"errors": {
"connection-refused": "Connessione rifiutata",
"timed-out": "Connessione scaduta",
"dns-failure": "Indirizzo non valido o non raggiungibile",
"unknown": "Errore di connessione"
}
},
"done": {
"message": "Non c'è niente da fare."
}
}
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import '../app.css';
import { waitLocale } from 'svelte-i18n';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
let ready = $state(false);
waitLocale().then(() => {
ready = true;
});
</script>
{#if ready}
{@render children()}
{/if}
+10
View File
@@ -1,5 +1,15 @@
import { getLocaleFromNavigator, init, register } from 'svelte-i18n';
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;
register('it', () => import('$lib/locales/it.json'));
register('en', () => import('$lib/locales/en.json'));
init({
fallbackLocale: 'it',
initialLocale: getLocaleFromNavigator()
});
+22 -29
View File
@@ -1,33 +1,26 @@
<main>
<img src="/logo.svg" alt="Oxibadge logo" class="logo" />
<h1>Hello, world!</h1>
</main>
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import Screen from '$lib/components/Screen.svelte';
import Onboarding from '$lib/components/Onboarding.svelte';
<style>
:global(body) {
margin: 0;
color: #f6f6f6;
background-color: #2f2f2f;
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
}
type Step = 'loading' | 'setup' | 'done';
main {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
let step = $state<Step>('loading');
.logo {
width: 8rem;
height: 8rem;
}
onMount(async () => {
const preferences = await invoke<{ host: string; port: number } | null>('load_preferences');
step = preferences ? 'done' : 'setup';
});
</script>
h1 {
margin: 0;
font-size: 2rem;
font-weight: 500;
}
</style>
<Screen>
{#if step === 'loading'}
<p>{$_('loading')}</p>
{:else if step === 'setup'}
<Onboarding onconnected={() => (step = 'done')} />
{:else}
<p>{$_('done.message')}</p>
{/if}
</Screen>