feat(01-10): SvelteKit frontend scaffold with Catppuccin theme and clients

- SvelteKit SPA with adapter-static, prerender, SSR disabled
- Catppuccin Mocha/Latte theme CSS with semantic color tokens
- WebSocket client with auto-reconnect and exponential backoff
- HTTP API client with JWT auth and 401 handling
- Auth state store with localStorage persistence (Svelte 5 runes)
- Tournament state store handling all WS message types (Svelte 5 runes)
- PIN login page with numpad, 48px touch targets
- Updated Makefile frontend target for real SvelteKit build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-03-01 03:54:29 +01:00
parent 9ce05f6c67
commit 47e1f19edd
43 changed files with 3230 additions and 42 deletions

View file

@ -18,11 +18,7 @@ test:
CGO_ENABLED=1 go test ./... CGO_ENABLED=1 go test ./...
frontend: frontend:
@mkdir -p frontend/build cd frontend && npm install && npm run build
@if [ ! -f frontend/build/index.html ]; then \
echo '<!DOCTYPE html><html><head><title>Felt</title></head><body><h1>Felt</h1><p>Loading...</p></body></html>' > frontend/build/index.html; \
fi
@echo "Frontend build complete (stub)"
all: frontend build all: frontend build

2
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
.svelte-kit/

View file

@ -0,0 +1 @@
export const env={}

View file

@ -0,0 +1 @@
[data-theme=mocha],:root{--ctp-rosewater: #f5e0dc;--ctp-flamingo: #f2cdcd;--ctp-pink: #f5c2e7;--ctp-mauve: #cba6f7;--ctp-red: #f38ba8;--ctp-maroon: #eba0ac;--ctp-peach: #fab387;--ctp-yellow: #f9e2af;--ctp-green: #a6e3a1;--ctp-teal: #94e2d5;--ctp-sky: #89dceb;--ctp-sapphire: #74c7ec;--ctp-blue: #89b4fa;--ctp-lavender: #b4befe;--ctp-text: #cdd6f4;--ctp-subtext1: #bac2de;--ctp-subtext0: #a6adc8;--ctp-overlay2: #9399b2;--ctp-overlay1: #7f849c;--ctp-overlay0: #6c7086;--ctp-surface2: #585b70;--ctp-surface1: #45475a;--ctp-surface0: #313244;--ctp-base: #1e1e2e;--ctp-mantle: #181825;--ctp-crust: #11111b;--color-bg: var(--ctp-base);--color-bg-elevated: var(--ctp-mantle);--color-bg-sunken: var(--ctp-crust);--color-surface: var(--ctp-surface0);--color-surface-hover: var(--ctp-surface1);--color-surface-active: var(--ctp-surface2);--color-text: var(--ctp-text);--color-text-secondary: var(--ctp-subtext1);--color-text-muted: var(--ctp-subtext0);--color-primary: var(--ctp-blue);--color-success: var(--ctp-green);--color-warning: var(--ctp-yellow);--color-error: var(--ctp-red);--color-accent: var(--ctp-mauve);--color-border: var(--ctp-surface1);--color-overlay: var(--ctp-overlay0);--color-felt: var(--ctp-green);--color-card: var(--ctp-text);--color-bounty: var(--ctp-pink);--color-prize: var(--ctp-yellow);--color-chip: var(--ctp-peach);--color-clock: var(--ctp-sapphire);--color-break: var(--ctp-teal);--color-elimination: var(--ctp-red)}[data-theme=latte]{--ctp-rosewater: #dc8a78;--ctp-flamingo: #dd7878;--ctp-pink: #ea76cb;--ctp-mauve: #8839ef;--ctp-red: #d20f39;--ctp-maroon: #e64553;--ctp-peach: #fe640b;--ctp-yellow: #df8e1d;--ctp-green: #40a02b;--ctp-teal: #179299;--ctp-sky: #04a5e5;--ctp-sapphire: #209fb5;--ctp-blue: #1e66f5;--ctp-lavender: #7287fd;--ctp-text: #4c4f69;--ctp-subtext1: #5c5f77;--ctp-subtext0: #6c6f85;--ctp-overlay2: #7c7f93;--ctp-overlay1: #8c8fa1;--ctp-overlay0: #9ca0b0;--ctp-surface2: #acb0be;--ctp-surface1: #bcc0cc;--ctp-surface0: #ccd0da;--ctp-base: #eff1f5;--ctp-mantle: #e6e9ef;--ctp-crust: #dce0e8}:root{--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: 1rem;--text-lg: 1.125rem;--text-xl: 1.25rem;--text-2xl: 1.5rem;--text-3xl: 1.875rem;--text-4xl: 2.25rem;--leading-tight: 1.25;--leading-normal: 1.5;--leading-relaxed: 1.75;--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-6: 1.5rem;--space-8: 2rem;--space-12: 3rem;--radius-sm: .25rem;--radius-md: .5rem;--radius-lg: .75rem;--radius-xl: 1rem;--radius-full: 9999px;--shadow-sm: 0 1px 2px rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px rgba(0, 0, 0, .3);--shadow-lg: 0 10px 15px rgba(0, 0, 0, .3);--transition-fast: .1s ease;--transition-normal: .2s ease;--transition-slow: .3s ease;--touch-target: 48px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html{font-family:var(--font-body);font-size:16px;line-height:var(--leading-normal);color:var(--color-text);background-color:var(--color-bg);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;scrollbar-gutter:stable}body{min-height:100dvh;color:var(--color-text);background-color:var(--color-bg)}button,a,input,select,textarea,[role=button],[role=tab],[role=menuitem]{touch-action:manipulation}.touch-target,button,[role=button],[role=tab]{min-height:var(--touch-target);min-width:var(--touch-target)}button:active,[role=button]:active,[role=tab]:active,.touch-target:active{transform:scale(.97);opacity:.9;transition:transform var(--transition-fast),opacity var(--transition-fast)}:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}:focus:not(:focus-visible){outline:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--color-bg)}::-webkit-scrollbar-thumb{background:var(--color-surface-active);border-radius:var(--radius-full)}::-webkit-scrollbar-thumb:hover{background:var(--color-overlay)}*{scrollbar-width:thin;scrollbar-color:var(--color-surface-active) var(--color-bg)}.font-mono{font-family:var(--font-mono)}.timer,.number,.blinds,.chips,.currency{font-family:var(--font-mono);font-variant-numeric:tabular-nums}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}

View file

@ -0,0 +1 @@
.container.svelte-1uha8ag{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100dvh;padding:1rem;gap:.5rem}h1.svelte-1uha8ag{font-size:2rem;font-weight:700;color:var(--color-primary)}.text-secondary.svelte-1uha8ag{color:var(--color-text-secondary)}

View file

@ -0,0 +1 @@
.login-container.svelte-1x05zx6{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:var(--space-4);background-color:var(--color-bg)}.login-card.svelte-1x05zx6{width:100%;max-width:360px;display:flex;flex-direction:column;align-items:center;gap:var(--space-6)}.logo.svelte-1x05zx6{text-align:center}.logo.svelte-1x05zx6 h1:where(.svelte-1x05zx6){font-size:var(--text-4xl);font-weight:700;color:var(--color-primary);letter-spacing:-.02em}.subtitle.svelte-1x05zx6{color:var(--color-text-secondary);font-size:var(--text-sm);margin-top:var(--space-1)}.pin-display.svelte-1x05zx6{display:flex;gap:var(--space-3);padding:var(--space-4) 0}.pin-dot.svelte-1x05zx6{width:16px;height:16px;border-radius:var(--radius-full);border:2px solid var(--color-surface-active);background-color:transparent;transition:background-color var(--transition-fast),border-color var(--transition-fast)}.pin-dot.filled.svelte-1x05zx6{background-color:var(--color-primary);border-color:var(--color-primary)}.error-message.svelte-1x05zx6{color:var(--color-error);font-size:var(--text-sm);text-align:center;padding:var(--space-2) var(--space-4);background-color:color-mix(in srgb,var(--color-error) 10%,transparent);border-radius:var(--radius-md);width:100%}.numpad.svelte-1x05zx6{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);width:100%}.numpad-btn.svelte-1x05zx6{display:flex;align-items:center;justify-content:center;height:64px;font-size:var(--text-2xl);font-weight:600;color:var(--color-text);background-color:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;transition:background-color var(--transition-fast)}.numpad-btn.svelte-1x05zx6:hover:not(:disabled){background-color:var(--color-surface-hover)}.numpad-btn.svelte-1x05zx6:disabled{opacity:.4;cursor:not-allowed;transform:none}.numpad-fn.svelte-1x05zx6{font-size:var(--text-sm);font-weight:500;color:var(--color-text-secondary)}.submit-btn.svelte-1x05zx6{width:100%;height:56px;font-size:var(--text-lg);font-weight:600;color:var(--color-bg);background-color:var(--color-primary);border:none;border-radius:var(--radius-lg);cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;transition:background-color var(--transition-fast),opacity var(--transition-fast)}.submit-btn.svelte-1x05zx6:hover:not(:disabled){opacity:.9}.submit-btn.svelte-1x05zx6:disabled{opacity:.4;cursor:not-allowed;transform:none}

View file

@ -0,0 +1 @@
import{N as p,m as u,O as c,P as f,Q as E,T as g,R as w,h as d,j as s,S as N,c as y,U as M,f as x,V as A}from"./Ym0WvvUy.js";var l;const i=((l=globalThis==null?void 0:globalThis.window)==null?void 0:l.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function L(t){return(i==null?void 0:i.createHTML(t))??t}function O(t){var r=p("template");return r.innerHTML=L(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=c;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&g)!==0,m=(r&w)!==0,a,v=!t.startsWith("<!>");return()=>{if(d)return n(s,null),s;a===void 0&&(a=O(v?t:"<!>"+t),e||(a=f(a)));var o=m||E?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=f(o),h=o.lastChild;n(T,h)}else n(o,o);return o}}function C(t=""){if(!d){var r=u(t+"");return n(r,r),r}var e=s;return e.nodeType!==M?(e.before(e=u()),x(e)):A(e),n(e,e),e}function I(){if(d)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=u();return t.append(r,e),n(r,e),t}function S(t,r){if(d){var e=c;((e.f&N)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const P="5";var _;typeof window<"u"&&((_=window.__svelte??(window.__svelte={})).v??(_.v=new Set)).add(P);export{S as a,n as b,I as c,b as f,C as t};

View file

@ -0,0 +1 @@
import{b as T,h as o,c as b,E as v,r as p,H as A,d as E,e as R,f as g,i as l,j as m}from"./Ym0WvvUy.js";import{B as d}from"./Da6yQRl8.js";function H(_,u,c=!1){var i;o&&(i=m,b());var n=new d(_),h=c?v:0;function f(a,s){if(o){var r=p(i),e;if(r===A?e=0:r===E?e=!1:e=parseInt(r.substring(1)),a!==e){var t=R();g(t),n.anchor=t,l(!1),n.ensure(a,s),l(!0);return}}n.ensure(a,s)}T(()=>{var a=!1;u((s,r=0)=>{a=!0,f(r,s)}),a||f(!1,null)},h)}export{H as i};

View file

@ -0,0 +1 @@
import{D as o,B as t,M as c,F as u}from"./Ym0WvvUy.js";function l(n){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(n){t===null&&l(),c&&t.l!==null?a(t).m.push(n):o(()=>{const e=u(n);if(typeof e=="function")return e})}function a(n){var e=n.l;return e.u??(e.u={a:[],b:[],m:[]})}export{r as o};

View file

@ -0,0 +1 @@
var h=e=>{throw TypeError(e)};var m=(e,t,o)=>t.has(e)||h("Cannot "+o);var r=(e,t,o)=>(m(e,t,"read from private field"),o?o.call(e):t.get(e)),l=(e,t,o)=>t.has(e)?h("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,o);import{s as c,g,a as u}from"./Ym0WvvUy.js";const n="felt_token",i="felt_operator";var a,s;class p{constructor(){l(this,a,c(null));l(this,s,c(null));typeof window<"u"&&this.loadFromStorage()}get token(){return g(r(this,a))}set token(t){u(r(this,a),t,!0)}get operator(){return g(r(this,s))}set operator(t){u(r(this,s),t,!0)}get isAuthenticated(){return this.token!==null}get isAdmin(){var t;return((t=this.operator)==null?void 0:t.role)==="admin"}get isFloor(){var t;return["admin","floor"].includes(((t=this.operator)==null?void 0:t.role)??"")}login(t,o){this.token=t,this.operator=o,this.saveToStorage()}logout(){this.token=null,this.operator=null,this.clearStorage()}loadFromStorage(){try{const t=localStorage.getItem(n),o=localStorage.getItem(i);t&&o&&(this.token=t,this.operator=JSON.parse(o))}catch(t){console.warn("auth: failed to load from storage:",t),this.clearStorage()}}saveToStorage(){try{this.token&&this.operator&&(localStorage.setItem(n,this.token),localStorage.setItem(i,JSON.stringify(this.operator)))}catch(t){console.warn("auth: failed to save to storage:",t)}}clearStorage(){try{localStorage.removeItem(n),localStorage.removeItem(i)}catch(t){console.warn("auth: failed to clear storage:",t)}}}a=new WeakMap,s=new WeakMap;const f=new p;export{f as a};

View file

@ -0,0 +1 @@
var B=Object.defineProperty;var g=i=>{throw TypeError(i)};var D=(i,e,s)=>e in i?B(i,e,{enumerable:!0,configurable:!0,writable:!0,value:s}):i[e]=s;var w=(i,e,s)=>D(i,typeof e!="symbol"?e+"":e,s),y=(i,e,s)=>e.has(i)||g("Cannot "+s);var t=(i,e,s)=>(y(i,e,"read from private field"),s?s.call(i):e.get(i)),l=(i,e,s)=>e.has(i)?g("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,s),M=(i,e,s,a)=>(y(i,e,"write to private field"),a?a.call(i,s):e.set(i,s),s);import{k as F,l as b,p as j,m as x,n as A,o as q,h as C,j as S,q as z,t as E}from"./Ym0WvvUy.js";var h,n,r,u,p,_,v;class I{constructor(e,s=!0){w(this,"anchor");l(this,h,new Map);l(this,n,new Map);l(this,r,new Map);l(this,u,new Set);l(this,p,!0);l(this,_,e=>{if(t(this,h).has(e)){var s=t(this,h).get(e),a=t(this,n).get(s);if(a)F(a),t(this,u).delete(s);else{var c=t(this,r).get(s);c&&(t(this,n).set(s,c.effect),t(this,r).delete(s),c.fragment.lastChild.remove(),this.anchor.before(c.fragment),a=c.effect)}for(const[f,o]of t(this,h)){if(t(this,h).delete(f),f===e)break;const d=t(this,r).get(o);d&&(b(d.effect),t(this,r).delete(o))}for(const[f,o]of t(this,n)){if(f===s||t(this,u).has(f))continue;const d=()=>{if(Array.from(t(this,h).values()).includes(f)){var k=document.createDocumentFragment();z(o,k),k.append(x()),t(this,r).set(f,{effect:o,fragment:k})}else b(o);t(this,u).delete(f),t(this,n).delete(f)};t(this,p)||!a?(t(this,u).add(f),j(o,d,!1)):d()}}});l(this,v,e=>{t(this,h).delete(e);const s=Array.from(t(this,h).values());for(const[a,c]of t(this,r))s.includes(a)||(b(c.effect),t(this,r).delete(a))});this.anchor=e,M(this,p,s)}ensure(e,s){var a=q,c=E();if(s&&!t(this,n).has(e)&&!t(this,r).has(e))if(c){var f=document.createDocumentFragment(),o=x();f.append(o),t(this,r).set(e,{effect:A(()=>s(o)),fragment:f})}else t(this,n).set(e,A(()=>s(this.anchor)));if(t(this,h).set(a,e),c){for(const[d,m]of t(this,n))d===e?a.unskip_effect(m):a.skip_effect(m);for(const[d,m]of t(this,r))d===e?a.unskip_effect(m.effect):a.skip_effect(m.effect);a.oncommit(t(this,_)),a.ondiscard(t(this,v))}else C&&(this.anchor=S),t(this,_).call(this,a)}}h=new WeakMap,n=new WeakMap,r=new WeakMap,u=new WeakMap,p=new WeakMap,_=new WeakMap,v=new WeakMap;export{I as B};

View file

@ -0,0 +1 @@
import{B as g,C as d,D as c,F as m,G as i,I as b,g as p,J as v,K as h,L as k}from"./Ym0WvvUy.js";function x(n=!1){const s=g,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let a=0,t={};const _=h(()=>{let l=!1;const r=s.s;for(const o in r)r[o]!==t[o]&&(t[o]=r[o],l=!0);return l&&a++,a});f=()=>p(_)}e.b.length&&d(()=>{u(s,f),i(e.b)}),c(()=>{const a=m(()=>e.m.map(b));return()=>{for(const t of a)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}k();export{x as i};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/giww_vF6.js";export{o as load_css,r as start};

View file

@ -0,0 +1 @@
import{c as s,a as c}from"../chunks/B6M6q2Zo.js";import{b as l,E as p,v as i}from"../chunks/Ym0WvvUy.js";import{B as m}from"../chunks/Da6yQRl8.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};

View file

@ -0,0 +1 @@
import{a as h,f as g}from"../chunks/B6M6q2Zo.js";import{i as v}from"../chunks/De6rLmuB.js";import{u as l,v as d,A as x,w as _,y as e,z as o,x as $}from"../chunks/Ym0WvvUy.js";import{s as p}from"../chunks/dTRRgeF-.js";import{s as k,p as m}from"../chunks/giww_vF6.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var w=g("<h1> </h1> <p> </p>",1);function q(f,n){l(n,!1),v();var t=w(),r=d(t),c=e(r,!0);o(r);var s=$(r,2),u=e(s,!0);o(s),x(()=>{var a;p(c,i.status),p(u,(a=i.error)==null?void 0:a.message)}),h(f,t),_()}export{q as component};

View file

@ -0,0 +1 @@
import{c as u,a as e,f as p}from"../chunks/B6M6q2Zo.js";import{i as _}from"../chunks/De6rLmuB.js";import{o as d}from"../chunks/Bfwrz3i4.js";import{u as x,v as y,w as A,x as b,y as m,z as n,A as k}from"../chunks/Ym0WvvUy.js";import{s as w}from"../chunks/dTRRgeF-.js";import{i as z}from"../chunks/B9dvBo0E.js";import{a as o}from"../chunks/DMqvp7vx.js";import{g as F}from"../chunks/giww_vF6.js";var M=p('<main class="container svelte-1uha8ag"><h1 class="svelte-1uha8ag">Felt</h1> <p class="text-secondary svelte-1uha8ag">Tournament management system</p> <p> </p></main>'),O=p('<main class="container svelte-1uha8ag"><p>Redirecting to login...</p></main>');function E(l,f){x(f,!1),d(()=>{o.isAuthenticated||F("/login")}),_();var s=u(),c=y(s);{var v=a=>{var t=M(),r=b(m(t),4),h=m(r);n(r),n(t),k(()=>{var i;return w(h,`Welcome, ${((i=o.operator)==null?void 0:i.name)??"Operator"??""}`)}),e(a,t)},g=a=>{var t=O();e(a,t)};z(c,a=>{o.isAuthenticated?a(v):a(g,!1)})}e(l,s),A()}export{E as component};

View file

@ -0,0 +1,2 @@
import{a as P,f as K,t as ue}from"../chunks/B6M6q2Zo.js";import{o as ke}from"../chunks/Bfwrz3i4.js";import{m as V,b as Ne,av as we,h as I,f as X,P as ze,c as Ce,g as h,r as Se,d as Ie,e as ce,i as j,j as G,am as ye,at as Me,ac as de,o as De,aw as y,n as Z,ax as He,t as Le,ay as Oe,az as Pe,as as ae,aA as Re,aB as Fe,aC as Be,Y as ve,aD as Ue,k as Ae,p as Te,aE as Q,_ as $e,aF as qe,aG as Ge,aq as Ye,l as Je,an as Ke,aH as Ve,aI as Xe,aJ as je,u as Qe,A as Y,w as We,aK as Ze,y as R,x as L,z as O,s as W,a as w}from"../chunks/Ym0WvvUy.js";import{d as er,e as rr,a as B,s as pe}from"../chunks/dTRRgeF-.js";import{i as ge}from"../chunks/B9dvBo0E.js";import{a as J}from"../chunks/DMqvp7vx.js";import{g as ee}from"../chunks/giww_vF6.js";function he(e,t){return t}function tr(e,t,r){for(var a=[],i=t.length,l,s=t.length,d=0;d<i;d++){let m=t[d];Te(m,()=>{if(l){if(l.pending.delete(m),l.done.add(m),l.pending.size===0){var g=e.outrogroups;re(ae(l.done)),g.delete(l),g.size===0&&(e.outrogroups=null)}}else s-=1},!1)}if(s===0){var f=a.length===0&&r!==null;if(f){var p=r,c=p.parentNode;Ye(c),c.append(p),e.items.clear()}re(t,!f)}else l={pending:new Set(t),done:new Set},(e.outrogroups??(e.outrogroups=new Set)).add(l)}function re(e,t=!0){for(var r=0;r<e.length;r++)Je(e[r],t)}var _e;function me(e,t,r,a,i,l=null){var s=e,d=new Map,f=(t&we)!==0;if(f){var p=e;s=I?X(ze(p)):p.appendChild(V())}I&&Ce();var c=null,m=Oe(()=>{var u=r();return Pe(u)?u:u==null?[]:ae(u)}),g,_=!0;function T(){n.fallback=c,ar(n,g,s,t,a),c!==null&&(g.length===0?(c.f&y)===0?Ae(c):(c.f^=y,q(c,null,s)):Te(c,()=>{c=null}))}var M=Ne(()=>{g=h(m);var u=g.length;let k=!1;if(I){var N=Se(s)===Ie;N!==(u===0)&&(s=ce(),X(s),j(!1),k=!0)}for(var b=new Set,A=De,D=Le(),E=0;E<u;E+=1){I&&G.nodeType===ye&&G.data===Me&&(s=G,k=!0,j(!1));var z=g[E],o=a(z,E),v=_?null:d.get(o);v?(v.v&&de(v.v,z),v.i&&de(v.i,E),D&&A.unskip_effect(v.e)):(v=nr(d,_?s:_e??(_e=V()),z,o,E,i,t,r),_||(v.e.f|=y),d.set(o,v)),b.add(o)}if(u===0&&l&&!c&&(_?c=Z(()=>l(s)):(c=Z(()=>l(_e??(_e=V()))),c.f|=y)),u>b.size&&He(),I&&u>0&&X(ce()),!_)if(D){for(const[x,C]of d)b.has(x)||A.skip_effect(C.e);A.oncommit(T),A.ondiscard(()=>{})}else T();k&&j(!0),h(m)}),n={effect:M,items:d,outrogroups:null,fallback:c};_=!1,I&&(s=G)}function U(e){for(;e!==null&&(e.f&qe)===0;)e=e.next;return e}function ar(e,t,r,a,i){var v,x,C,F,ne,ie,se,oe,le;var l=(a&Ge)!==0,s=t.length,d=e.items,f=U(e.effect.first),p,c=null,m,g=[],_=[],T,M,n,u;if(l)for(u=0;u<s;u+=1)T=t[u],M=i(T,u),n=d.get(M).e,(n.f&y)===0&&((x=(v=n.nodes)==null?void 0:v.a)==null||x.measure(),(m??(m=new Set)).add(n));for(u=0;u<s;u+=1){if(T=t[u],M=i(T,u),n=d.get(M).e,e.outrogroups!==null)for(const S of e.outrogroups)S.pending.delete(n),S.done.delete(n);if((n.f&y)!==0)if(n.f^=y,n===f)q(n,null,r);else{var k=c?c.next:f;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),H(e,c,n),H(e,n,k),q(n,k,r),c=n,g=[],_=[],f=U(c.next);continue}if((n.f&Q)!==0&&(Ae(n),l&&((F=(C=n.nodes)==null?void 0:C.a)==null||F.unfix(),(m??(m=new Set)).delete(n))),n!==f){if(p!==void 0&&p.has(n)){if(g.length<_.length){var N=_[0],b;c=N.prev;var A=g[0],D=g[g.length-1];for(b=0;b<g.length;b+=1)q(g[b],N,r);for(b=0;b<_.length;b+=1)p.delete(_[b]);H(e,A.prev,D.next),H(e,c,A),H(e,D,N),f=N,c=D,u-=1,g=[],_=[]}else p.delete(n),q(n,f,r),H(e,n.prev,n.next),H(e,n,c===null?e.effect.first:c.next),H(e,c,n),c=n;continue}for(g=[],_=[];f!==null&&f!==n;)(p??(p=new Set)).add(f),_.push(f),f=U(f.next);if(f===null)continue}(n.f&y)===0&&g.push(n),c=n,f=U(n.next)}if(e.outrogroups!==null){for(const S of e.outrogroups)S.pending.size===0&&(re(ae(S.done)),(ne=e.outrogroups)==null||ne.delete(S));e.outrogroups.size===0&&(e.outrogroups=null)}if(f!==null||p!==void 0){var E=[];if(p!==void 0)for(n of p)(n.f&Q)===0&&E.push(n);for(;f!==null;)(f.f&Q)===0&&f!==e.fallback&&E.push(f),f=U(f.next);var z=E.length;if(z>0){var o=(a&we)!==0&&s===0?r:null;if(l){for(u=0;u<z;u+=1)(se=(ie=E[u].nodes)==null?void 0:ie.a)==null||se.measure();for(u=0;u<z;u+=1)(le=(oe=E[u].nodes)==null?void 0:oe.a)==null||le.fix()}tr(e,E,o)}}l&&$e(()=>{var S,fe;if(m!==void 0)for(n of m)(fe=(S=n.nodes)==null?void 0:S.a)==null||fe.apply()})}function nr(e,t,r,a,i,l,s,d){var f=(s&Re)!==0?(s&Fe)===0?Be(r,!1,!1):ve(r):null,p=(s&Ue)!==0?ve(i):null;return{v:f,i:p,e:Z(()=>(l(t,f??r,p??i,d),()=>{e.delete(a)}))}}function q(e,t,r){if(e.nodes)for(var a=e.nodes.start,i=e.nodes.end,l=t&&(t.f&y)===0?t.nodes.start:r;a!==null;){var s=Ke(a);if(l.before(a),a===i)return;a=s}}function H(e,t,r){t===null?e.effect.first=r:t.next=r,r===null?e.effect.last=t:r.prev=t}const be=[...`
\r\f \v\uFEFF`];function ir(e,t,r){var a=""+e;if(r){for(var i of Object.keys(r))if(r[i])a=a?a+" "+i:i;else if(a.length)for(var l=i.length,s=0;(s=a.indexOf(i,s))>=0;){var d=s+l;(s===0||be.includes(a[s-1]))&&(d===a.length||be.includes(a[d]))?a=(s===0?"":a.substring(0,s))+a.substring(d+1):s=d}}return a===""?null:a}function sr(e,t,r,a,i,l){var s=e.__className;if(I||s!==r||s===void 0){var d=ir(r,a,l);(!I||d!==e.getAttribute("class"))&&(d==null?e.removeAttribute("class"):e.className=d),e.__className=r}else if(l&&i!==l)for(var f in l){var p=!!l[f];(i==null||p!==!!i[f])&&e.classList.toggle(f,p)}return l}const or=Symbol("is custom element"),lr=Symbol("is html");function Ee(e,t,r,a){var i=fr(e);I&&(i[t]=e.getAttribute(t)),i[t]!==(i[t]=r)&&(r==null?e.removeAttribute(t):typeof r!="string"&&ur(e).includes(t)?e[t]=r:e.setAttribute(t,r))}function fr(e){return e.__attributes??(e.__attributes={[or]:e.nodeName.includes("-"),[lr]:e.namespaceURI===Ve})}var xe=new Map;function ur(e){var t=e.getAttribute("is")||e.nodeName,r=xe.get(t);if(r)return r;xe.set(t,r=[]);for(var a,i=e,l=Element.prototype;l!==i;){a=je(i);for(var s in a)a[s].set&&r.push(s);i=Xe(i)}return r}class te extends Error{constructor(t,r,a){const i=typeof a=="object"&&a!==null&&"error"in a?a.error:r;super(i),this.status=t,this.statusText=r,this.body=a,this.name="ApiError"}}function cr(){return`${window.location.origin}/api/v1`}function dr(e){const t={Accept:"application/json"};e&&(t["Content-Type"]="application/json");const r=J.token;return r&&(t.Authorization=`Bearer ${r}`),t}async function vr(e){if(e.status===401)throw J.logout(),await ee("/login"),new te(401,"Unauthorized",{error:"Session expired"});if(!e.ok){let t;try{t=await e.json()}catch{t={error:e.statusText}}throw new te(e.status,e.statusText,t)}if(e.status!==204)return e.json()}async function $(e,t,r){const a=`${cr()}${t}`,i={method:e,headers:dr(r!==void 0),credentials:"same-origin"};r!==void 0&&(i.body=JSON.stringify(r));const l=await fetch(a,i);return vr(l)}const pr={get(e){return $("GET",e)},post(e,t){return $("POST",e,t)},put(e,t){return $("PUT",e,t)},patch(e,t){return $("PATCH",e,t)},delete(e){return $("DELETE",e)}};var gr=K("<div></div>"),hr=K('<div class="error-message svelte-1x05zx6" role="alert"> </div>'),_r=K('<button class="numpad-btn touch-target svelte-1x05zx6"> </button>'),mr=K('<main class="login-container svelte-1x05zx6"><div class="login-card svelte-1x05zx6"><div class="logo svelte-1x05zx6"><h1 class="svelte-1x05zx6">Felt</h1> <p class="subtitle svelte-1x05zx6">Tournament Manager</p></div> <div class="pin-display svelte-1x05zx6" role="status"></div> <!> <div class="numpad svelte-1x05zx6"><!> <button class="numpad-btn numpad-fn touch-target svelte-1x05zx6" aria-label="Clear PIN">CLR</button> <button class="numpad-btn touch-target svelte-1x05zx6" aria-label="Digit 0">0</button> <button class="numpad-btn numpad-fn touch-target svelte-1x05zx6" aria-label="Delete last digit">DEL</button></div> <button class="submit-btn touch-target svelte-1x05zx6"><!></button></div></main>');function Nr(e,t){Qe(t,!0);let r=W(""),a=W(""),i=W(!1);const l=6;ke(()=>{J.isAuthenticated&&ee("/")});function s(o){h(r).length>=l||(w(r,h(r)+o),w(a,""))}function d(){w(r,h(r).slice(0,-1),!0),w(a,"")}function f(){w(r,""),w(a,"")}async function p(){if(!(h(r).length<4||h(i))){w(i,!0),w(a,"");try{const o=await pr.post("/auth/login",{pin:h(r)});J.login(o.token,{id:o.operator.id,name:o.operator.name,role:o.operator.role}),await ee("/")}catch(o){o instanceof te?o.status===429?w(a,"Too many attempts. Please wait."):o.status===401?w(a,"Invalid PIN. Try again."):w(a,o.message,!0):w(a,"Connection error. Check your network."),w(r,"")}finally{w(i,!1)}}}function c(o){o.key>="0"&&o.key<="9"?s(o.key):o.key==="Backspace"?d():o.key==="Enter"?p():o.key==="Escape"&&f()}var m=mr();rr("keydown",Ze,c);var g=R(m),_=L(R(g),2);me(_,21,()=>Array(l),he,(o,v,x)=>{var C=gr();let F;Y(()=>F=sr(C,1,"pin-dot svelte-1x05zx6",null,F,{filled:x<h(r).length})),P(o,C)}),O(_);var T=L(_,2);{var M=o=>{var v=hr(),x=R(v,!0);O(v),Y(()=>pe(x,h(a))),P(o,v)};ge(T,o=>{h(a)&&o(M)})}var n=L(T,2),u=R(n);me(u,16,()=>["1","2","3","4","5","6","7","8","9"],he,(o,v)=>{var x=_r(),C=R(x,!0);O(x),Y(()=>{x.disabled=h(i)||h(r).length>=l,Ee(x,"aria-label",`Digit ${v??""}`),pe(C,v)}),B("click",x,()=>s(v)),P(o,x)});var k=L(u,2),N=L(k,2),b=L(N,2);O(n);var A=L(n,2),D=R(A);{var E=o=>{var v=ue("Signing in...");P(o,v)},z=o=>{var v=ue("Sign In");P(o,v)};ge(D,o=>{h(i)?o(E):o(z,!1)})}O(A),O(g),O(m),Y(()=>{Ee(_,"aria-label",`PIN entered: ${h(r).length??""} digits`),k.disabled=h(i),N.disabled=h(i)||h(r).length>=l,b.disabled=h(i)||h(r).length===0,A.disabled=h(r).length<4||h(i)}),B("click",k,f),B("click",N,()=>s("0")),B("click",b,d),B("click",A,p),P(e,m),We()}er(["click"]);export{Nr as component};

View file

@ -0,0 +1 @@
{"version":"1772333625386"}

BIN
frontend/build/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View file

@ -1,38 +1,39 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="mocha">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Felt</title> <link rel="icon" href="/favicon.png" />
<style> <title>Felt</title>
body { <link href="/_app/immutable/entry/start.Cw5np0_P.js" rel="modulepreload">
margin: 0; <link href="/_app/immutable/chunks/giww_vF6.js" rel="modulepreload">
padding: 0; <link href="/_app/immutable/chunks/Ym0WvvUy.js" rel="modulepreload">
display: flex; <link href="/_app/immutable/chunks/Bfwrz3i4.js" rel="modulepreload">
justify-content: center; <link href="/_app/immutable/entry/app.DWnDWHgs.js" rel="modulepreload">
align-items: center; <link href="/_app/immutable/chunks/dTRRgeF-.js" rel="modulepreload">
min-height: 100vh; <link href="/_app/immutable/chunks/B6M6q2Zo.js" rel="modulepreload">
background-color: #1e1e2e; <link href="/_app/immutable/chunks/B9dvBo0E.js" rel="modulepreload">
color: #cdd6f4; <link href="/_app/immutable/chunks/Da6yQRl8.js" rel="modulepreload">
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} </head>
.loading { <body>
text-align: center; <div style="display: contents">
} <script>
.loading h1 { {
font-size: 2rem; __sveltekit_1rgg0vt = {
margin-bottom: 0.5rem; base: ""
} };
.loading p {
font-size: 1rem; const element = document.currentScript.parentElement;
opacity: 0.7;
} Promise.all([
</style> import("/_app/immutable/entry/start.Cw5np0_P.js"),
</head> import("/_app/immutable/entry/app.DWnDWHgs.js")
<body> ]).then(([kit, app]) => {
<div class="loading"> kit.start(app, element);
<h1>Felt</h1> });
<p>Loading...</p> }
</div> </script>
</body> </div>
</body>
</html> </html>

39
frontend/build/login.html Normal file
View file

@ -0,0 +1,39 @@
<!doctype html>
<html lang="en" data-theme="mocha">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" href="./favicon.png" />
<title>Felt</title>
<link href="./_app/immutable/entry/start.Cw5np0_P.js" rel="modulepreload">
<link href="./_app/immutable/chunks/giww_vF6.js" rel="modulepreload">
<link href="./_app/immutable/chunks/Ym0WvvUy.js" rel="modulepreload">
<link href="./_app/immutable/chunks/Bfwrz3i4.js" rel="modulepreload">
<link href="./_app/immutable/entry/app.DWnDWHgs.js" rel="modulepreload">
<link href="./_app/immutable/chunks/dTRRgeF-.js" rel="modulepreload">
<link href="./_app/immutable/chunks/B6M6q2Zo.js" rel="modulepreload">
<link href="./_app/immutable/chunks/B9dvBo0E.js" rel="modulepreload">
<link href="./_app/immutable/chunks/Da6yQRl8.js" rel="modulepreload">
</head>
<body>
<div style="display: contents">
<script>
{
__sveltekit_1rgg0vt = {
base: new URL(".", location).pathname.slice(0, -1)
};
const element = document.currentScript.parentElement;
Promise.all([
import("./_app/immutable/entry/start.Cw5np0_P.js"),
import("./_app/immutable/entry/app.DWnDWHgs.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

1627
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
frontend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "felt-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
}

138
frontend/src/app.css Normal file
View file

@ -0,0 +1,138 @@
@import '$lib/theme/catppuccin.css';
/* ============================================
Reset
============================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ============================================
Base styles
============================================ */
html {
font-family: var(--font-body);
font-size: 16px;
line-height: var(--leading-normal);
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Prevent layout shift from scrollbar */
scrollbar-gutter: stable;
}
body {
min-height: 100dvh;
color: var(--color-text);
background-color: var(--color-bg);
}
/* ============================================
Touch targets & interaction
Poker room: TD using phone with one hand
============================================ */
button,
a,
input,
select,
textarea,
[role='button'],
[role='tab'],
[role='menuitem'] {
/* Prevent double-tap zoom on mobile */
touch-action: manipulation;
}
/* Minimum 48px touch target for all interactive elements */
.touch-target,
button,
[role='button'],
[role='tab'] {
min-height: var(--touch-target);
min-width: var(--touch-target);
}
/* Active/pressed state for tactile feedback */
button:active,
[role='button']:active,
[role='tab']:active,
.touch-target:active {
transform: scale(0.97);
opacity: 0.9;
transition: transform var(--transition-fast), opacity var(--transition-fast);
}
/* Focus visible for keyboard accessibility */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove default focus ring for mouse/touch users */
:focus:not(:focus-visible) {
outline: none;
}
/* ============================================
Scrollbar styling (dark theme)
============================================ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-active);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-overlay);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-surface-active) var(--color-bg);
}
/* ============================================
Typography helpers
============================================ */
.font-mono {
font-family: var(--font-mono);
}
/* Timer/number display — always monospace */
.timer,
.number,
.blinds,
.chips,
.currency {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
/* ============================================
Common utility classes
============================================ */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

13
frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

13
frontend/src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="mocha">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<title>Felt</title>
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

124
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,124 @@
/**
* HTTP API client for the Felt backend.
*
* Auto-detects base URL from current host, attaches JWT from auth store,
* handles 401 responses by clearing auth state and redirecting to login.
*/
import { auth } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
/** Typed API error with status code and message. */
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown
) {
const msg = typeof body === 'object' && body !== null && 'error' in body
? (body as { error: string }).error
: statusText;
super(msg);
this.name = 'ApiError';
}
}
/** Base URL for API requests — auto-detected from current host. */
function getBaseUrl(): string {
return `${window.location.origin}/api/v1`;
}
/** Build headers with JWT auth and content type. */
function buildHeaders(hasBody: boolean): HeadersInit {
const headers: Record<string, string> = {
'Accept': 'application/json'
};
if (hasBody) {
headers['Content-Type'] = 'application/json';
}
const token = auth.token;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/** Handle API response — parse JSON, handle errors. */
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
// Token expired or invalid — clear auth and redirect to login
auth.logout();
await goto('/login');
throw new ApiError(401, 'Unauthorized', { error: 'Session expired' });
}
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = { error: response.statusText };
}
throw new ApiError(response.status, response.statusText, body);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
/** Perform an API request. */
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${getBaseUrl()}${path}`;
const init: RequestInit = {
method,
headers: buildHeaders(body !== undefined),
credentials: 'same-origin'
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
return handleResponse<T>(response);
}
/**
* HTTP API client.
*
* All methods auto-attach JWT from auth store and handle 401 responses.
*/
export const api = {
/** GET request. */
get<T>(path: string): Promise<T> {
return request<T>('GET', path);
},
/** POST request with JSON body. */
post<T>(path: string, body?: unknown): Promise<T> {
return request<T>('POST', path, body);
},
/** PUT request with JSON body. */
put<T>(path: string, body?: unknown): Promise<T> {
return request<T>('PUT', path, body);
},
/** PATCH request with JSON body. */
patch<T>(path: string, body?: unknown): Promise<T> {
return request<T>('PATCH', path, body);
},
/** DELETE request. */
delete<T>(path: string): Promise<T> {
return request<T>('DELETE', path);
}
};

View file

@ -0,0 +1,109 @@
/**
* Authentication state using Svelte 5 runes.
*
* Manages JWT token and operator info with localStorage persistence.
* Token is stored in localStorage (acceptable while Leaf is local-network only).
*
* TODO Phase 7: Migrate token storage from localStorage to HttpOnly cookies
* for XSS protection when Netbird reverse proxy is in place.
*/
const TOKEN_KEY = 'felt_token';
const OPERATOR_KEY = 'felt_operator';
/** Operator role type. */
export type OperatorRole = 'admin' | 'floor' | 'viewer';
/** Operator information from JWT claims. */
export interface Operator {
id: string;
name: string;
role: OperatorRole;
}
class AuthState {
token = $state<string | null>(null);
operator = $state<Operator | null>(null);
constructor() {
// Load persisted state on initialization
if (typeof window !== 'undefined') {
this.loadFromStorage();
}
}
/** Whether the user is authenticated. */
get isAuthenticated(): boolean {
return this.token !== null;
}
/** Whether the user has admin role. */
get isAdmin(): boolean {
return this.operator?.role === 'admin';
}
/** Whether the user has floor or admin role. */
get isFloor(): boolean {
return ['admin', 'floor'].includes(this.operator?.role ?? '');
}
/**
* Set authentication state after successful login.
*
* @param token - JWT token string
* @param operator - Operator info from token claims
*/
login(token: string, operator: Operator): void {
this.token = token;
this.operator = operator;
this.saveToStorage();
}
/** Clear authentication state (logout or 401). */
logout(): void {
this.token = null;
this.operator = null;
this.clearStorage();
}
/** Load persisted auth from localStorage. */
private loadFromStorage(): void {
try {
const token = localStorage.getItem(TOKEN_KEY);
const operatorJson = localStorage.getItem(OPERATOR_KEY);
if (token && operatorJson) {
this.token = token;
this.operator = JSON.parse(operatorJson) as Operator;
}
} catch (err) {
console.warn('auth: failed to load from storage:', err);
this.clearStorage();
}
}
/** Save auth state to localStorage. */
private saveToStorage(): void {
try {
if (this.token && this.operator) {
localStorage.setItem(TOKEN_KEY, this.token);
localStorage.setItem(OPERATOR_KEY, JSON.stringify(this.operator));
}
} catch (err) {
console.warn('auth: failed to save to storage:', err);
}
}
/** Clear auth from localStorage. */
private clearStorage(): void {
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(OPERATOR_KEY);
} catch (err) {
console.warn('auth: failed to clear storage:', err);
}
}
}
/** Singleton auth state instance. */
export const auth = new AuthState();

View file

@ -0,0 +1,326 @@
/**
* Tournament state using Svelte 5 runes.
*
* Handles real-time WebSocket messages from the backend and maintains
* reactive state for all tournament data: clock, players, tables,
* financials, activity feed, rankings, and table balance status.
*/
// ============================================
// Types
// ============================================
export interface ClockSnapshot {
level: number;
name: string;
small_blind: number;
big_blind: number;
ante: number;
elapsed_seconds: number;
remaining_seconds: number;
is_break: boolean;
is_paused: boolean;
next_break_in_seconds: number | null;
}
export type PlayerStatus = 'registered' | 'active' | 'eliminated' | 'away';
export interface Player {
id: string;
name: string;
status: PlayerStatus;
table_id: string | null;
seat: number | null;
chips: number;
rebuys: number;
addons: number;
bounty: number;
finish_position: number | null;
eliminated_by: string | null;
}
export interface Table {
id: string;
number: number;
seats: number;
players: string[]; // player IDs
is_final_table: boolean;
is_break_table: boolean;
}
export interface FinancialSummary {
total_buyin: number;
total_rebuys: number;
total_addons: number;
total_collected: number;
prize_pool: number;
house_fee: number;
paid_positions: number;
payouts: PayoutEntry[];
}
export interface PayoutEntry {
position: number;
amount: number;
player_id: string | null;
player_name: string | null;
}
export interface ActivityEntry {
id: string;
type: string;
message: string;
player_id: string | null;
player_name: string | null;
timestamp: number;
}
export interface PlayerRanking {
position: number;
player_id: string;
player_name: string;
chips: number;
bounties: number;
}
export interface BalanceStatus {
is_balanced: boolean;
moves_needed: BalanceMove[];
max_diff: number;
}
export interface BalanceMove {
player_id: string;
player_name: string;
from_table: number;
to_table: number;
to_seat: number;
}
export interface WSMessage {
type: string;
tournament_id?: string;
data: unknown;
timestamp: number;
}
// ============================================
// State
// ============================================
class TournamentState {
/** Current tournament ID. */
id = $state<string | null>(null);
/** Clock/timer state. */
clock = $state<ClockSnapshot | null>(null);
/** All players. */
players = $state<Player[]>([]);
/** All tables. */
tables = $state<Table[]>([]);
/** Financial summary. */
financials = $state<FinancialSummary | null>(null);
/** Activity feed (most recent first). */
activity = $state<ActivityEntry[]>([]);
/** Player rankings by chip count. */
rankings = $state<PlayerRanking[]>([]);
/** Table balance status. */
balanceStatus = $state<BalanceStatus | null>(null);
/** Maximum activity feed entries to keep. */
private maxActivityEntries = 100;
// ============================================
// Derived state
// ============================================
/** Count of active (not eliminated) players. */
get remainingPlayers(): number {
return this.players.filter((p) => p.status === 'active').length;
}
/** Total registered players. */
get totalPlayers(): number {
return this.players.length;
}
/** Active tables count. */
get activeTables(): number {
return this.tables.filter((t) => t.players.length > 0).length;
}
/** Whether tables are balanced. */
get isBalanced(): boolean {
return this.balanceStatus?.is_balanced ?? true;
}
// ============================================
// WebSocket message handler
// ============================================
/**
* Handle an incoming WebSocket message and update state.
*
* Message types match the Go backend's broadcast types:
* - clock.tick: Timer update every second
* - state.snapshot: Full state load (on connect or refresh)
* - player.*: Player-related events
* - table.*: Table-related events
* - financial.*: Financial updates
* - balance.*: Table balance events
*/
handleMessage(msg: WSMessage): void {
switch (msg.type) {
// Clock
case 'clock.tick':
this.clock = msg.data as ClockSnapshot;
break;
case 'clock.level_change':
this.clock = msg.data as ClockSnapshot;
break;
case 'clock.paused':
if (this.clock) this.clock.is_paused = true;
break;
case 'clock.resumed':
if (this.clock) this.clock.is_paused = false;
break;
// Full state snapshot
case 'state.snapshot':
this.loadFullState(msg.data as FullSnapshot);
break;
// Players
case 'player.registered':
this.addOrUpdatePlayer(msg.data as Player);
break;
case 'player.seated':
this.addOrUpdatePlayer(msg.data as Player);
break;
case 'player.bust':
case 'player.eliminated':
this.addOrUpdatePlayer(msg.data as Player);
break;
case 'player.rebuy':
case 'player.addon':
this.addOrUpdatePlayer(msg.data as Player);
break;
case 'player.moved':
this.addOrUpdatePlayer(msg.data as Player);
break;
// Tables
case 'table.created':
this.addOrUpdateTable(msg.data as Table);
break;
case 'table.broken':
this.removeTable((msg.data as { id: string }).id);
break;
case 'table.updated':
this.addOrUpdateTable(msg.data as Table);
break;
// Financials
case 'financial.updated':
this.financials = msg.data as FinancialSummary;
break;
// Rankings
case 'rankings.updated':
this.rankings = msg.data as PlayerRanking[];
break;
// Balance
case 'balance.updated':
this.balanceStatus = msg.data as BalanceStatus;
break;
// Activity
case 'activity.new':
this.addActivity(msg.data as ActivityEntry);
break;
// Connection
case 'connected':
console.log('tournament: connected to server');
break;
default:
console.warn(`tournament: unknown message type: ${msg.type}`);
}
}
/** Reset all state. */
reset(): void {
this.id = null;
this.clock = null;
this.players = [];
this.tables = [];
this.financials = null;
this.activity = [];
this.rankings = [];
this.balanceStatus = null;
}
// ============================================
// Private helpers
// ============================================
private loadFullState(snapshot: FullSnapshot): void {
this.id = snapshot.id ?? this.id;
this.clock = snapshot.clock ?? null;
this.players = snapshot.players ?? [];
this.tables = snapshot.tables ?? [];
this.financials = snapshot.financials ?? null;
this.activity = snapshot.activity ?? [];
this.rankings = snapshot.rankings ?? [];
this.balanceStatus = snapshot.balance_status ?? null;
}
private addOrUpdatePlayer(player: Player): void {
const idx = this.players.findIndex((p) => p.id === player.id);
if (idx >= 0) {
this.players[idx] = player;
} else {
this.players.push(player);
}
}
private addOrUpdateTable(table: Table): void {
const idx = this.tables.findIndex((t) => t.id === table.id);
if (idx >= 0) {
this.tables[idx] = table;
} else {
this.tables.push(table);
}
}
private removeTable(tableId: string): void {
this.tables = this.tables.filter((t) => t.id !== tableId);
}
private addActivity(entry: ActivityEntry): void {
this.activity = [entry, ...this.activity].slice(0, this.maxActivityEntries);
}
}
/** Full state snapshot received on WebSocket connect. */
interface FullSnapshot {
id?: string;
clock?: ClockSnapshot;
players?: Player[];
tables?: Table[];
financials?: FinancialSummary;
activity?: ActivityEntry[];
rankings?: PlayerRanking[];
balance_status?: BalanceStatus;
}
/** Singleton tournament state instance. */
export const tournament = new TournamentState();

View file

@ -0,0 +1,163 @@
/*
* Catppuccin Theme for Felt
* https://catppuccin.com/
*
* Mocha (dark) default
* Latte (light) alternate
*
* All 26 base colors + semantic mappings + poker-specific tokens
*/
/* ============================================
Mocha (dark theme) default
============================================ */
[data-theme='mocha'],
:root {
/* Base colors */
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
/* Surface colors */
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
/* Semantic color mappings */
--color-bg: var(--ctp-base);
--color-bg-elevated: var(--ctp-mantle);
--color-bg-sunken: var(--ctp-crust);
--color-surface: var(--ctp-surface0);
--color-surface-hover: var(--ctp-surface1);
--color-surface-active: var(--ctp-surface2);
--color-text: var(--ctp-text);
--color-text-secondary: var(--ctp-subtext1);
--color-text-muted: var(--ctp-subtext0);
--color-primary: var(--ctp-blue);
--color-success: var(--ctp-green);
--color-warning: var(--ctp-yellow);
--color-error: var(--ctp-red);
--color-accent: var(--ctp-mauve);
--color-border: var(--ctp-surface1);
--color-overlay: var(--ctp-overlay0);
/* Poker-specific semantic tokens */
--color-felt: var(--ctp-green);
--color-card: var(--ctp-text);
--color-bounty: var(--ctp-pink);
--color-prize: var(--ctp-yellow);
--color-chip: var(--ctp-peach);
--color-clock: var(--ctp-sapphire);
--color-break: var(--ctp-teal);
--color-elimination: var(--ctp-red);
}
/* ============================================
Latte (light theme) alternate
============================================ */
[data-theme='latte'] {
/* Base colors */
--ctp-rosewater: #dc8a78;
--ctp-flamingo: #dd7878;
--ctp-pink: #ea76cb;
--ctp-mauve: #8839ef;
--ctp-red: #d20f39;
--ctp-maroon: #e64553;
--ctp-peach: #fe640b;
--ctp-yellow: #df8e1d;
--ctp-green: #40a02b;
--ctp-teal: #179299;
--ctp-sky: #04a5e5;
--ctp-sapphire: #209fb5;
--ctp-blue: #1e66f5;
--ctp-lavender: #7287fd;
/* Surface colors */
--ctp-text: #4c4f69;
--ctp-subtext1: #5c5f77;
--ctp-subtext0: #6c6f85;
--ctp-overlay2: #7c7f93;
--ctp-overlay1: #8c8fa1;
--ctp-overlay0: #9ca0b0;
--ctp-surface2: #acb0be;
--ctp-surface1: #bcc0cc;
--ctp-surface0: #ccd0da;
--ctp-base: #eff1f5;
--ctp-mantle: #e6e9ef;
--ctp-crust: #dce0e8;
/* Semantic mappings auto-inherit from --ctp-* variables */
}
/* ============================================
Typography
============================================ */
:root {
/* Font stacks */
--font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
/* Font sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
/* Transitions */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Touch target minimum */
--touch-target: 48px;
}

199
frontend/src/lib/ws.ts Normal file
View file

@ -0,0 +1,199 @@
/**
* WebSocket client with auto-reconnect and exponential backoff.
*
* Connects to the Go backend's /ws endpoint with JWT authentication
* via query parameter (browser WebSocket API limitation).
*/
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
export interface WSMessage {
type: string;
tournament_id?: string;
data: unknown;
timestamp: number;
}
type MessageHandler = (msg: WSMessage) => void;
type StateHandler = (state: ConnectionState) => void;
const MIN_RECONNECT_DELAY = 1000; // 1s
const MAX_RECONNECT_DELAY = 30000; // 30s
const BACKOFF_MULTIPLIER = 2;
export class WebSocketClient {
private ws: WebSocket | null = null;
private url: string = '';
private token: string = '';
private tournamentID: string = '';
private reconnectDelay: number = MIN_RECONNECT_DELAY;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalClose: boolean = false;
private messageHandlers: MessageHandler[] = [];
private stateHandlers: StateHandler[] = [];
private _state: ConnectionState = 'disconnected';
/** Current connection state. */
get state(): ConnectionState {
return this._state;
}
/**
* Connect to the WebSocket server.
*
* @param token - JWT token for authentication
* @param tournamentID - Optional tournament scope
*/
connect(token: string, tournamentID?: string): void {
this.token = token;
this.tournamentID = tournamentID ?? '';
this.intentionalClose = false;
// Auto-detect protocol: http -> ws, https -> wss
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
let wsUrl = `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`;
if (this.tournamentID) {
wsUrl += `&tournament=${encodeURIComponent(this.tournamentID)}`;
}
this.url = wsUrl;
this.doConnect();
}
/**
* Subscribe to a specific tournament's updates.
* Reconnects with the new tournament scope.
*/
subscribe(tournamentID: string): void {
const wasConnected = this._state === 'connected';
this.tournamentID = tournamentID;
if (wasConnected && this.token) {
this.disconnect();
this.connect(this.token, tournamentID);
}
}
/** Disconnect from the WebSocket server. */
disconnect(): void {
this.intentionalClose = true;
this.clearReconnectTimer();
if (this.ws) {
this.ws.close(1000, 'client disconnect');
this.ws = null;
}
this.setState('disconnected');
}
/** Send a JSON message to the server. */
send(message: Record<string, unknown>): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn('ws: cannot send, not connected');
}
}
/** Register a message handler. Returns unsubscribe function. */
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.push(handler);
return () => {
this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
};
}
/** Register a connection state change handler. Returns unsubscribe function. */
onStateChange(handler: StateHandler): () => void {
this.stateHandlers.push(handler);
return () => {
this.stateHandlers = this.stateHandlers.filter((h) => h !== handler);
};
}
private doConnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.setState('connecting');
try {
this.ws = new WebSocket(this.url);
} catch (err) {
console.error('ws: failed to create WebSocket:', err);
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
console.log('ws: connected');
this.setState('connected');
this.reconnectDelay = MIN_RECONNECT_DELAY; // Reset backoff on success
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const msg: WSMessage = JSON.parse(event.data as string);
for (const handler of this.messageHandlers) {
handler(msg);
}
} catch (err) {
console.error('ws: failed to parse message:', err);
}
};
this.ws.onclose = (event: CloseEvent) => {
console.log(`ws: closed (code=${event.code}, reason=${event.reason})`);
this.ws = null;
if (!this.intentionalClose) {
this.scheduleReconnect();
} else {
this.setState('disconnected');
}
};
this.ws.onerror = (event: Event) => {
console.error('ws: error:', event);
// onclose will fire after onerror, which handles reconnect
};
}
private scheduleReconnect(): void {
this.setState('reconnecting');
this.clearReconnectTimer();
console.log(`ws: reconnecting in ${this.reconnectDelay}ms`);
this.reconnectTimer = setTimeout(() => {
this.doConnect();
}, this.reconnectDelay);
// Exponential backoff with cap
this.reconnectDelay = Math.min(this.reconnectDelay * BACKOFF_MULTIPLIER, MAX_RECONNECT_DELAY);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private setState(state: ConnectionState): void {
if (this._state !== state) {
this._state = state;
for (const handler of this.stateHandlers) {
handler(state);
}
}
}
}
/** Singleton WebSocket client instance. */
export const wsClient = new WebSocketClient();

View file

@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false; // SPA mode, no SSR

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
if (!auth.isAuthenticated) {
goto('/login');
}
});
</script>
{#if auth.isAuthenticated}
<main class="container">
<h1>Felt</h1>
<p class="text-secondary">Tournament management system</p>
<p>Welcome, {auth.operator?.name ?? 'Operator'}</p>
</main>
{:else}
<main class="container">
<p>Redirecting to login...</p>
</main>
{/if}
<style>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: 1rem;
gap: 0.5rem;
}
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.text-secondary {
color: var(--color-text-secondary);
}
</style>

View file

@ -0,0 +1,302 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
import { api, ApiError } from '$lib/api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// PIN state
let pin = $state('');
let error = $state('');
let loading = $state(false);
const MAX_PIN_LENGTH = 6;
// Redirect if already logged in
onMount(() => {
if (auth.isAuthenticated) {
goto('/');
}
});
function appendDigit(digit: string): void {
if (pin.length >= MAX_PIN_LENGTH) return;
pin += digit;
error = '';
}
function deleteDigit(): void {
pin = pin.slice(0, -1);
error = '';
}
function clearPin(): void {
pin = '';
error = '';
}
async function submitPin(): Promise<void> {
if (pin.length < 4 || loading) return;
loading = true;
error = '';
try {
const result = await api.post<{
token: string;
operator: { id: string; name: string; role: string };
}>('/auth/login', { pin });
auth.login(result.token, {
id: result.operator.id,
name: result.operator.name,
role: result.operator.role as 'admin' | 'floor' | 'viewer'
});
await goto('/');
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 429) {
error = 'Too many attempts. Please wait.';
} else if (err.status === 401) {
error = 'Invalid PIN. Try again.';
} else {
error = err.message;
}
} else {
error = 'Connection error. Check your network.';
}
pin = '';
} finally {
loading = false;
}
}
// Submit on Enter key
function handleKeydown(event: KeyboardEvent): void {
if (event.key >= '0' && event.key <= '9') {
appendDigit(event.key);
} else if (event.key === 'Backspace') {
deleteDigit();
} else if (event.key === 'Enter') {
submitPin();
} else if (event.key === 'Escape') {
clearPin();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<main class="login-container">
<div class="login-card">
<div class="logo">
<h1>Felt</h1>
<p class="subtitle">Tournament Manager</p>
</div>
<!-- PIN dots display -->
<div class="pin-display" role="status" aria-label="PIN entered: {pin.length} digits">
{#each Array(MAX_PIN_LENGTH) as _, i}
<div class="pin-dot" class:filled={i < pin.length}></div>
{/each}
</div>
<!-- Error message -->
{#if error}
<div class="error-message" role="alert">
{error}
</div>
{/if}
<!-- Number pad -->
<div class="numpad">
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as digit}
<button
class="numpad-btn touch-target"
onclick={() => appendDigit(digit)}
disabled={loading || pin.length >= MAX_PIN_LENGTH}
aria-label="Digit {digit}"
>
{digit}
</button>
{/each}
<button
class="numpad-btn numpad-fn touch-target"
onclick={clearPin}
disabled={loading}
aria-label="Clear PIN"
>
CLR
</button>
<button
class="numpad-btn touch-target"
onclick={() => appendDigit('0')}
disabled={loading || pin.length >= MAX_PIN_LENGTH}
aria-label="Digit 0"
>
0
</button>
<button
class="numpad-btn numpad-fn touch-target"
onclick={deleteDigit}
disabled={loading || pin.length === 0}
aria-label="Delete last digit"
>
DEL
</button>
</div>
<!-- Submit button -->
<button
class="submit-btn touch-target"
onclick={submitPin}
disabled={pin.length < 4 || loading}
>
{#if loading}
Signing in...
{:else}
Sign In
{/if}
</button>
</div>
</main>
<style>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: var(--space-4);
background-color: var(--color-bg);
}
.login-card {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-6);
}
.logo {
text-align: center;
}
.logo h1 {
font-size: var(--text-4xl);
font-weight: 700;
color: var(--color-primary);
letter-spacing: -0.02em;
}
.subtitle {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin-top: var(--space-1);
}
/* PIN dots */
.pin-display {
display: flex;
gap: var(--space-3);
padding: var(--space-4) 0;
}
.pin-dot {
width: 16px;
height: 16px;
border-radius: var(--radius-full);
border: 2px solid var(--color-surface-active);
background-color: transparent;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.pin-dot.filled {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
/* Error */
.error-message {
color: var(--color-error);
font-size: var(--text-sm);
text-align: center;
padding: var(--space-2) var(--space-4);
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: var(--radius-md);
width: 100%;
}
/* Number pad */
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
width: 100%;
}
.numpad-btn {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--transition-fast);
}
.numpad-btn:hover:not(:disabled) {
background-color: var(--color-surface-hover);
}
.numpad-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.numpad-fn {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
}
/* Submit button */
.submit-btn {
width: 100%;
height: 56px;
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition:
background-color var(--transition-fast),
opacity var(--transition-fast);
}
.submit-btn:hover:not(:disabled) {
opacity: 0.9;
}
.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
</style>

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

17
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html', // SPA fallback
precompress: false,
strict: true
}),
paths: { base: '' }
}
};
export default config;

14
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

6
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});