From b2b06a062bc736219b6dd7cf4318cfc6cd58589c Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 11:57:58 -1000
Subject: [PATCH 1/9] fix: align SSE architecture with portigo for reliable
connections
- Reorder HandleGameEvents to create NATS subscriptions before SSE
- Use chi's middleware.NewWrapResponseWriter for proper http.Flusher support
- Add slog-zerolog adapter for unified logging
- Add ErrorLog to HTTP server for better error visibility
- Change session Cookie.Secure to false for HTTP support
- Change heartbeat from 15s to 10s
- Remove ConnectionIndicator patching (was causing PatchElementsNoTargetsFound)
The key fix was using chi's response writer wrapper which properly
implements http.Flusher, allowing SSE data to be flushed immediately
instead of being buffered.
---
features/c4game/handlers.go | 87 +++++++++++++++++---------------
features/c4game/pages/game.templ | 1 -
go.mod | 3 ++
go.sum | 6 +++
logging/middleware.go | 21 +++-----
main.go | 22 +++++---
sessions/sessions.go | 2 +-
7 files changed, 79 insertions(+), 63 deletions(-)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 7b153d9..3fa7565 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -16,7 +16,6 @@ import (
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/c4game/pages"
- sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/sessions"
)
@@ -95,6 +94,7 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
@@ -104,68 +104,75 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
}
playerID := sessions.GetPlayerID(sm, r)
- myColor := gi.GetPlayerColor(playerID)
- sse := datastar.NewSSE(w, r, datastar.WithCompression(
- datastar.WithBrotli(datastar.WithBrotliLevel(5)),
- ))
-
- chatCfg := c4ChatConfig(gameID)
- room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
-
- patchAll := func() error {
- myColor = gi.GetPlayerColor(playerID)
- g := gi.GetGame()
- return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
- }
-
- sendPing := func() error {
- return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
- }
-
- // Send initial render and ping
- if err := sendPing(); err != nil {
- return
- }
- if err := patchAll(); err != nil {
- return
- }
-
- heartbeat := time.NewTicker(15 * time.Second)
- defer heartbeat.Stop()
-
- // Subscribe to game state updates
+ // Subscribe to game state updates BEFORE creating SSE
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
- // Subscribe to chat messages
+ // Subscribe to chat messages BEFORE creating SSE
+ chatCfg := c4ChatConfig(gameID)
+ room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
chatCh, cleanupChat := room.Subscribe()
defer cleanupChat()
- ctx := r.Context()
+ // Setup heartbeat BEFORE creating SSE
+ heartbeat := time.NewTicker(10 * time.Second)
+ defer heartbeat.Stop()
+
+ // NOW create SSE
+ sse := datastar.NewSSE(w, r, datastar.WithCompression(
+ datastar.WithBrotli(datastar.WithBrotliLevel(5)),
+ ))
+
+ // Define patch function
+ patchAll := func() error {
+ myColor := gi.GetPlayerColor(playerID)
+ g := gi.GetGame()
+ return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
+ }
+
+ // Send initial state
+ if err := patchAll(); err != nil {
+ return
+ }
+
+ // Event loop
for {
select {
case <-ctx.Done():
return
- case <-heartbeat.C:
- if err := sendPing(); err != nil {
- return
- }
+
case <-gameCh:
+ // Drain rapid-fire notifications
+ drainGame:
+ for {
+ select {
+ case <-gameCh:
+ default:
+ break drainGame
+ }
+ }
if err := patchAll(); err != nil {
return
}
+
case chatMsg := <-chatCh:
- err := sse.PatchElementTempl(
+ if err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("c4-chat-history"),
datastar.WithModeAppend(),
- )
- if err != nil {
+ ); err != nil {
+ return
+ }
+
+ case <-heartbeat.C:
+ // Heartbeat just keeps the connection alive by triggering a game state refresh
+ if err := patchAll(); err != nil {
return
}
}
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index 455c91d..c5d9f11 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -18,7 +18,6 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
data-signals="{chatMsg: ''}"
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
>
- @sharedcomponents.ConnectionIndicator(0)
@GameContent(g, myColor, messages, chatCfg)
}
diff --git a/go.mod b/go.mod
index 069eb86..ad1794a 100644
--- a/go.mod
+++ b/go.mod
@@ -170,6 +170,9 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/sajari/fuzzy v1.0.0 // indirect
+ github.com/samber/lo v1.52.0 // indirect
+ github.com/samber/slog-common v0.20.0 // indirect
+ github.com/samber/slog-zerolog/v2 v2.9.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
diff --git a/go.sum b/go.sum
index df2ccbd..150f2e4 100644
--- a/go.sum
+++ b/go.sum
@@ -565,6 +565,12 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
+github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
+github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
+github.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc=
+github.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8=
+github.com/samber/slog-zerolog/v2 v2.9.1 h1:RMOq8XqzfuGx1X0TEIlS9OXbbFmqLY2/wJppghz66YY=
+github.com/samber/slog-zerolog/v2 v2.9.1/go.mod h1:DQYYve14WgCRN/XnKeHl4266jXK0DgYkYXkfZ4Fp98k=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
diff --git a/logging/middleware.go b/logging/middleware.go
index be6c21a..7cdc9e8 100644
--- a/logging/middleware.go
+++ b/logging/middleware.go
@@ -5,10 +5,11 @@ import (
"net/http"
"time"
- "github.com/ryanhamamura/games/config"
-
+ "github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
+
+ "github.com/ryanhamamura/games/config"
)
const (
@@ -64,25 +65,15 @@ func colorLatency(d time.Duration, useColor bool) string {
}
}
-type responseWriter struct {
- http.ResponseWriter
- status int
-}
-
-func (rw *responseWriter) WriteHeader(code int) {
- rw.status = code
- rw.ResponseWriter.WriteHeader(code)
-}
-
func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
- rw := &responseWriter{ResponseWriter: w}
+ ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
- next.ServeHTTP(rw, r)
+ next.ServeHTTP(ww, r)
- status := rw.status
+ status := ww.Status()
if status == 0 {
status = http.StatusOK
}
diff --git a/main.go b/main.go
index 9170eb1..34cde03 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,12 @@ import (
"syscall"
"time"
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/rs/zerolog/log"
+ slogzerolog "github.com/samber/slog-zerolog/v2"
+ "golang.org/x/sync/errgroup"
+
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db"
@@ -21,11 +27,6 @@ import (
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
"github.com/ryanhamamura/games/version"
-
- "github.com/go-chi/chi/v5"
- "github.com/go-chi/chi/v5/middleware"
- "github.com/rs/zerolog/log"
- "golang.org/x/sync/errgroup"
)
//go:embed assets
@@ -36,7 +37,12 @@ func main() {
defer cancel()
cfg := config.Global
- logging.SetupLogger(cfg.Environment, cfg.LogLevel)
+ zerologLogger := logging.SetupLogger(cfg.Environment, cfg.LogLevel)
+ slog.SetDefault(slog.New(slogzerolog.Option{
+ Level: slogzerolog.ZeroLogLeveler{Logger: zerologLogger},
+ Logger: zerologLogger,
+ NoTimestamp: true,
+ }.NewZerologHandler()))
if err := run(ctx); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("server error")
@@ -101,6 +107,10 @@ func run(ctx context.Context) error {
BaseContext: func(l net.Listener) context.Context {
return egctx
},
+ ErrorLog: slog.NewLogLogger(
+ slog.Default().Handler(),
+ slog.LevelError,
+ ),
}
eg.Go(func() error {
diff --git a/sessions/sessions.go b/sessions/sessions.go
index 3880d47..fe8e0e0 100644
--- a/sessions/sessions.go
+++ b/sessions/sessions.go
@@ -33,7 +33,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
sessionManager.Cookie.Name = "games_session"
sessionManager.Cookie.Path = "/"
sessionManager.Cookie.HttpOnly = true
- sessionManager.Cookie.Secure = true
+ sessionManager.Cookie.Secure = false
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
slog.Info("session manager configured")
--
2.49.1
From 8536f8e948e6f4a17cf6f0626117bd1d5547c9c1 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:23:25 -1000
Subject: [PATCH 2/9] refactor: extract GameService for Connect 4 NATS/chat
handling
Move NATS subscription and chat room management into a dedicated
GameService, following the portigo service pattern. Handlers now
receive the service and call its methods instead of managing
NATS connections directly.
---
features/c4game/handlers.go | 44 ++++-----------
features/c4game/routes.go | 12 ++--
features/c4game/services/game_service.go | 70 ++++++++++++++++++++++++
router/router.go | 16 ++++--
4 files changed, 95 insertions(+), 47 deletions(-)
create mode 100644 features/c4game/services/game_service.go
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 3fa7565..3c94474 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -1,46 +1,23 @@
package c4game
import (
- "fmt"
"net/http"
"strconv"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
- "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
- "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/c4game/pages"
+ "github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/sessions"
)
-// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
-var c4ChatColors = map[int]string{
- 0: "#4a2a3a", // color 1 stored as slot 0
- 1: "#2a4545", // color 2 stored as slot 1
-}
-
-func c4ChatColor(slot int) string {
- if c, ok := c4ChatColors[slot]; ok {
- return c
- }
- return "#666"
-}
-
-func c4ChatConfig(gameID string) chatcomponents.Config {
- return chatcomponents.Config{
- CSSPrefix: "c4",
- PostURL: fmt.Sprintf("/games/%s/chat", gameID),
- Color: c4ChatColor,
- }
-}
-
-func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -84,15 +61,15 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
}
g := gi.GetGame()
- room := chat.NewPersistentRoom(nil, "", queries, gameID)
+ room := svc.ChatRoom(gameID)
- if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
+ if err := pages.GamePage(g, myColor, room.Messages(), svc.ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
}
-func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
gameID := chi.URLParam(r, "id")
@@ -106,8 +83,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
playerID := sessions.GetPlayerID(sm, r)
// Subscribe to game state updates BEFORE creating SSE
- gameCh := make(chan *nats.Msg, 64)
- gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
+ gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -115,8 +91,8 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages BEFORE creating SSE
- chatCfg := c4ChatConfig(gameID)
- room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
+ chatCfg := svc.ChatConfig(gameID)
+ room := svc.ChatRoom(gameID)
chatCh, cleanupChat := room.Subscribe()
defer cleanupChat()
@@ -209,7 +185,7 @@ func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.Handler
}
}
-func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
+func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -256,7 +232,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
- room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
+ room := svc.ChatRoom(gameID)
room.Send(msg)
sse := datastar.NewSSE(w, r)
diff --git a/features/c4game/routes.go b/features/c4game/routes.go
index e936fd4..123eb7c 100644
--- a/features/c4game/routes.go
+++ b/features/c4game/routes.go
@@ -4,24 +4,22 @@ package c4game
import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
- "github.com/nats-io/nats.go"
"github.com/ryanhamamura/games/connect4"
- "github.com/ryanhamamura/games/db/repository"
+ "github.com/ryanhamamura/games/features/c4game/services"
)
func SetupRoutes(
router chi.Router,
store *connect4.Store,
- nc *nats.Conn,
+ svc *services.GameService,
sessions *scs.SessionManager,
- queries *repository.Queries,
) {
router.Route("/games/{id}", func(r chi.Router) {
- r.Get("/", HandleGamePage(store, sessions, queries))
- r.Get("/events", HandleGameEvents(store, nc, sessions, queries))
+ r.Get("/", HandleGamePage(store, svc, sessions))
+ r.Get("/events", HandleGameEvents(store, svc, sessions))
r.Post("/drop", HandleDropPiece(store, sessions))
- r.Post("/chat", HandleSendChat(store, nc, sessions, queries))
+ r.Post("/chat", HandleSendChat(store, svc, sessions))
r.Post("/join", HandleSetNickname(store, sessions))
r.Post("/rematch", HandleRematch(store, sessions))
})
diff --git a/features/c4game/services/game_service.go b/features/c4game/services/game_service.go
new file mode 100644
index 0000000..40927ad
--- /dev/null
+++ b/features/c4game/services/game_service.go
@@ -0,0 +1,70 @@
+// Package services provides the game service layer for Connect 4,
+// handling NATS subscriptions and chat room management.
+package services
+
+import (
+ "fmt"
+
+ "github.com/nats-io/nats.go"
+
+ "github.com/ryanhamamura/games/chat"
+ chatcomponents "github.com/ryanhamamura/games/chat/components"
+ "github.com/ryanhamamura/games/connect4"
+ "github.com/ryanhamamura/games/db/repository"
+)
+
+// c4ChatColors maps player slot (0-indexed) to CSS background colors.
+var c4ChatColors = map[int]string{
+ 0: "#4a2a3a", // Red player
+ 1: "#2a4545", // Yellow player
+}
+
+func c4ChatColor(slot int) string {
+ if c, ok := c4ChatColors[slot]; ok {
+ return c
+ }
+ return "#666"
+}
+
+// GameService manages NATS subscriptions and chat for Connect 4 games.
+type GameService struct {
+ nc *nats.Conn
+ queries *repository.Queries
+}
+
+// NewGameService creates a new game service.
+func NewGameService(nc *nats.Conn, queries *repository.Queries) *GameService {
+ return &GameService{
+ nc: nc,
+ queries: queries,
+ }
+}
+
+// SubscribeGameUpdates returns a NATS subscription and channel for game state updates.
+func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) {
+ ch := make(chan *nats.Msg, 64)
+ sub, err := s.nc.ChanSubscribe(connect4.GameSubject(gameID), ch)
+ if err != nil {
+ return nil, nil, fmt.Errorf("subscribing to game updates: %w", err)
+ }
+ return sub, ch, nil
+}
+
+// ChatConfig returns the chat configuration for a game.
+func (s *GameService) ChatConfig(gameID string) chatcomponents.Config {
+ return chatcomponents.Config{
+ CSSPrefix: "c4",
+ PostURL: fmt.Sprintf("/games/%s/chat", gameID),
+ Color: c4ChatColor,
+ }
+}
+
+// ChatRoom returns a persistent chat room for a game.
+func (s *GameService) ChatRoom(gameID string) *chat.Room {
+ return chat.NewPersistentRoom(s.nc, connect4.ChatSubject(gameID), s.queries, gameID)
+}
+
+// PublishGameUpdate sends a notification that the game state has changed.
+func (s *GameService) PublishGameUpdate(gameID string) error {
+ return s.nc.Publish(connect4.GameSubject(gameID), nil)
+}
diff --git a/router/router.go b/router/router.go
index 1a2cd8f..a779768 100644
--- a/router/router.go
+++ b/router/router.go
@@ -7,19 +7,20 @@ import (
"net/http"
"sync"
+ "github.com/alexedwards/scs/v2"
+ "github.com/go-chi/chi/v5"
+ "github.com/nats-io/nats.go"
+ "github.com/starfederation/datastar-go/datastar"
+
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/games/features/c4game"
+ "github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/games/features/snakegame"
"github.com/ryanhamamura/games/snake"
-
- "github.com/alexedwards/scs/v2"
- "github.com/go-chi/chi/v5"
- "github.com/nats-io/nats.go"
- "github.com/starfederation/datastar-go/datastar"
)
func SetupRoutes(
@@ -40,9 +41,12 @@ func SetupRoutes(
setupReload(router)
}
+ // Services
+ c4Svc := services.NewGameService(nc, queries)
+
auth.SetupRoutes(router, queries, sessions)
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
- c4game.SetupRoutes(router, store, nc, sessions, queries)
+ c4game.SetupRoutes(router, store, c4Svc, sessions)
snakegame.SetupRoutes(router, snakeStore, nc, sessions)
}
--
2.49.1
From de78ba6d39f5ef9c0ad40b988bb63689fc2162ce Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:25:25 -1000
Subject: [PATCH 3/9] refactor: extract GameService for Snake NATS/chat
handling
Apply the same service pattern from Connect 4 to Snake game.
Handlers now receive the service and call its methods instead of
managing NATS connections directly. Also aligns heartbeat to 10s
and removes ConnectionIndicator patching (matching C4 changes).
---
features/snakegame/handlers.go | 68 +++++++--------------
features/snakegame/routes.go | 10 +--
features/snakegame/services/game_service.go | 62 +++++++++++++++++++
router/router.go | 8 ++-
4 files changed, 95 insertions(+), 53 deletions(-)
create mode 100644 features/snakegame/services/game_service.go
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 4448beb..99d11fd 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -1,41 +1,24 @@
package snakegame
import (
- "fmt"
+ "errors"
"net/http"
"strconv"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
- "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
- sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/snakegame/pages"
+ "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
)
-func snakeChatColor(slot int) string {
- if slot >= 0 && slot < len(snake.SnakeColors) {
- return snake.SnakeColors[slot]
- }
- return "#666"
-}
-
-func snakeChatConfig(gameID string) chatcomponents.Config {
- return chatcomponents.Config{
- CSSPrefix: "snake",
- PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
- Color: snakeChatColor,
- StopKeyPropagation: true,
- }
-}
-
-func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
+func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -77,13 +60,14 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
}
sg := si.GetGame()
- if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
+ chatCfg := svc.ChatConfig(gameID)
+ if err := pages.GamePage(sg, mySlot, nil, chatCfg, gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
}
-func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
+func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -95,17 +79,25 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID)
+ // Subscribe to game updates BEFORE creating SSE (following portigo pattern)
+ gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ defer gameSub.Unsubscribe() //nolint:errcheck
+
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
- chatCfg := snakeChatConfig(gameID)
+ chatCfg := svc.ChatConfig(gameID)
// Chat room (multiplayer only)
var room *chat.Room
sg := si.GetGame()
if sg.Mode == snake.ModeMultiplayer {
- room = chat.NewRoom(nc, snake.ChatSubject(gameID))
+ room = svc.ChatRoom(gameID)
}
chatMessages := func() []chat.Message {
@@ -118,36 +110,21 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
patchAll := func() error {
si, ok = snakeStore.Get(gameID)
if !ok {
- return fmt.Errorf("game not found")
+ return errors.New("game not found")
}
mySlot = si.GetPlayerSlot(playerID)
sg = si.GetGame()
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
}
- sendPing := func() error {
- return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
- }
-
- // Send initial render and ping
- if err := sendPing(); err != nil {
- return
- }
+ // Send initial render
if err := patchAll(); err != nil {
return
}
- heartbeat := time.NewTicker(15 * time.Second)
+ heartbeat := time.NewTicker(10 * time.Second)
defer heartbeat.Stop()
- // Subscribe to game updates via NATS
- gameCh := make(chan *nats.Msg, 64)
- gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
- if err != nil {
- return
- }
- defer gameSub.Unsubscribe() //nolint:errcheck
-
// Chat subscription (multiplayer only)
var chatCh <-chan chat.Message
var cleanupChat func()
@@ -164,7 +141,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
return
case <-heartbeat.C:
- if err := sendPing(); err != nil {
+ // Heartbeat just refreshes game state
+ if err := patchAll(); err != nil {
return
}
@@ -231,7 +209,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"`
}
-func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
+func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID)
@@ -264,7 +242,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
Message: signals.ChatMsg,
}
- room := chat.NewRoom(nc, snake.ChatSubject(gameID))
+ room := svc.ChatRoom(gameID)
room.Send(msg)
sse := datastar.NewSSE(w, r)
diff --git a/features/snakegame/routes.go b/features/snakegame/routes.go
index 0f30757..4999631 100644
--- a/features/snakegame/routes.go
+++ b/features/snakegame/routes.go
@@ -4,17 +4,17 @@ package snakegame
import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
- "github.com/nats-io/nats.go"
+ "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/snake"
)
-func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
+func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, svc *services.GameService, sessions *scs.SessionManager) {
router.Route("/snake/{id}", func(r chi.Router) {
- r.Get("/", HandleSnakePage(snakeStore, sessions))
- r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions))
+ r.Get("/", HandleSnakePage(snakeStore, svc, sessions))
+ r.Get("/events", HandleSnakeEvents(snakeStore, svc, sessions))
r.Post("/dir", HandleSetDirection(snakeStore, sessions))
- r.Post("/chat", HandleSendChat(snakeStore, nc, sessions))
+ r.Post("/chat", HandleSendChat(snakeStore, svc, sessions))
r.Post("/join", HandleSetNickname(snakeStore, sessions))
r.Post("/rematch", HandleRematch(snakeStore, sessions))
})
diff --git a/features/snakegame/services/game_service.go b/features/snakegame/services/game_service.go
new file mode 100644
index 0000000..ac6c70c
--- /dev/null
+++ b/features/snakegame/services/game_service.go
@@ -0,0 +1,62 @@
+// Package services provides the game service layer for Snake,
+// handling NATS subscriptions and chat room management.
+package services
+
+import (
+ "fmt"
+
+ "github.com/nats-io/nats.go"
+
+ "github.com/ryanhamamura/games/chat"
+ chatcomponents "github.com/ryanhamamura/games/chat/components"
+ "github.com/ryanhamamura/games/snake"
+)
+
+func snakeChatColor(slot int) string {
+ if slot >= 0 && slot < len(snake.SnakeColors) {
+ return snake.SnakeColors[slot]
+ }
+ return "#666"
+}
+
+// GameService manages NATS subscriptions and chat for Snake games.
+type GameService struct {
+ nc *nats.Conn
+}
+
+// NewGameService creates a new game service.
+func NewGameService(nc *nats.Conn) *GameService {
+ return &GameService{
+ nc: nc,
+ }
+}
+
+// SubscribeGameUpdates returns a NATS subscription and channel for game state updates.
+func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) {
+ ch := make(chan *nats.Msg, 64)
+ sub, err := s.nc.ChanSubscribe(snake.GameSubject(gameID), ch)
+ if err != nil {
+ return nil, nil, fmt.Errorf("subscribing to game updates: %w", err)
+ }
+ return sub, ch, nil
+}
+
+// ChatConfig returns the chat configuration for a game.
+func (s *GameService) ChatConfig(gameID string) chatcomponents.Config {
+ return chatcomponents.Config{
+ CSSPrefix: "snake",
+ PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
+ Color: snakeChatColor,
+ StopKeyPropagation: true,
+ }
+}
+
+// ChatRoom returns a chat room for a game (ephemeral, not persisted).
+func (s *GameService) ChatRoom(gameID string) *chat.Room {
+ return chat.NewRoom(s.nc, snake.ChatSubject(gameID))
+}
+
+// PublishGameUpdate sends a notification that the game state has changed.
+func (s *GameService) PublishGameUpdate(gameID string) error {
+ return s.nc.Publish(snake.GameSubject(gameID), nil)
+}
diff --git a/router/router.go b/router/router.go
index a779768..216509f 100644
--- a/router/router.go
+++ b/router/router.go
@@ -17,9 +17,10 @@ import (
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/games/features/c4game"
- "github.com/ryanhamamura/games/features/c4game/services"
+ c4services "github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/games/features/snakegame"
+ snakeservices "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/snake"
)
@@ -42,12 +43,13 @@ func SetupRoutes(
}
// Services
- c4Svc := services.NewGameService(nc, queries)
+ c4Svc := c4services.NewGameService(nc, queries)
+ snakeSvc := snakeservices.NewGameService(nc)
auth.SetupRoutes(router, queries, sessions)
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
c4game.SetupRoutes(router, store, c4Svc, sessions)
- snakegame.SetupRoutes(router, snakeStore, nc, sessions)
+ snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
}
func setupReload(router chi.Router) {
--
2.49.1
From cedcadfe3cd75bc209fefbe85ea745a3ecba5920 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:34:13 -1000
Subject: [PATCH 4/9] feat: re-enable connection indicator for SSE status
Add ConnectionIndicator to Connect 4 game page (Snake already had it).
Both games now patch the indicator on initial connect and every 10s
heartbeat, giving users visual feedback that SSE is connected.
---
features/c4game/handlers.go | 11 +++++++++--
features/c4game/pages/game.templ | 1 +
features/snakegame/handlers.go | 11 +++++++++--
3 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 3c94474..2515eef 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -14,6 +14,7 @@ import (
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/features/c4game/services"
+ sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/sessions"
)
@@ -112,7 +113,10 @@ func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
- // Send initial state
+ // Send initial connection indicator and state
+ if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
+ return
+ }
if err := patchAll(); err != nil {
return
}
@@ -147,7 +151,10 @@ func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.
}
case <-heartbeat.C:
- // Heartbeat just keeps the connection alive by triggering a game state refresh
+ // Heartbeat updates connection indicator and refreshes game state
+ if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
+ return
+ }
if err := patchAll(); err != nil {
return
}
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index c5d9f11..455c91d 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -18,6 +18,7 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
data-signals="{chatMsg: ''}"
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
>
+ @sharedcomponents.ConnectionIndicator(0)
@GameContent(g, myColor, messages, chatCfg)
}
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 99d11fd..7808327 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -12,6 +12,7 @@ import (
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
+ sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/sessions"
@@ -117,7 +118,10 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
}
- // Send initial render
+ // Send initial connection indicator and render
+ if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
+ return
+ }
if err := patchAll(); err != nil {
return
}
@@ -141,7 +145,10 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
return
case <-heartbeat.C:
- // Heartbeat just refreshes game state
+ // Heartbeat updates connection indicator and refreshes game state
+ if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
+ return
+ }
if err := patchAll(); err != nil {
return
}
--
2.49.1
From 1109dccdd86bed153915cd3a66b55c13c26fe035 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:47:28 -1000
Subject: [PATCH 5/9] fix: remove broken connection indicator
The ConnectionIndicator component caused PatchElementsNoTargetsFound
errors due to complex nested IDs. Removing it for now - we can design
a better solution later if needed.
---
features/c4game/handlers.go | 11 ++---------
features/c4game/pages/game.templ | 1 -
features/snakegame/handlers.go | 11 ++---------
features/snakegame/pages/game.templ | 1 -
4 files changed, 4 insertions(+), 20 deletions(-)
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 2515eef..6b22d5b 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -14,7 +14,6 @@ import (
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/features/c4game/services"
- sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/sessions"
)
@@ -113,10 +112,7 @@ func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
- // Send initial connection indicator and state
- if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
- return
- }
+ // Send initial state
if err := patchAll(); err != nil {
return
}
@@ -151,10 +147,7 @@ func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.
}
case <-heartbeat.C:
- // Heartbeat updates connection indicator and refreshes game state
- if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
- return
- }
+ // Heartbeat refreshes game state to keep connection alive
if err := patchAll(); err != nil {
return
}
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index 455c91d..c5d9f11 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -18,7 +18,6 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
data-signals="{chatMsg: ''}"
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
>
- @sharedcomponents.ConnectionIndicator(0)
@GameContent(g, myColor, messages, chatCfg)
}
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 7808327..74cb84e 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -12,7 +12,6 @@ import (
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
- sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/sessions"
@@ -118,10 +117,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
}
- // Send initial connection indicator and render
- if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
- return
- }
+ // Send initial render
if err := patchAll(); err != nil {
return
}
@@ -145,10 +141,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
return
case <-heartbeat.C:
- // Heartbeat updates connection indicator and refreshes game state
- if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
- return
- }
+ // Heartbeat refreshes game state to keep connection alive
if err := patchAll(); err != nil {
return
}
diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ
index a1b2cd4..378690c 100644
--- a/features/snakegame/pages/game.templ
+++ b/features/snakegame/pages/game.templ
@@ -37,7 +37,6 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
tabindex="0"
>
- @components.ConnectionIndicator(0)
@GameContent(sg, mySlot, messages, chatCfg, gameID)
}
--
2.49.1
From 155ac2c71a6a6e6e5a51e9ec194c9476dbba4d66 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:52:39 -1000
Subject: [PATCH 6/9] feat: replace connection indicator with live clock
A ticking clock serves as an implicit connection indicator - if it
stops updating, users know the connection is stale. Simpler and more
useful than a status dot.
---
features/c4game/pages/game.templ | 1 +
features/common/components/shared.templ | 60 +++----------------------
features/snakegame/pages/game.templ | 1 +
3 files changed, 8 insertions(+), 54 deletions(-)
diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ
index c5d9f11..35d9c84 100644
--- a/features/c4game/pages/game.templ
+++ b/features/c4game/pages/game.templ
@@ -25,6 +25,7 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
+ @sharedcomponents.LiveClock()
@sharedcomponents.BackToLobby()
@sharedcomponents.StealthTitle("text-3xl font-bold")
@components.PlayerInfo(g, myColor)
diff --git a/features/common/components/shared.templ b/features/common/components/shared.templ
index d5f68c0..0f92101 100644
--- a/features/common/components/shared.templ
+++ b/features/common/components/shared.templ
@@ -1,7 +1,7 @@
package components
import (
- "fmt"
+ "time"
"github.com/starfederation/datastar-go/datastar"
)
@@ -48,60 +48,12 @@ templ NicknamePrompt(returnPath string) {
}
-func isStale(lastPing int64) bool {
- return lastPing == 0
-}
-
-var connectionWatcherHandle = templ.NewOnceHandle()
-
-// ConnectionIndicator shows a small dot indicating SSE connection status.
-// Server patches this with a timestamp; client JS detects staleness.
-templ ConnectionIndicator(lastPing int64) {
-
-
+// LiveClock shows the current server time, updated with each SSE patch.
+// If the clock stops updating, users know the connection is stale.
+templ LiveClock() {
+
+ { time.Now().Format("15:04:05") }
- @connectionWatcherHandle.Once() {
- @connectionWatcher()
- }
-}
-
-script connectionWatcher() {
- setInterval(function() {
- var el = document.getElementById('connection-indicator');
- var dot = document.getElementById('connection-dot');
- var ping = document.getElementById('connection-ping');
- if (!el || !dot || !ping) return;
-
- var lastPing = parseInt(el.dataset.lastPing, 10) || 0;
- var stale = Date.now() - lastPing > 20000;
-
- dot.classList.toggle('status-success', !stale);
- dot.classList.toggle('status-error', stale);
- ping.classList.toggle('status-success', !stale);
- ping.classList.toggle('status-error', stale);
- ping.classList.toggle('animate-ping', !stale);
- }, 1000);
}
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ
index 378690c..7ab922a 100644
--- a/features/snakegame/pages/game.templ
+++ b/features/snakegame/pages/game.templ
@@ -44,6 +44,7 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
+ @components.LiveClock()
@components.BackToLobby()
~~~~
@snakecomponents.PlayerList(sg, mySlot)
--
2.49.1
From e0f5d555fba4559bf9b83a0bf3105cd1713037b8 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 12:53:46 -1000
Subject: [PATCH 7/9] feat: add status indicator dot to live clock
---
features/common/components/shared.templ | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/features/common/components/shared.templ b/features/common/components/shared.templ
index 0f92101..d310227 100644
--- a/features/common/components/shared.templ
+++ b/features/common/components/shared.templ
@@ -51,7 +51,8 @@ templ NicknamePrompt(returnPath string) {
// LiveClock shows the current server time, updated with each SSE patch.
// If the clock stops updating, users know the connection is stale.
templ LiveClock() {
-
+
+
{ time.Now().Format("15:04:05") }
}
--
2.49.1
From 9a8fe4534df166f8b8b839e3cb112360a9d47f26 Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:26:52 -1000
Subject: [PATCH 8/9] feat: add hashfs for static asset cache busting and live
clock
- Add assets package with dev/prod build tags
- Dev: serve from filesystem with Cache-Control: no-store
- Prod: use hashfs for cache-busting URLs
- Add LiveClock component to show SSE connection status
- Update templates to use StaticPath for asset URLs
---
assets/assets.go | 5 +++++
assets/static_dev.go | 22 +++++++++++++++++++++
assets/static_prod.go | 26 +++++++++++++++++++++++++
features/common/components/shared.templ | 4 ++--
features/common/layouts/base.templ | 5 +++--
go.mod | 1 +
go.sum | 2 ++
main.go | 6 +-----
router/router.go | 7 ++-----
9 files changed, 64 insertions(+), 14 deletions(-)
create mode 100644 assets/assets.go
create mode 100644 assets/static_dev.go
create mode 100644 assets/static_prod.go
diff --git a/assets/assets.go b/assets/assets.go
new file mode 100644
index 0000000..837ab85
--- /dev/null
+++ b/assets/assets.go
@@ -0,0 +1,5 @@
+// Package assets provides static file serving with build-tag switching
+// between live filesystem (dev) and embedded hashfs (prod).
+package assets
+
+const DirectoryPath = "assets"
diff --git a/assets/static_dev.go b/assets/static_dev.go
new file mode 100644
index 0000000..4b4776c
--- /dev/null
+++ b/assets/static_dev.go
@@ -0,0 +1,22 @@
+//go:build dev
+
+package assets
+
+import (
+ "net/http"
+ "os"
+
+ "github.com/rs/zerolog/log"
+)
+
+func Handler() http.Handler {
+ log.Debug().Str("path", DirectoryPath).Msg("static assets served from filesystem")
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.StripPrefix("/assets/", http.FileServerFS(os.DirFS(DirectoryPath))).ServeHTTP(w, r)
+ })
+}
+
+func StaticPath(path string) string {
+ return "/assets/" + path
+}
diff --git a/assets/static_prod.go b/assets/static_prod.go
new file mode 100644
index 0000000..6a17b54
--- /dev/null
+++ b/assets/static_prod.go
@@ -0,0 +1,26 @@
+//go:build !dev
+
+package assets
+
+import (
+ "embed"
+ "net/http"
+
+ "github.com/benbjohnson/hashfs"
+ "github.com/rs/zerolog/log"
+)
+
+var (
+ //go:embed css js
+ staticFiles embed.FS
+ staticSys = hashfs.NewFS(staticFiles)
+)
+
+func Handler() http.Handler {
+ log.Debug().Msg("static assets are embedded with hashfs")
+ return hashfs.FileServer(staticSys)
+}
+
+func StaticPath(path string) string {
+ return "/" + staticSys.HashName(path)
+}
diff --git a/features/common/components/shared.templ b/features/common/components/shared.templ
index d310227..b9f9d2d 100644
--- a/features/common/components/shared.templ
+++ b/features/common/components/shared.templ
@@ -51,8 +51,8 @@ templ NicknamePrompt(returnPath string) {
// LiveClock shows the current server time, updated with each SSE patch.
// If the clock stops updating, users know the connection is stale.
templ LiveClock() {
-
-
+
+
{ time.Now().Format("15:04:05") }
}
diff --git a/features/common/layouts/base.templ b/features/common/layouts/base.templ
index 5029e7f..e779665 100644
--- a/features/common/layouts/base.templ
+++ b/features/common/layouts/base.templ
@@ -1,6 +1,7 @@
package layouts
import (
+ "github.com/ryanhamamura/games/assets"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/version"
)
@@ -11,8 +12,8 @@ templ Base(title string) {
{ title }
-
-
+
+
if config.Global.Environment == config.Dev {
diff --git a/go.mod b/go.mod
index ad1794a..e7ffb13 100644
--- a/go.mod
+++ b/go.mod
@@ -68,6 +68,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
+ github.com/benbjohnson/hashfs v0.2.2 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
diff --git a/go.sum b/go.sum
index 150f2e4..eeb1ae1 100644
--- a/go.sum
+++ b/go.sum
@@ -136,6 +136,8 @@ github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/benbjohnson/hashfs v0.2.2 h1:vFZtksphM5LcnMRFctj49jCUkCc7wp3NP6INyfjkse4=
+github.com/benbjohnson/hashfs v0.2.2/go.mod h1:7OMXaMVo1YkfiIPxKrl7OXkUTUgWjmsAKyR+E6xDIRM=
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
diff --git a/main.go b/main.go
index 34cde03..9ad9f0b 100644
--- a/main.go
+++ b/main.go
@@ -2,7 +2,6 @@ package main
import (
"context"
- "embed"
"fmt"
"log/slog"
"net"
@@ -29,9 +28,6 @@ import (
"github.com/ryanhamamura/games/version"
)
-//go:embed assets
-var assets embed.FS
-
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
@@ -97,7 +93,7 @@ func run(ctx context.Context) error {
sessionManager.LoadAndSave,
)
- router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets)
+ router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
// HTTP server
srv := &http.Server{
diff --git a/router/router.go b/router/router.go
index 216509f..083c7ac 100644
--- a/router/router.go
+++ b/router/router.go
@@ -2,8 +2,6 @@
package router
import (
- "embed"
- "io/fs"
"net/http"
"sync"
@@ -12,6 +10,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
+ "github.com/ryanhamamura/games/assets"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
@@ -31,11 +30,9 @@ func SetupRoutes(
nc *nats.Conn,
store *connect4.Store,
snakeStore *snake.SnakeStore,
- assets embed.FS,
) {
// Static assets
- subFS, _ := fs.Sub(assets, "assets")
- router.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServerFS(subFS)))
+ router.Handle("/assets/*", assets.Handler())
// Hot-reload for development
if config.Global.Environment == config.Dev {
--
2.49.1
From c826981b4d098b283656650a4a52a84ba707857d Mon Sep 17 00:00:00 2001
From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:28:59 -1000
Subject: [PATCH 9/9] feat: tick live clock every second via SSE heartbeat
---
assets/css/output.css | 2611 ++++++++++++++++++++++-
features/c4game/handlers.go | 2 +-
features/common/components/shared.templ | 2 +-
features/snakegame/handlers.go | 2 +-
4 files changed, 2613 insertions(+), 4 deletions(-)
diff --git a/assets/css/output.css b/assets/css/output.css
index fefcd35..c3d2248 100644
--- a/assets/css/output.css
+++ b/assets/css/output.css
@@ -1,2 +1,2611 @@
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
-@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-gray-800:oklch(27.8% .033 256.848);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-bold:700;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@media (prefers-color-scheme:dark){:root:not([data-theme]){color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000}}@property --radialprogress{syntax:"
";inherits:true;initial-value:0%}:root:not(span){overflow:var(--page-overflow)}:root{background:var(--page-scroll-bg,var(--root-bg));--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) var(--root-bg,#0000)}@supports (color:color-mix(in lab, red, red)){:root{--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%))}}:root{--page-scroll-transition-on:background-color .3s ease-out;transition:var(--page-scroll-transition);scrollbar-gutter:var(--page-scroll-gutter,unset);scrollbar-gutter:if(style(--page-has-scroll: 1): var(--page-scroll-gutter,unset) ; else: unset)}@keyframes set-page-has-scroll{0%,to{--page-has-scroll:1}}:root,[data-theme]{background:var(--page-scroll-bg,var(--root-bg));color:var(--color-base-content)}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}:where(:root),:root:has(input.theme-controller[value=stealth]:checked),[data-theme=stealth]{color-scheme:light;--color-base-100:oklch(78% .008 260);--color-base-200:oklch(72% .008 260);--color-base-300:oklch(64% .008 260);--color-base-content:oklch(22% .01 260);--color-primary:oklch(38% .02 260);--color-primary-content:oklch(90% .006 260);--color-secondary:oklch(52% .015 260);--color-secondary-content:oklch(22% .01 260);--color-accent:oklch(48% .02 280);--color-accent-content:oklch(90% .006 260);--color-neutral:oklch(35% .015 260);--color-neutral-content:oklch(88% .006 260);--color-success:oklch(52% .02 160);--color-success-content:oklch(22% .01 160);--color-warning:oklch(58% .02 80);--color-warning-content:oklch(28% .01 80);--color-error:oklch(45% .03 20);--color-error-content:oklch(90% .006 20);--color-info:oklch(48% .02 250);--color-info-content:oklch(22% .01 250);--radius-selector:.5rem;--radius-field:.5rem;--radius-box:.75rem;--border:1px;--depth:0;--noise:0}}@layer components;@layer utilities{@layer daisyui.l1.l2.l3{.diff{webkit-user-select:none;-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.diff:focus-visible,.diff:has(.diff-item-1:focus-visible),.diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.diff:focus-visible .diff-resizer{min-width:95cqi;max-width:95cqi}.diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px}.diff:has(.diff-item-1:focus-visible) .diff-resizer{min-width:5cqi;max-width:5cqi}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}.tab{cursor:pointer;appearance:none;text-align:center;webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative}@media (hover:hover){.tab:hover{color:var(--color-base-content)}}.tab{--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem}.tab:is(input[type=radio]){min-width:fit-content}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:is(label){position:relative}.tab:is(label) input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content{display:block}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.tab:not(input):empty{cursor:default;flex-grow:1}.tab:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.tab:focus{outline-offset:2px;outline:2px solid #0000}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}.tab[disabled]{pointer-events:none;opacity:.4}:where(.btn){width:unset}.btn{cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;webkit-user-select:none;-webkit-user-select:none;user-select:none;padding-inline:var(--btn-p);color:var(--btn-fg);--tw-prose-links:var(--btn-fg);height:var(--size);font-size:var(--fontsize,.875rem);outline-color:var(--btn-color,var(--color-base-content));background-color:var(--btn-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--btn-noise);border-width:var(--border);border-style:solid;border-color:var(--btn-border);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));touch-action:manipulation;box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;font-weight:600;transition-property:color,background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg), 0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.btn{--btn-noise:var(--fx-noise)}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200));translate:0 .5px}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%)}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio])[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);isolation:isolate}.countdown{display:inline-flex}.countdown>*{visibility:hidden;--value-v:calc(mod(max(0, var(--value)), 1000));--value-hundreds:calc(round(to-zero, var(--value-v) / 100, 1));--value-tens:calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));--value-ones:calc(mod(var(--value-v), 100));--show-hundreds:clamp(clamp(0, var(--digits,1) - 2, 1), var(--value-hundreds), 1);--show-tens:clamp(clamp(0, var(--digits,1) - 1, 1), var(--value-tens) + var(--show-hundreds), 1);--first-digits:calc(round(to-zero, var(--value-v) / 10, 1));height:1em;width:calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);direction:ltr;transition:width .4s ease-out .2s;display:inline-block;position:relative;overflow-y:clip}.countdown>:before,.countdown>:after{visibility:visible;--tw-content:"00\a 01\a 02\a 03\a 04\a 05\a 06\a 07\a 08\a 09\a 10\a 11\a 12\a 13\a 14\a 15\a 16\a 17\a 18\a 19\a 20\a 21\a 22\a 23\a 24\a 25\a 26\a 27\a 28\a 29\a 30\a 31\a 32\a 33\a 34\a 35\a 36\a 37\a 38\a 39\a 40\a 41\a 42\a 43\a 44\a 45\a 46\a 47\a 48\a 49\a 50\a 51\a 52\a 53\a 54\a 55\a 56\a 57\a 58\a 59\a 60\a 61\a 62\a 63\a 64\a 65\a 66\a 67\a 68\a 69\a 70\a 71\a 72\a 73\a 74\a 75\a 76\a 77\a 78\a 79\a 80\a 81\a 82\a 83\a 84\a 85\a 86\a 87\a 88\a 89\a 90\a 91\a 92\a 93\a 94\a 95\a 96\a 97\a 98\a 99\a ";content:var(--tw-content);font-variant-numeric:tabular-nums;white-space:pre;text-align:end;direction:rtl;transition:all 1s cubic-bezier(1,0,0,1),width .2s ease-out .2s,opacity .2s ease-out .2s;position:absolute;overflow-x:clip}.countdown>:before{width:calc(1ch + var(--show-hundreds) * 1ch);top:calc(var(--first-digits) * -1em);opacity:var(--show-tens);inset-inline-end:0}.countdown>:after{width:1ch;top:calc(var(--value-ones) * -1em);inset-inline-start:0}.list{flex-direction:column;font-size:.875rem;display:flex}.list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:var(--color-base-content);position:absolute;bottom:0}@supports (color:color-mix(in lab, red, red)){:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)}}.input{cursor:text;border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.input{--size:calc(var(--size-field,.25rem) * 10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.input:where(input){display:inline-flex}.input :where(input){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-flex}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px;outline:2px solid #0000}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr}.input :where(input[type=date]){display:inline-flex}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.input:focus,.input:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem}}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input::-webkit-calendar-picker-indicator{position:absolute;inset-inline-end:.75em}.input:has(>input[type=date]) :where(input[type=date]){webkit-appearance:none;appearance:none;display:inline-flex}.input:has(>input[type=date]) input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute;inset-inline-end:.75em}.indicator{width:max-content;display:inline-flex;position:relative}.indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}.steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid}.steps .step:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}.steps .step>.step-icon,.steps .step:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}.steps .step:first-child:before{--tw-content:none;content:var(--tw-content)}.steps .step[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}.range{appearance:none;webkit-appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:currentColor}@supports (color:color-mix(in lab, red, red)){.range{--range-bg:color-mix(in oklab, currentColor 10%, #0000)}}.range{cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}.range::-moz-range-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;appearance:none;webkit-appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));position:relative;top:50%;transform:translateY(-50%)}@supports (color:color-mix(in lab, red, red)){.range::-webkit-slider-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));background-color:currentColor;position:relative;top:50%}@supports (color:color-mix(in lab, red, red)){.range::-moz-range-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range:disabled{cursor:not-allowed;opacity:.3}.select{border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;font-size:.875rem;display:inline-flex;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.select{border-color:var(--input-color);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.select{--size:calc(var(--size-field,.25rem) * 10)}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}[dir=rtl] .select::picker(select){translate:.5rem}[dir=rtl] .select select::picker(select){translate:.5rem}.select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px;outline:2px solid #0000}}.select select:not(:last-child){background-image:none;margin-inline-end:-1.375rem}.select:focus,.select:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.select:focus,.select:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}@supports (appearance:base-select){.select,.select select{appearance:base-select}:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) optgroup{padding-top:.5em}:is(.select,.select select) optgroup option:first-child{margin-top:.5em}:is(.select,.select select) option{border-radius:var(--radius-field);white-space:normal;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.select,.select select) option:not(:disabled):active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}.checkbox{border:var(--border) solid var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}.checkbox:indeterminate{background-color:var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox:indeterminate{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:none}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000))}}.radio{box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor)}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor}@media (prefers-reduced-motion:no-preference){.radio:checked,.radio[aria-checked=true]{animation:.2s ease-out radio}}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem;outline:.25rem solid}}.stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.stack>*{width:100%;height:100%}.stack>:nth-child(n+2){opacity:.7;width:100%}.stack>:nth-child(2){z-index:2;opacity:.9}.stack>:first-child{z-index:3;width:100%}.filter{flex-wrap:wrap;display:flex}.filter input[type=radio]{width:auto}.filter input{opacity:1;transition:margin .1s,opacity .3s,padding .3s,border-width .1s;overflow:hidden;scale:1}.filter input:not(:last-child){margin-inline-end:.25rem}.filter input.filter-reset{aspect-ratio:1}.filter input.filter-reset:after{--tw-content:"×";content:var(--tw-content)}.filter:not(:has(input:checked:not(.filter-reset))) .filter-reset,.filter:not(:has(input:checked:not(.filter-reset))) input[type=reset],.filter:has(input:checked:not(.filter-reset)) input:not(:checked,.filter-reset,input[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.label{white-space:nowrap;color:currentColor;align-items:center;gap:.375rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.label{color:color-mix(in oklab, currentcolor 60%, transparent)}}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex}.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid currentColor;margin-inline:-.75rem .75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid currentColor;margin-inline:.75rem -.75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:var(--color-base-content);width:.5rem;height:.5rem;display:inline-block}@supports (color:color-mix(in lab, red, red)){.status{background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.status{vertical-align:middle;color:#0000004d;background-position:50%;background-repeat:no-repeat}@supports (color:color-mix(in lab, red, red)){.status{color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.status{background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000)}}.tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.alert{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.link:focus{outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content)}}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui.l1.l2{.btn:disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn:disabled:not(.btn-link,.btn-ghost){box-shadow:none}.btn:disabled{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.btn[disabled]:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn[disabled]:not(.btn-link,.btn-ghost){box-shadow:none}.btn[disabled]{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.list .list-row:has(.list-col-grow:first-child){--list-grid-cols:1fr}.list .list-row:has(.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row>*{grid-row-start:1}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.checkbox:disabled,.radio:disabled{cursor:not-allowed;opacity:.2}.btn-active{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn-active{--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);isolation:isolate}:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4}:is(.stack,.stack.stack-bottom)>:nth-child(2){grid-area:2/2/5/5}:is(.stack,.stack.stack-bottom)>:first-child{grid-area:1/1/4/6}.stack.stack-top>*{grid-area:1/3/4/4}.stack.stack-top>:nth-child(2){grid-area:2/2/5/5}.stack.stack-top>:first-child{grid-area:3/1/6/6}.stack.stack-start>*{grid-area:3/1/4/4}.stack.stack-start>:nth-child(2){grid-area:2/2/5/5}.stack.stack-start>:first-child{grid-area:1/3/6/6}.stack.stack-end>*{grid-area:3/3/4/6}.stack.stack-end>:nth-child(2){grid-area:2/2/5/5}.stack.stack-end>:first-child{grid-area:1/1/6/4}.tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.tabs-box>.tab{border-radius:var(--radius-field);border-style:none}.tabs-box>.tab:focus-visible,.tabs-box>.tab:is(label:has(:checked:focus-visible)){outline-offset:2px}.tabs-box>.tab:focus-visible{z-index:1}.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px var(--color-neutral), 0 1px 6px -4px var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000)}}@media (forced-colors:active){.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){border:1px solid}}.tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.btn-square{width:var(--size);height:var(--size);padding-inline:0}.alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}}.countdown.countdown{line-height:1em}.visible{visibility:visible}.relative{position:relative}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;align-items:stretch;display:inline-flex}.join :where(.join-item){border-start-start-radius:var(--join-ss,0);border-start-end-radius:var(--join-se,0);border-end-end-radius:var(--join-ee,0);border-end-start-radius:var(--join-es,0)}.join :where(.join-item) *{--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>.join-item:where(:first-child),.join :first-child:not(:last-child) :where(.join-item){--join-ss:var(--radius-field);--join-se:0;--join-es:var(--radius-field);--join-ee:0}.join>.join-item:where(:last-child),.join :last-child:not(:first-child) :where(.join-item){--join-ss:0;--join-se:var(--radius-field);--join-es:0;--join-ee:var(--radius-field)}.join>.join-item:where(:only-child),.join :only-child :where(.join-item){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}.join>:where(:focus,:has(:focus)){z-index:1}@media (hover:hover){.join>:where(.btn:hover,:has(.btn:hover)){isolation:isolate}}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.my-4{margin-block:calc(var(--spacing) * 4)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-4{margin-left:calc(var(--spacing) * 4)}.alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200))}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-screen{height:100vh}.w-full{width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.border-none{--tw-border-style:none;border-style:none}.bg-base-200{background-color:var(--color-base-200)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.py-1{padding-block:calc(var(--spacing) * 1)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.break-all{word-break:break-all}.text-base-content{color:var(--color-base-content)}.text-gray-800{color:var(--color-gray-800)}.text-success{color:var(--color-success)}.no-underline{text-decoration-line:none}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-70{opacity:.7}@layer daisyui.l1{.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)):not(:disabled,[disabled],.btn-disabled){--btn-fg:var(--btn-color,currentColor);outline-color:currentColor}@media (hover:none){.btn-ghost:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color,currentColor);--btn-border:#0000;--btn-noise:none;outline-color:currentColor}}}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-base-300:hover{background-color:var(--color-base-300)}@layer daisyui.l1.l2.l3{.hover\:btn-error:hover{--btn-color:var(--color-error);--btn-fg:var(--color-error-content)}}}}.board{background:#334;border-radius:12px;gap:8px;padding:16px;display:flex}.column{border-radius:8px;flex-direction:column;gap:8px;padding:4px;display:flex}.column.clickable{cursor:pointer}.column.clickable:hover{background:#ffffff14}.cell{background:#556;border-radius:50%;width:48px;height:48px;transition:background .2s}.cell.red{background:#4a2a3a}.cell.yellow{background:#2a4545}.cell.red.active-turn{animation:1.5s ease-in-out infinite alternate glow-red}.cell.yellow.active-turn{animation:1.5s ease-in-out infinite alternate glow-yellow}.cell.winning{animation:.5s ease-in-out infinite alternate pulse}@keyframes glow-red{0%{box-shadow:0 0 4px #4a2a3a4d}to{box-shadow:0 0 12px #4a2a3ab3}}@keyframes glow-yellow{0%{box-shadow:0 0 4px #2a45454d}to{box-shadow:0 0 12px #2a4545b3}}@keyframes pulse{50%{opacity:.5}}.player-chip{background:#445;border-radius:50%;width:20px;height:20px}.player-chip.red{background:#4a2a3a}.player-chip.yellow{background:#2a4545}.snake-board{background:#556;border:3px solid #445;border-radius:8px;gap:0;display:inline-grid;overflow:hidden}.snake-row{display:contents}.snake-cell{background:#667;border:1px solid #00000014}.snake-cell.snake-head{border-radius:4px;animation:50ms ease-out head-pop}@keyframes head-pop{0%{transform:scale(.85)}to{transform:scale(1)}}.snake-cell.snake-food{background:#334;border-radius:50%;animation:1.2s ease-in-out infinite alternate food-pulse;box-shadow:0 0 3px #0003}@keyframes food-pulse{0%{transform:scale(.85);box-shadow:0 0 3px #0000001a}to{transform:scale(1);box-shadow:0 0 6px #0003}}.snake-cell.snake-dead{opacity:.35;filter:grayscale(.5);transition:opacity .4s ease-out}.snake-wrapper:focus{outline:none}.snake-game-area{justify-content:center;align-items:flex-start;gap:16px;display:flex}@media (max-width:768px){.snake-game-area{flex-direction:column;align-items:center}.snake-game-area .snake-chat{width:100%;max-width:480px}.snake-chat-history{height:150px}}.snake-chat{width:100%;max-width:480px}.snake-game-area .snake-chat{flex-shrink:0;width:260px;max-width:none}.snake-chat-history{background:#334;border-radius:8px 8px 0 0;flex-direction:column;gap:2px;height:300px;padding:8px;display:flex;overflow-y:auto}.snake-chat-msg{font-size:.85rem;line-height:1.3}.snake-chat-input{background:#445;border-radius:0 0 8px 8px;gap:0;display:flex;overflow:hidden}.snake-chat-input input{color:inherit;background:0 0;border:none;outline:none;flex:1;padding:6px 10px;font-size:.85rem}.snake-chat-input button{color:inherit;cursor:pointer;background:#556;border:none;padding:6px 14px;font-size:.85rem}.snake-chat-input button:hover{background:#667}.c4-game-area{justify-content:center;align-items:flex-start;gap:16px;display:flex}@media (max-width:768px){.c4-game-area{flex-direction:column;align-items:center}.c4-game-area .c4-chat{width:100%;max-width:480px}.c4-chat-history{height:150px}.board{gap:4px;padding:8px}.column{gap:4px;padding:2px}.cell{width:36px;height:36px}}.c4-chat{width:100%;max-width:480px}.c4-game-area .c4-chat{flex-shrink:0;width:260px;max-width:none}.c4-chat-history{background:#334;border-radius:8px 8px 0 0;flex-direction:column;gap:2px;height:300px;padding:8px;display:flex;overflow-y:auto}.c4-chat-msg{font-size:.85rem;line-height:1.3}.c4-chat-input{background:#445;border-radius:0 0 8px 8px;gap:0;display:flex;overflow:hidden}.c4-chat-input input{color:inherit;background:0 0;border:none;outline:none;flex:1;padding:6px 10px;font-size:.85rem}.c4-chat-input button{color:inherit;cursor:pointer;background:#556;border:none;padding:6px 14px;font-size:.85rem}.c4-chat-input button:hover{background:#667}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@keyframes dropdown{0%{opacity:0}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@keyframes menu{0%{opacity:0}}@keyframes progress{50%{background-position-x:-115%}}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
\ No newline at end of file
+@layer properties;
+@layer theme, base, components, utilities;
+@layer theme {
+ :root, :host {
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
+ monospace;
+ --color-gray-500: oklch(55.1% 0.027 264.364);
+ --color-gray-800: oklch(27.8% 0.033 256.848);
+ --color-black: #000;
+ --color-white: #fff;
+ --spacing: 0.25rem;
+ --container-sm: 24rem;
+ --container-md: 28rem;
+ --text-xs: 0.75rem;
+ --text-xs--line-height: calc(1 / 0.75);
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.875);
+ --text-lg: 1.125rem;
+ --text-lg--line-height: calc(1.75 / 1.125);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --font-weight-bold: 700;
+ --radius-lg: 0.5rem;
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: var(--font-sans);
+ --default-mono-font-family: var(--font-mono);
+ }
+}
+@layer base {
+ *, ::after, ::before, ::backdrop, ::file-selector-button {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid;
+ }
+ html, :host {
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ tab-size: 4;
+ font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
+ font-feature-settings: var(--default-font-feature-settings, normal);
+ font-variation-settings: var(--default-font-variation-settings, normal);
+ -webkit-tap-highlight-color: transparent;
+ }
+ hr {
+ height: 0;
+ color: inherit;
+ border-top-width: 1px;
+ }
+ abbr:where([title]) {
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+ }
+ h1, h2, h3, h4, h5, h6 {
+ font-size: inherit;
+ font-weight: inherit;
+ }
+ a {
+ color: inherit;
+ -webkit-text-decoration: inherit;
+ text-decoration: inherit;
+ }
+ b, strong {
+ font-weight: bolder;
+ }
+ code, kbd, samp, pre {
+ font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
+ font-feature-settings: var(--default-mono-font-feature-settings, normal);
+ font-variation-settings: var(--default-mono-font-variation-settings, normal);
+ font-size: 1em;
+ }
+ small {
+ font-size: 80%;
+ }
+ sub, sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ table {
+ text-indent: 0;
+ border-color: inherit;
+ border-collapse: collapse;
+ }
+ :-moz-focusring {
+ outline: auto;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ summary {
+ display: list-item;
+ }
+ ol, ul, menu {
+ list-style: none;
+ }
+ img, svg, video, canvas, audio, iframe, embed, object {
+ display: block;
+ vertical-align: middle;
+ }
+ img, video {
+ max-width: 100%;
+ height: auto;
+ }
+ button, input, select, optgroup, textarea, ::file-selector-button {
+ font: inherit;
+ font-feature-settings: inherit;
+ font-variation-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ border-radius: 0;
+ background-color: transparent;
+ opacity: 1;
+ }
+ :where(select:is([multiple], [size])) optgroup {
+ font-weight: bolder;
+ }
+ :where(select:is([multiple], [size])) optgroup option {
+ padding-inline-start: 20px;
+ }
+ ::file-selector-button {
+ margin-inline-end: 4px;
+ }
+ ::placeholder {
+ opacity: 1;
+ }
+ @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
+ ::placeholder {
+ color: currentcolor;
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, currentcolor 50%, transparent);
+ }
+ }
+ }
+ textarea {
+ resize: vertical;
+ }
+ ::-webkit-search-decoration {
+ -webkit-appearance: none;
+ }
+ ::-webkit-date-and-time-value {
+ min-height: 1lh;
+ text-align: inherit;
+ }
+ ::-webkit-datetime-edit {
+ display: inline-flex;
+ }
+ ::-webkit-datetime-edit-fields-wrapper {
+ padding: 0;
+ }
+ ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
+ padding-block: 0;
+ }
+ ::-webkit-calendar-picker-indicator {
+ line-height: 1;
+ }
+ :-moz-ui-invalid {
+ box-shadow: none;
+ }
+ button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
+ appearance: button;
+ }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [hidden]:where(:not([hidden='until-found'])) {
+ display: none !important;
+ }
+}
+@layer utilities {
+ .diff {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: grid;
+ width: 100%;
+ overflow: hidden;
+ webkit-user-select: none;
+ user-select: none;
+ grid-template-rows: 1fr 1.8rem 1fr;
+ direction: ltr;
+ container-type: inline-size;
+ grid-template-columns: auto 1fr;
+ &:focus-visible, &:has(.diff-item-1:focus-visible) {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ outline-color: var(--color-base-content);
+ }
+ &:focus-visible {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ outline-color: var(--color-base-content);
+ .diff-resizer {
+ min-width: 95cqi;
+ max-width: 95cqi;
+ }
+ }
+ &:has(.diff-item-1:focus-visible) {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ .diff-resizer {
+ min-width: 5cqi;
+ max-width: 5cqi;
+ }
+ }
+ @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
+ &:focus {
+ .diff-resizer {
+ min-width: 5cqi;
+ max-width: 5cqi;
+ }
+ }
+ &:has(.diff-item-1:focus) {
+ .diff-resizer {
+ min-width: 95cqi;
+ max-width: 95cqi;
+ }
+ }
+ }
+ }
+ }
+ .tab {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: inline-flex;
+ cursor: pointer;
+ appearance: none;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ webkit-user-select: none;
+ user-select: none;
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-base-content);
+ }
+ }
+ --tab-p: 0.75rem;
+ --tab-bg: var(--color-base-100);
+ --tab-border-color: var(--color-base-300);
+ --tab-radius-ss: 0;
+ --tab-radius-se: 0;
+ --tab-radius-es: 0;
+ --tab-radius-ee: 0;
+ --tab-order: 0;
+ --tab-radius-min: calc(0.75rem - var(--border));
+ --tab-radius-limit: min(var(--radius-field), var(--tab-radius-min));
+ --tab-radius-grad: #0000 calc(69% - var(--border)),
+ var(--tab-border-color) calc(69% - var(--border) + 0.25px),
+ var(--tab-border-color) 69%,
+ var(--tab-bg) calc(69% + 0.25px);
+ border-color: #0000;
+ order: var(--tab-order);
+ height: var(--tab-height);
+ font-size: 0.875rem;
+ padding-inline: var(--tab-p);
+ &:is(input[type="radio"]) {
+ min-width: fit-content;
+ &:after {
+ --tw-content: attr(aria-label);
+ content: var(--tw-content);
+ }
+ }
+ &:is(label) {
+ position: relative;
+ input {
+ position: absolute;
+ inset: calc(0.25rem * 0);
+ cursor: pointer;
+ appearance: none;
+ opacity: 0%;
+ }
+ }
+ &:checked, &:is(label:has(:checked)), &:is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]) {
+ & + .tab-content {
+ display: block;
+ }
+ }
+ &:not( :checked, label:has(:checked), :hover, .tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"] ) {
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
+ }
+ }
+ &:not(input):empty {
+ flex-grow: 1;
+ cursor: default;
+ }
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:focus-visible, &:is(label:has(:checked:focus-visible)) {
+ outline: 2px solid currentColor;
+ outline-offset: -5px;
+ }
+ &[disabled] {
+ pointer-events: none;
+ opacity: 40%;
+ }
+ }
+ }
+ .btn {
+ :where(&) {
+ @layer daisyui.l1.l2.l3 {
+ width: unset;
+ }
+ }
+ .prose :where(a&:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
+ text-decoration-line: none;
+ }
+ @layer daisyui.l1.l2.l3 {
+ display: inline-flex;
+ flex-shrink: 0;
+ cursor: pointer;
+ flex-wrap: nowrap;
+ align-items: center;
+ justify-content: center;
+ gap: calc(0.25rem * 1.5);
+ text-align: center;
+ vertical-align: middle;
+ outline-offset: 2px;
+ webkit-user-select: none;
+ user-select: none;
+ padding-inline: var(--btn-p);
+ color: var(--btn-fg);
+ --tw-prose-links: var(--btn-fg);
+ height: var(--size);
+ font-size: var(--fontsize, 0.875rem);
+ font-weight: 600;
+ outline-color: var(--btn-color, var(--color-base-content));
+ transition-property: color, background-color, border-color, box-shadow;
+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
+ transition-duration: 0.2s;
+ border-start-start-radius: var(--join-ss, var(--radius-field));
+ border-start-end-radius: var(--join-se, var(--radius-field));
+ border-end-start-radius: var(--join-es, var(--radius-field));
+ border-end-end-radius: var(--join-ee, var(--radius-field));
+ background-color: var(--btn-bg);
+ background-size: auto, calc(var(--noise) * 100%);
+ background-image: none, var(--btn-noise);
+ border-width: var(--border);
+ border-style: solid;
+ border-color: var(--btn-border);
+ text-shadow: 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 0.15));
+ touch-action: manipulation;
+ box-shadow: 0 0.5px 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);
+ --size: calc(var(--size-field, 0.25rem) * 10);
+ --btn-bg: var(--btn-color, var(--color-base-200));
+ --btn-fg: var(--color-base-content);
+ --btn-p: 1rem;
+ --btn-border: var(--btn-bg);
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-border: color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));
+ }
+ --btn-shadow: 0 3px 2px -2px var(--btn-bg),
+ 0 4px 3px -2px var(--btn-bg);
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-shadow: 0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000),
+ 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);
+ }
+ --btn-noise: var(--fx-noise);
+ @media (hover: hover) {
+ &:hover {
+ --btn-bg: var(--btn-color, var(--color-base-200));
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
+ }
+ }
+ }
+ &:focus-visible, &:has(:focus-visible) {
+ outline-width: 2px;
+ outline-style: solid;
+ isolation: isolate;
+ }
+ &:active:not(.btn-active) {
+ translate: 0 0.5px;
+ --btn-bg: var(--btn-color, var(--color-base-200));
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 5%);
+ }
+ --btn-border: var(--btn-color, var(--color-base-200));
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-border: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
+ }
+ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);
+ }
+ &:is(input[type="checkbox"], input[type="radio"]) {
+ appearance: none;
+ &[aria-label]::after {
+ --tw-content: attr(aria-label);
+ content: var(--tw-content);
+ }
+ }
+ &:where(input:checked:not(.filter .btn)) {
+ --btn-color: var(--color-primary);
+ --btn-fg: var(--color-primary-content);
+ isolation: isolate;
+ }
+ }
+ &:disabled {
+ @layer daisyui.l1.l2 {
+ &:not(.btn-link, .btn-ghost) {
+ background-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
+ }
+ box-shadow: none;
+ }
+ pointer-events: none;
+ --btn-border: #0000;
+ --btn-noise: none;
+ --btn-fg: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
+ }
+ }
+ }
+ &[disabled] {
+ @layer daisyui.l1.l2 {
+ &:not(.btn-link, .btn-ghost) {
+ background-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
+ }
+ box-shadow: none;
+ }
+ pointer-events: none;
+ --btn-border: #0000;
+ --btn-noise: none;
+ --btn-fg: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
+ }
+ }
+ }
+ }
+ .loading {
+ @layer daisyui.l1.l2.l3 {
+ pointer-events: none;
+ display: inline-block;
+ aspect-ratio: 1 / 1;
+ background-color: currentcolor;
+ vertical-align: middle;
+ width: calc(var(--size-selector, 0.25rem) * 6);
+ mask-size: 100%;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
+ }
+ }
+ .countdown {
+ &.countdown {
+ line-height: 1em;
+ }
+ @layer daisyui.l1.l2.l3 {
+ display: inline-flex;
+ & > * {
+ visibility: hidden;
+ position: relative;
+ display: inline-block;
+ overflow-y: clip;
+ transition: width 0.4s ease-out 0.2s;
+ height: 1em;
+ --value-v: calc(mod(max(0, var(--value)), 1000));
+ --value-hundreds: calc(round(to-zero, var(--value-v) / 100, 1));
+ --value-tens: calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));
+ --value-ones: calc(mod(var(--value-v), 100));
+ --show-hundreds: clamp(clamp(0, var(--digits, 1) - 2, 1), var(--value-hundreds), 1);
+ --show-tens: clamp(
+ clamp(0, var(--digits, 1) - 1, 1),
+ var(--value-tens) + var(--show-hundreds),
+ 1
+ );
+ --first-digits: calc(round(to-zero, var(--value-v) / 10, 1));
+ width: calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);
+ direction: ltr;
+ &:before, &:after {
+ visibility: visible;
+ position: absolute;
+ overflow-x: clip;
+ --tw-content: "00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";
+ content: var(--tw-content);
+ font-variant-numeric: tabular-nums;
+ white-space: pre;
+ text-align: end;
+ direction: rtl;
+ transition: all 1s cubic-bezier(1, 0, 0, 1), width 0.2s ease-out 0.2s, opacity 0.2s ease-out 0.2s;
+ }
+ &:before {
+ width: calc(1ch + var(--show-hundreds) * 1ch);
+ top: calc(var(--first-digits) * -1em);
+ inset-inline-end: 0;
+ opacity: var(--show-tens);
+ }
+ &:after {
+ width: 1ch;
+ top: calc(var(--value-ones) * -1em);
+ inset-inline-start: 0;
+ }
+ }
+ }
+ }
+ .visible {
+ visibility: visible;
+ }
+ .list {
+ @layer daisyui.l1.l2.l3 {
+ display: flex;
+ flex-direction: column;
+ font-size: 0.875rem;
+ .list-row {
+ --list-grid-cols: minmax(0, auto) 1fr;
+ position: relative;
+ display: grid;
+ grid-auto-flow: column;
+ gap: calc(0.25rem * 4);
+ border-radius: var(--radius-box);
+ padding: calc(0.25rem * 4);
+ word-break: break-word;
+ grid-template-columns: var(--list-grid-cols);
+ }
+ & > :not(:last-child) {
+ &.list-row, .list-row {
+ &:after {
+ content: "";
+ border-bottom: var(--border) solid;
+ inset-inline: var(--radius-box);
+ position: absolute;
+ bottom: calc(0.25rem * 0);
+ border-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
+ }
+ }
+ }
+ }
+ }
+ @layer daisyui.l1.l2 {
+ .list-row {
+ &:has(.list-col-grow:nth-child(1)) {
+ --list-grid-cols: 1fr;
+ }
+ &:has(.list-col-grow:nth-child(2)) {
+ --list-grid-cols: minmax(0, auto) 1fr;
+ }
+ &:has(.list-col-grow:nth-child(3)) {
+ --list-grid-cols: minmax(0, auto) minmax(0, auto) 1fr;
+ }
+ &:has(.list-col-grow:nth-child(4)) {
+ --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
+ }
+ &:has(.list-col-grow:nth-child(5)) {
+ --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr;
+ }
+ &:has(.list-col-grow:nth-child(6)) {
+ --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto)
+ minmax(0, auto) 1fr;
+ }
+ > * {
+ grid-row-start: 1;
+ }
+ }
+ }
+ }
+ .input {
+ @layer daisyui.l1.l2.l3 {
+ cursor: text;
+ border: var(--border) solid #0000;
+ position: relative;
+ display: inline-flex;
+ flex-shrink: 1;
+ appearance: none;
+ align-items: center;
+ gap: calc(0.25rem * 2);
+ background-color: var(--color-base-100);
+ padding-inline: calc(0.25rem * 3);
+ vertical-align: middle;
+ white-space: nowrap;
+ width: clamp(3rem, 20rem, 100%);
+ height: var(--size);
+ font-size: max(var(--font-size, 0.875rem), 0.875rem);
+ touch-action: manipulation;
+ border-start-start-radius: var(--join-ss, var(--radius-field));
+ border-start-end-radius: var(--join-se, var(--radius-field));
+ border-end-start-radius: var(--join-es, var(--radius-field));
+ border-end-end-radius: var(--join-ee, var(--radius-field));
+ border-color: var(--input-color);
+ box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ }
+ --size: calc(var(--size-field, 0.25rem) * 10);
+ --input-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
+ }
+ &:where(input) {
+ display: inline-flex;
+ }
+ :where(input) {
+ display: inline-flex;
+ height: 100%;
+ width: 100%;
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ &:focus, &:focus-within {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ }
+ :where(input[type="url"]), :where(input[type="email"]) {
+ direction: ltr;
+ }
+ :where(input[type="date"]) {
+ display: inline-flex;
+ }
+ &:focus, &:focus-within {
+ --input-color: var(--color-base-content);
+ box-shadow: 0 1px var(--input-color);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
+ }
+ outline: 2px solid var(--input-color);
+ outline-offset: 2px;
+ isolation: isolate;
+ }
+ @media (pointer: coarse) {
+ @supports (-webkit-touch-callout: none) {
+ &:focus, &:focus-within {
+ --font-size: 1rem;
+ }
+ }
+ }
+ &:has(> input[disabled]), &:is(:disabled, [disabled]), fieldset:disabled & {
+ cursor: not-allowed;
+ border-color: var(--color-base-200);
+ background-color: var(--color-base-200);
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
+ }
+ &::placeholder {
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ }
+ }
+ box-shadow: none;
+ }
+ &:has(> input[disabled]) > input[disabled] {
+ cursor: not-allowed;
+ }
+ &::-webkit-date-and-time-value {
+ text-align: inherit;
+ }
+ &[type="number"] {
+ &::-webkit-inner-spin-button {
+ margin-block: calc(0.25rem * -3);
+ margin-inline-end: calc(0.25rem * -3);
+ }
+ }
+ &::-webkit-calendar-picker-indicator {
+ position: absolute;
+ inset-inline-end: 0.75em;
+ }
+ &:has(> input[type="date"]) {
+ :where(input[type="date"]) {
+ display: inline-flex;
+ webkit-appearance: none;
+ appearance: none;
+ }
+ input[type="date"]::-webkit-calendar-picker-indicator {
+ position: absolute;
+ inset-inline-end: 0.75em;
+ width: 1em;
+ height: 1em;
+ cursor: pointer;
+ }
+ }
+ }
+ }
+ .indicator {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: inline-flex;
+ width: max-content;
+ :where(.indicator-item) {
+ z-index: 1;
+ position: absolute;
+ white-space: nowrap;
+ top: var(--indicator-t, 0);
+ bottom: var(--indicator-b, auto);
+ left: var(--indicator-s, auto);
+ right: var(--indicator-e, 0);
+ translate: var(--indicator-x, 50%) var(--indicator-y, -50%);
+ }
+ }
+ }
+ .steps {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-grid;
+ grid-auto-flow: column;
+ overflow: hidden;
+ overflow-x: auto;
+ counter-reset: step;
+ grid-auto-columns: 1fr;
+ .step {
+ display: grid;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ grid-template-columns: auto;
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ grid-template-rows: 40px 1fr;
+ place-items: center;
+ text-align: center;
+ min-width: 4rem;
+ --step-bg: var(--color-base-300);
+ --step-fg: var(--color-base-content);
+ &:before {
+ top: calc(0.25rem * 0);
+ grid-column-start: 1;
+ grid-row-start: 1;
+ height: calc(0.25rem * 2);
+ width: 100%;
+ border: 1px solid;
+ color: var(--step-bg);
+ background-color: var(--step-bg);
+ content: "";
+ margin-inline-start: -100%;
+ }
+ > .step-icon, &:not(:has(.step-icon)):after {
+ --tw-content: counter(step);
+ content: var(--tw-content);
+ counter-increment: step;
+ z-index: 1;
+ color: var(--step-fg);
+ background-color: var(--step-bg);
+ border: 1px solid var(--step-bg);
+ position: relative;
+ grid-column-start: 1;
+ grid-row-start: 1;
+ display: grid;
+ height: calc(0.25rem * 8);
+ width: calc(0.25rem * 8);
+ place-items: center;
+ place-self: center;
+ border-radius: calc(infinity * 1px);
+ }
+ &:first-child:before {
+ --tw-content: none;
+ content: var(--tw-content);
+ }
+ &[data-content]:after {
+ --tw-content: attr(data-content);
+ content: var(--tw-content);
+ }
+ }
+ }
+ @layer daisyui.l1.l2 {
+ .step-neutral {
+ + .step-neutral:before, &:after, > .step-icon {
+ --step-bg: var(--color-neutral);
+ --step-fg: var(--color-neutral-content);
+ }
+ }
+ .step-primary {
+ + .step-primary:before, &:after, > .step-icon {
+ --step-bg: var(--color-primary);
+ --step-fg: var(--color-primary-content);
+ }
+ }
+ .step-secondary {
+ + .step-secondary:before, &:after, > .step-icon {
+ --step-bg: var(--color-secondary);
+ --step-fg: var(--color-secondary-content);
+ }
+ }
+ .step-accent {
+ + .step-accent:before, &:after, > .step-icon {
+ --step-bg: var(--color-accent);
+ --step-fg: var(--color-accent-content);
+ }
+ }
+ .step-info {
+ + .step-info:before, &:after, > .step-icon {
+ --step-bg: var(--color-info);
+ --step-fg: var(--color-info-content);
+ }
+ }
+ .step-success {
+ + .step-success:before, &:after, > .step-icon {
+ --step-bg: var(--color-success);
+ --step-fg: var(--color-success-content);
+ }
+ }
+ .step-warning {
+ + .step-warning:before, &:after, > .step-icon {
+ --step-bg: var(--color-warning);
+ --step-fg: var(--color-warning-content);
+ }
+ }
+ .step-error {
+ + .step-error:before, &:after, > .step-icon {
+ --step-bg: var(--color-error);
+ --step-fg: var(--color-error-content);
+ }
+ }
+ }
+ }
+ .range {
+ @layer daisyui.l1.l2.l3 {
+ appearance: none;
+ webkit-appearance: none;
+ --range-thumb: var(--color-base-100);
+ --range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
+ --range-progress: currentColor;
+ --range-fill: 1;
+ --range-p: 0.25rem;
+ --range-bg: currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ --range-bg: color-mix(in oklab, currentColor 10%, #0000);
+ }
+ cursor: pointer;
+ overflow: hidden;
+ background-color: transparent;
+ vertical-align: middle;
+ width: clamp(3rem, 20rem, 100%);
+ --radius-selector-max: calc(
+ var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
+ );
+ border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
+ border: none;
+ height: var(--range-thumb-size);
+ [dir="rtl"] & {
+ --range-dir: -1;
+ }
+ &:focus {
+ outline: none;
+ }
+ &:focus-visible {
+ outline: 2px solid;
+ outline-offset: 2px;
+ }
+ &::-webkit-slider-runnable-track {
+ width: 100%;
+ background-color: var(--range-bg);
+ border-radius: var(--radius-selector);
+ height: calc(var(--range-thumb-size) * 0.5);
+ }
+ @media (forced-colors: active) {
+ &::-webkit-slider-runnable-track {
+ border: 1px solid;
+ }
+ }
+ @media (forced-colors: active) {
+ &::-moz-range-track {
+ border: 1px solid;
+ }
+ }
+ &::-webkit-slider-thumb {
+ position: relative;
+ box-sizing: border-box;
+ border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
+ background-color: var(--range-thumb);
+ height: var(--range-thumb-size);
+ width: var(--range-thumb-size);
+ border: var(--range-p) solid;
+ appearance: none;
+ webkit-appearance: none;
+ top: 50%;
+ color: var(--range-progress);
+ transform: translateY(-50%);
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
+ }
+ }
+ &::-moz-range-track {
+ width: 100%;
+ background-color: var(--range-bg);
+ border-radius: var(--radius-selector);
+ height: calc(var(--range-thumb-size) * 0.5);
+ }
+ &::-moz-range-thumb {
+ position: relative;
+ box-sizing: border-box;
+ border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
+ background-color: currentColor;
+ height: var(--range-thumb-size);
+ width: var(--range-thumb-size);
+ border: var(--range-p) solid;
+ top: 50%;
+ color: var(--range-progress);
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
+ }
+ }
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 30%;
+ }
+ }
+ }
+ .select {
+ @layer daisyui.l1.l2.l3 {
+ border: var(--border) solid #0000;
+ position: relative;
+ display: inline-flex;
+ flex-shrink: 1;
+ appearance: none;
+ align-items: center;
+ gap: calc(0.25rem * 1.5);
+ background-color: var(--color-base-100);
+ padding-inline-start: calc(0.25rem * 3);
+ padding-inline-end: calc(0.25rem * 7);
+ vertical-align: middle;
+ width: clamp(3rem, 20rem, 100%);
+ height: var(--size);
+ font-size: 0.875rem;
+ touch-action: manipulation;
+ border-start-start-radius: var(--join-ss, var(--radius-field));
+ border-start-end-radius: var(--join-se, var(--radius-field));
+ border-end-start-radius: var(--join-es, var(--radius-field));
+ border-end-end-radius: var(--join-ee, var(--radius-field));
+ background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%);
+ background-position: calc(100% - 20px) calc(1px + 50%), calc(100% - 16.1px) calc(1px + 50%);
+ background-size: 4px 4px, 4px 4px;
+ background-repeat: no-repeat;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ }
+ border-color: var(--input-color);
+ --input-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
+ }
+ --size: calc(var(--size-field, 0.25rem) * 10);
+ [dir="rtl"] & {
+ background-position: calc(0% + 12px) calc(1px + 50%), calc(0% + 16px) calc(1px + 50%);
+ &::picker(select), select::picker(select) {
+ translate: 0.5rem 0;
+ }
+ }
+ &[multiple] {
+ height: auto;
+ overflow: auto;
+ padding-block: calc(0.25rem * 3);
+ padding-inline-end: calc(0.25rem * 3);
+ background-image: none;
+ }
+ select {
+ margin-inline-start: calc(0.25rem * -3);
+ margin-inline-end: calc(0.25rem * -7);
+ width: calc(100% + 2.75rem);
+ appearance: none;
+ padding-inline-start: calc(0.25rem * 3);
+ padding-inline-end: calc(0.25rem * 7);
+ height: calc(100% - calc(var(--border) * 2));
+ align-items: center;
+ background: inherit;
+ border-radius: inherit;
+ border-style: none;
+ &:focus, &:focus-within {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:not(:last-child) {
+ margin-inline-end: calc(0.25rem * -5.5);
+ background-image: none;
+ }
+ }
+ &:focus, &:focus-within {
+ --input-color: var(--color-base-content);
+ box-shadow: 0 1px var(--input-color);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
+ }
+ outline: 2px solid var(--input-color);
+ outline-offset: 2px;
+ isolation: isolate;
+ }
+ &:has(> select[disabled]), &:is(:disabled, [disabled]), fieldset:disabled & {
+ cursor: not-allowed;
+ border-color: var(--color-base-200);
+ background-color: var(--color-base-200);
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
+ }
+ &::placeholder {
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ }
+ }
+ }
+ &:has(> select[disabled]) > select[disabled] {
+ cursor: not-allowed;
+ }
+ &, & select {
+ @supports (appearance: base-select) {
+ appearance: base-select;
+ }
+ @supports (appearance: base-select) {
+ &::picker(select) {
+ appearance: base-select;
+ }
+ }
+ &::picker(select) {
+ color: inherit;
+ max-height: min(24rem, 70dvh);
+ margin-inline: 0.5rem;
+ translate: -0.5rem 0;
+ border: var(--border) solid var(--color-base-200);
+ margin-block: calc(0.25rem * 2);
+ border-radius: var(--radius-box);
+ padding: calc(0.25rem * 2);
+ background-color: inherit;
+ box-shadow: 0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/0.2);
+ box-shadow: 0 20px 25px -5px rgb(0 0 0 / calc(var(--depth) * 0.1)), 0 8px 10px -6px rgb(0 0 0 / calc(var(--depth) * 0.1));
+ }
+ &::picker-icon {
+ display: none;
+ }
+ optgroup {
+ padding-top: 0.5em;
+ option {
+ &:nth-child(1) {
+ margin-top: 0.5em;
+ }
+ }
+ }
+ option {
+ border-radius: var(--radius-field);
+ padding-inline: calc(0.25rem * 3);
+ padding-block: calc(0.25rem * 1.5);
+ transition-property: color, background-color;
+ transition-duration: 0.2s;
+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
+ white-space: normal;
+ &:not(:disabled) {
+ &:hover, &:focus-visible {
+ cursor: pointer;
+ background-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
+ }
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:active {
+ background-color: var(--color-neutral);
+ color: var(--color-neutral-content);
+ box-shadow: 0 2px calc(var(--depth) * 3px) -2px var(--color-neutral);
+ }
+ }
+ }
+ }
+ }
+ }
+ .checkbox {
+ @layer daisyui.l1.l2.l3 {
+ border: var(--border) solid var(--input-color, var(--color-base-content));
+ @supports (color: color-mix(in lab, red, red)) {
+ border: var(--border) solid var(--input-color, color-mix(in oklab, var(--color-base-content) 20%, #0000));
+ }
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ cursor: pointer;
+ appearance: none;
+ border-radius: var(--radius-selector);
+ padding: calc(0.25rem * 1);
+ vertical-align: middle;
+ color: var(--color-base-content);
+ box-shadow: 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0 #0000 inset, 0 0 #0000;
+ transition: background-color 0.2s, box-shadow 0.2s;
+ --size: calc(var(--size-selector, 0.25rem) * 6);
+ width: var(--size);
+ height: var(--size);
+ background-size: auto, calc(var(--noise) * 100%);
+ background-image: none, var(--fx-noise);
+ &:before {
+ --tw-content: "";
+ content: var(--tw-content);
+ display: block;
+ width: 100%;
+ height: 100%;
+ rotate: 45deg;
+ background-color: currentcolor;
+ opacity: 0%;
+ transition: clip-path 0.3s, opacity 0.1s, rotate 0.3s, translate 0.3s;
+ transition-delay: 0.1s;
+ clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 70% 80%, 70% 100%);
+ box-shadow: 0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ font-size: 1rem;
+ line-height: 0.75;
+ }
+ &:focus-visible {
+ outline: 2px solid var(--input-color, currentColor);
+ outline-offset: 2px;
+ }
+ &:checked, &[aria-checked="true"] {
+ background-color: var(--input-color, #0000);
+ box-shadow: 0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1));
+ &:before {
+ clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 0%, 70% 0%, 70% 100%);
+ opacity: 100%;
+ }
+ @media (forced-colors: active) {
+ &:before {
+ rotate: 0deg;
+ background-color: transparent;
+ --tw-content: "✔︎";
+ clip-path: none;
+ }
+ }
+ @media print {
+ &:before {
+ rotate: 0deg;
+ background-color: transparent;
+ --tw-content: "✔︎";
+ clip-path: none;
+ }
+ }
+ }
+ &:indeterminate {
+ background-color: var( --input-color, var(--color-base-content) );
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: var( --input-color, color-mix(in oklab, var(--color-base-content) 20%, #0000) );
+ }
+ &:before {
+ rotate: 0deg;
+ opacity: 100%;
+ translate: 0 -35%;
+ clip-path: polygon(20% 100%, 20% 80%, 50% 80%, 50% 80%, 80% 80%, 80% 100%);
+ }
+ }
+ }
+ &:disabled {
+ @layer daisyui.l1.l2 {
+ cursor: not-allowed;
+ opacity: 20%;
+ }
+ }
+ }
+ .radio {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ cursor: pointer;
+ appearance: none;
+ border-radius: calc(infinity * 1px);
+ padding: calc(0.25rem * 1);
+ vertical-align: middle;
+ border: var(--border) solid var(--input-color, currentColor);
+ @supports (color: color-mix(in lab, red, red)) {
+ border: var(--border) solid var(--input-color, color-mix(in srgb, currentColor 20%, #0000));
+ }
+ box-shadow: 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset;
+ --size: calc(var(--size-selector, 0.25rem) * 6);
+ width: var(--size);
+ height: var(--size);
+ color: var(--input-color, currentColor);
+ &:before {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: calc(infinity * 1px);
+ --tw-content: "";
+ content: var(--tw-content);
+ background-size: auto, calc(var(--noise) * 100%);
+ background-image: none, var(--fx-noise);
+ }
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ }
+ &:checked, &[aria-checked="true"] {
+ border-color: currentcolor;
+ background-color: var(--color-base-100);
+ @media (prefers-reduced-motion: no-preference) {
+ animation: radio 0.2s ease-out;
+ }
+ &:before {
+ background-color: currentcolor;
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * 0.1));
+ }
+ @media (forced-colors: active) {
+ &:before {
+ outline-style: var(--tw-outline-style);
+ outline-width: 1px;
+ outline-offset: calc(1px * -1);
+ }
+ }
+ @media print {
+ &:before {
+ outline: 0.25rem solid;
+ outline-offset: -1rem;
+ }
+ }
+ }
+ }
+ &:disabled {
+ @layer daisyui.l1.l2 {
+ cursor: not-allowed;
+ opacity: 20%;
+ }
+ }
+ }
+ .fixed {
+ position: fixed;
+ }
+ .relative {
+ position: relative;
+ }
+ .static {
+ position: static;
+ }
+ .start {
+ inset-inline-start: var(--spacing);
+ }
+ .end {
+ inset-inline-end: var(--spacing);
+ }
+ .top-2 {
+ top: calc(var(--spacing) * 2);
+ }
+ .right-2 {
+ right: calc(var(--spacing) * 2);
+ }
+ .bottom-1 {
+ bottom: calc(var(--spacing) * 1);
+ }
+ .join {
+ display: inline-flex;
+ align-items: stretch;
+ --join-ss: 0;
+ --join-se: 0;
+ --join-es: 0;
+ --join-ee: 0;
+ :where(.join-item) {
+ border-start-start-radius: var(--join-ss, 0);
+ border-start-end-radius: var(--join-se, 0);
+ border-end-start-radius: var(--join-es, 0);
+ border-end-end-radius: var(--join-ee, 0);
+ * {
+ --join-ss: var(--radius-field);
+ --join-se: var(--radius-field);
+ --join-es: var(--radius-field);
+ --join-ee: var(--radius-field);
+ }
+ }
+ > .join-item:where(:first-child) {
+ --join-ss: var(--radius-field);
+ --join-se: 0;
+ --join-es: var(--radius-field);
+ --join-ee: 0;
+ }
+ :first-child:not(:last-child) {
+ :where(.join-item) {
+ --join-ss: var(--radius-field);
+ --join-se: 0;
+ --join-es: var(--radius-field);
+ --join-ee: 0;
+ }
+ }
+ > .join-item:where(:last-child) {
+ --join-ss: 0;
+ --join-se: var(--radius-field);
+ --join-es: 0;
+ --join-ee: var(--radius-field);
+ }
+ :last-child:not(:first-child) {
+ :where(.join-item) {
+ --join-ss: 0;
+ --join-se: var(--radius-field);
+ --join-es: 0;
+ --join-ee: var(--radius-field);
+ }
+ }
+ > .join-item:where(:only-child) {
+ --join-ss: var(--radius-field);
+ --join-se: var(--radius-field);
+ --join-es: var(--radius-field);
+ --join-ee: var(--radius-field);
+ }
+ :only-child {
+ :where(.join-item) {
+ --join-ss: var(--radius-field);
+ --join-se: var(--radius-field);
+ --join-es: var(--radius-field);
+ --join-ee: var(--radius-field);
+ }
+ }
+ > :where(:focus, :has(:focus)) {
+ z-index: 1;
+ }
+ @media (hover: hover) {
+ > :where(.btn:hover, :has(.btn:hover)) {
+ isolation: isolate;
+ }
+ }
+ }
+ .btn-active {
+ @layer daisyui.l1.l2 {
+ --btn-bg: var(--btn-color, var(--color-base-200));
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
+ }
+ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);
+ isolation: isolate;
+ }
+ }
+ .stack {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-grid;
+ grid-template-columns: 3px 4px 1fr 4px 3px;
+ grid-template-rows: 3px 4px 1fr 4px 3px;
+ & > * {
+ height: 100%;
+ width: 100%;
+ &:nth-child(n + 2) {
+ width: 100%;
+ opacity: 70%;
+ }
+ &:nth-child(2) {
+ z-index: 2;
+ opacity: 90%;
+ }
+ &:nth-child(1) {
+ z-index: 3;
+ width: 100%;
+ }
+ }
+ }
+ @layer daisyui.l1.l2 {
+ &, &.stack-bottom {
+ > * {
+ grid-column: 3 / 4;
+ grid-row: 3 / 6;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 1 / 6;
+ grid-row: 1 / 4;
+ }
+ }
+ }
+ &.stack-top {
+ > * {
+ grid-column: 3 / 4;
+ grid-row: 1 / 4;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 1 / 6;
+ grid-row: 3 / 6;
+ }
+ }
+ }
+ &.stack-start {
+ > * {
+ grid-column: 1 / 4;
+ grid-row: 3 / 4;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 3 / 6;
+ grid-row: 1 / 6;
+ }
+ }
+ }
+ &.stack-end {
+ > * {
+ grid-column: 3 / 6;
+ grid-row: 3 / 4;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 1 / 4;
+ grid-row: 1 / 6;
+ }
+ }
+ }
+ }
+ }
+ .tabs-box {
+ @layer daisyui.l1.l2 {
+ background-color: var(--color-base-200);
+ padding: calc(0.25rem * 1);
+ --tabs-box-radius: calc(3 * var(--radius-field));
+ border-radius: calc( min(var(--tab-height) / 2, var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) );
+ box-shadow: 0 -0.5px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 0.5px oklch(0% 0 0 / calc(var(--depth) * 0.05)) inset;
+ > .tab {
+ border-radius: var(--radius-field);
+ border-style: none;
+ &:focus-visible, &:is(label:has(:checked:focus-visible)) {
+ outline-offset: 2px;
+ }
+ &:focus-visible {
+ z-index: 1;
+ }
+ }
+ > :is(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"]):not( .tab-disabled, [disabled] ), > :is(input:checked), > :is(label:has(:checked)) {
+ background-color: var(--tab-bg, var(--color-base-100));
+ box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px var(--color-neutral), 0 1px 6px -4px var(--color-neutral);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
+ }
+ @media (forced-colors: active) {
+ border: 1px solid;
+ }
+ }
+ > .tab-content {
+ margin-top: calc(0.25rem * 1);
+ height: calc(100% - var(--tab-height) + var(--border) - 0.5rem);
+ border-radius: calc( min(var(--tab-height) / 2, var(--radius-field)) + min(0.25rem, var(--tabs-box-radius)) - var(--border) );
+ }
+ }
+ }
+ .filter {
+ @layer daisyui.l1.l2.l3 {
+ display: flex;
+ flex-wrap: wrap;
+ input[type="radio"] {
+ width: auto;
+ }
+ input {
+ overflow: hidden;
+ opacity: 100%;
+ scale: 1;
+ transition: margin 0.1s, opacity 0.3s, padding 0.3s, border-width 0.1s;
+ &:not(:last-child) {
+ margin-inline-end: calc(0.25rem * 1);
+ }
+ &.filter-reset {
+ aspect-ratio: 1 / 1;
+ &::after {
+ --tw-content: "×";
+ content: var(--tw-content);
+ }
+ }
+ }
+ &:not(:has(input:checked:not(.filter-reset))) {
+ .filter-reset, input[type="reset"] {
+ scale: 0;
+ border-width: 0;
+ margin-inline: calc(0.25rem * 0);
+ width: calc(0.25rem * 0);
+ padding-inline: calc(0.25rem * 0);
+ opacity: 0%;
+ }
+ }
+ &:has(input:checked:not(.filter-reset)) {
+ input:not(:checked, .filter-reset, input[type="reset"]) {
+ scale: 0;
+ border-width: 0;
+ margin-inline: calc(0.25rem * 0);
+ width: calc(0.25rem * 0);
+ padding-inline: calc(0.25rem * 0);
+ opacity: 0%;
+ }
+ }
+ }
+ }
+ .mx-auto {
+ margin-inline: auto;
+ }
+ .my-2 {
+ margin-block: calc(var(--spacing) * 2);
+ }
+ .my-4 {
+ margin-block: calc(var(--spacing) * 4);
+ }
+ .label {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-flex;
+ align-items: center;
+ gap: calc(0.25rem * 1.5);
+ white-space: nowrap;
+ color: currentcolor;
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, currentcolor 60%, transparent);
+ }
+ &:has(input) {
+ cursor: pointer;
+ }
+ &:is(.input > *, .select > *) {
+ display: flex;
+ height: calc(100% - 0.5rem);
+ align-items: center;
+ padding-inline: calc(0.25rem * 3);
+ white-space: nowrap;
+ font-size: inherit;
+ &:first-child {
+ margin-inline-start: calc(0.25rem * -3);
+ margin-inline-end: calc(0.25rem * 3);
+ border-inline-end: var(--border) solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border-inline-end: var(--border) solid color-mix(in oklab, currentColor 10%, #0000);
+ }
+ }
+ &:last-child {
+ margin-inline-start: calc(0.25rem * 3);
+ margin-inline-end: calc(0.25rem * -3);
+ border-inline-start: var(--border) solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border-inline-start: var(--border) solid color-mix(in oklab, currentColor 10%, #0000);
+ }
+ }
+ }
+ }
+ }
+ .mt-2 {
+ margin-top: calc(var(--spacing) * 2);
+ }
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
+ .mt-8 {
+ margin-top: calc(var(--spacing) * 8);
+ }
+ .mb-2 {
+ margin-bottom: calc(var(--spacing) * 2);
+ }
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-6 {
+ margin-bottom: calc(var(--spacing) * 6);
+ }
+ .ml-4 {
+ margin-left: calc(var(--spacing) * 4);
+ }
+ .status {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-block;
+ aspect-ratio: 1 / 1;
+ width: calc(0.25rem * 2);
+ height: calc(0.25rem * 2);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ }
+ background-position: center;
+ background-repeat: no-repeat;
+ vertical-align: middle;
+ color: color-mix(in srgb, #000 30%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-black) 30%, transparent);
+ }
+ background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), #0000 );
+ box-shadow: 0 2px 3px -1px currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);
+ }
+ }
+ }
+ .tabs {
+ @layer daisyui.l1.l2.l3 {
+ display: flex;
+ flex-wrap: wrap;
+ --tabs-height: auto;
+ --tabs-direction: row;
+ --tab-height: calc(var(--size-field, 0.25rem) * 10);
+ height: var(--tabs-height);
+ flex-direction: var(--tabs-direction);
+ }
+ }
+ .alert {
+ border-width: var(--border);
+ border-color: var(--alert-border-color, var(--color-base-200));
+ @layer daisyui.l1.l2.l3 {
+ border-style: solid;
+ --alert-border-color: var(--color-base-200);
+ display: grid;
+ align-items: center;
+ gap: calc(0.25rem * 4);
+ border-radius: var(--radius-box);
+ padding-inline: calc(0.25rem * 4);
+ padding-block: calc(0.25rem * 3);
+ color: var(--color-base-content);
+ background-color: var(--alert-color, var(--color-base-200));
+ justify-content: start;
+ justify-items: start;
+ grid-auto-flow: column;
+ grid-template-columns: auto;
+ text-align: start;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ background-size: auto, calc(var(--noise) * 100%);
+ background-image: none, var(--fx-noise);
+ box-shadow: 0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * 0.08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * 0.08));
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * 0.08)) inset, 0 1px color-mix( in oklab, color-mix(in oklab, #000 20%, var(--alert-color, var(--color-base-200))) calc(var(--depth) * 20%), #0000 ), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * 0.08));
+ }
+ &:has(:nth-child(2)) {
+ grid-template-columns: auto minmax(auto, 1fr);
+ }
+ }
+ }
+ .fieldset {
+ @layer daisyui.l1.l2.l3 {
+ display: grid;
+ gap: calc(0.25rem * 1.5);
+ padding-block: calc(0.25rem * 1);
+ font-size: 0.75rem;
+ grid-template-columns: 1fr;
+ grid-auto-rows: max-content;
+ }
+ }
+ .chat {
+ @layer daisyui.l1.l2.l3 {
+ display: grid;
+ grid-auto-rows: min-content;
+ column-gap: calc(0.25rem * 3);
+ padding-block: calc(0.25rem * 1);
+ --mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
+ }
+ }
+ .block {
+ display: block;
+ }
+ .contents {
+ display: contents;
+ }
+ .flex {
+ display: flex;
+ }
+ .grid {
+ display: grid;
+ }
+ .hidden {
+ display: none;
+ }
+ .inline {
+ display: inline;
+ }
+ .btn-square {
+ @layer daisyui.l1.l2 {
+ padding-inline: calc(0.25rem * 0);
+ width: var(--size);
+ height: var(--size);
+ }
+ }
+ .status-sm {
+ @layer daisyui.l1.l2 {
+ width: calc(0.25rem * 1);
+ height: calc(0.25rem * 1);
+ }
+ }
+ .status-xs {
+ @layer daisyui.l1.l2 {
+ width: calc(0.25rem * 0.5);
+ height: calc(0.25rem * 0.5);
+ }
+ }
+ .h-screen {
+ height: 100vh;
+ }
+ .w-full {
+ width: 100%;
+ }
+ .max-w-md {
+ max-width: var(--container-md);
+ }
+ .max-w-sm {
+ max-width: var(--container-sm);
+ }
+ .flex-1 {
+ flex: 1;
+ }
+ .link {
+ @layer daisyui.l1.l2.l3 {
+ cursor: pointer;
+ text-decoration-line: underline;
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+ }
+ }
+ }
+ .flex-col {
+ flex-direction: column;
+ }
+ .flex-wrap {
+ flex-wrap: wrap;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .justify-between {
+ justify-content: space-between;
+ }
+ .justify-center {
+ justify-content: center;
+ }
+ .gap-1 {
+ gap: calc(var(--spacing) * 1);
+ }
+ .gap-1\.5 {
+ gap: calc(var(--spacing) * 1.5);
+ }
+ .gap-2 {
+ gap: calc(var(--spacing) * 2);
+ }
+ .gap-4 {
+ gap: calc(var(--spacing) * 4);
+ }
+ .gap-8 {
+ gap: calc(var(--spacing) * 8);
+ }
+ .rounded {
+ border-radius: 0.25rem;
+ }
+ .rounded-lg {
+ border-radius: var(--radius-lg);
+ }
+ .border-none {
+ --tw-border-style: none;
+ border-style: none;
+ }
+ .status-error {
+ @layer daisyui.l1.l2 {
+ background-color: var(--color-error);
+ color: var(--color-error);
+ }
+ }
+ .status-success {
+ @layer daisyui.l1.l2 {
+ background-color: var(--color-success);
+ color: var(--color-success);
+ }
+ }
+ .bg-base-200 {
+ background-color: var(--color-base-200);
+ }
+ .bg-white {
+ background-color: var(--color-white);
+ }
+ .p-2 {
+ padding: calc(var(--spacing) * 2);
+ }
+ .p-3 {
+ padding: calc(var(--spacing) * 3);
+ }
+ .p-4 {
+ padding: calc(var(--spacing) * 4);
+ }
+ .px-2 {
+ padding-inline: calc(var(--spacing) * 2);
+ }
+ .py-1 {
+ padding-block: calc(var(--spacing) * 1);
+ }
+ .text-center {
+ text-align: center;
+ }
+ .text-left {
+ text-align: left;
+ }
+ .font-mono {
+ font-family: var(--font-mono);
+ }
+ .text-3xl {
+ font-size: var(--text-3xl);
+ line-height: var(--tw-leading, var(--text-3xl--line-height));
+ }
+ .text-lg {
+ font-size: var(--text-lg);
+ line-height: var(--tw-leading, var(--text-lg--line-height));
+ }
+ .text-sm {
+ font-size: var(--text-sm);
+ line-height: var(--tw-leading, var(--text-sm--line-height));
+ }
+ .text-xl {
+ font-size: var(--text-xl);
+ line-height: var(--tw-leading, var(--text-xl--line-height));
+ }
+ .text-xs {
+ font-size: var(--text-xs);
+ line-height: var(--tw-leading, var(--text-xs--line-height));
+ }
+ .font-bold {
+ --tw-font-weight: var(--font-weight-bold);
+ font-weight: var(--font-weight-bold);
+ }
+ .break-all {
+ word-break: break-all;
+ }
+ .alert-error {
+ @layer daisyui.l1.l2 {
+ color: var(--color-error-content);
+ --alert-border-color: var(--color-error);
+ --alert-color: var(--color-error);
+ }
+ }
+ .alert-info {
+ @layer daisyui.l1.l2 {
+ color: var(--color-info-content);
+ --alert-border-color: var(--color-info);
+ --alert-color: var(--color-info);
+ }
+ }
+ .alert-success {
+ @layer daisyui.l1.l2 {
+ color: var(--color-success-content);
+ --alert-border-color: var(--color-success);
+ --alert-color: var(--color-success);
+ }
+ }
+ .alert-warning {
+ @layer daisyui.l1.l2 {
+ color: var(--color-warning-content);
+ --alert-border-color: var(--color-warning);
+ --alert-color: var(--color-warning);
+ }
+ }
+ .text-base-content {
+ color: var(--color-base-content);
+ }
+ .text-gray-500 {
+ color: var(--color-gray-500);
+ }
+ .text-gray-800 {
+ color: var(--color-gray-800);
+ }
+ .text-success {
+ color: var(--color-success);
+ }
+ .no-underline {
+ text-decoration-line: none;
+ }
+ .opacity-40 {
+ opacity: 40%;
+ }
+ .opacity-60 {
+ opacity: 60%;
+ }
+ .opacity-70 {
+ opacity: 70%;
+ }
+ .btn-ghost {
+ @layer daisyui.l1 {
+ &:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
+ --btn-shadow: "";
+ --btn-bg: #0000;
+ --btn-border: #0000;
+ --btn-noise: none;
+ &:not(:disabled, [disabled], .btn-disabled) {
+ outline-color: currentcolor;
+ --btn-fg: var(--btn-color, currentColor);
+ }
+ }
+ @media (hover: none) {
+ &:not(.btn-active, :active, :focus-visible, input:checked:not(.filter .btn)):hover {
+ outline-color: currentcolor;
+ --btn-shadow: "";
+ --btn-bg: #0000;
+ --btn-fg: var(--btn-color, currentColor);
+ --btn-border: #0000;
+ --btn-noise: none;
+ }
+ }
+ }
+ }
+ .filter {
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
+ .transition-colors {
+ transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
+ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+ transition-duration: var(--tw-duration, var(--default-transition-duration));
+ }
+ .btn-sm {
+ @layer daisyui.l1.l2 {
+ --fontsize: 0.75rem;
+ --btn-p: 0.75rem;
+ --size: calc(var(--size-field, 0.25rem) * 8);
+ }
+ }
+ .btn-primary {
+ @layer daisyui.l1.l2.l3 {
+ --btn-color: var(--color-primary);
+ --btn-fg: var(--color-primary-content);
+ }
+ }
+ .btn-secondary {
+ @layer daisyui.l1.l2.l3 {
+ --btn-color: var(--color-secondary);
+ --btn-fg: var(--color-secondary-content);
+ }
+ }
+ .hover\:bg-base-300 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-base-300);
+ }
+ }
+ }
+ .hover\:btn-error {
+ &:hover {
+ @media (hover: hover) {
+ @layer daisyui.l1.l2.l3 {
+ --btn-color: var(--color-error);
+ --btn-fg: var(--color-error-content);
+ }
+ }
+ }
+ }
+}
+.board {
+ display: flex;
+ gap: 8px;
+ background: #334;
+ padding: 16px;
+ border-radius: 12px;
+}
+.column {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 4px;
+ border-radius: 8px;
+}
+.column.clickable {
+ cursor: pointer;
+}
+.column.clickable:hover {
+ background: rgba(255,255,255,0.08);
+}
+.cell {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: #556;
+ transition: background 0.2s;
+}
+.cell.red {
+ background: #4a2a3a;
+}
+.cell.yellow {
+ background: #2a4545;
+}
+.cell.red.active-turn {
+ animation: glow-red 1.5s ease-in-out infinite alternate;
+}
+.cell.yellow.active-turn {
+ animation: glow-yellow 1.5s ease-in-out infinite alternate;
+}
+.cell.winning {
+ animation: pulse 0.5s ease-in-out infinite alternate;
+}
+@keyframes glow-red {
+ from {
+ box-shadow: 0 0 4px rgba(74, 42, 58, 0.3);
+ }
+ to {
+ box-shadow: 0 0 12px rgba(74, 42, 58, 0.7);
+ }
+}
+@keyframes glow-yellow {
+ from {
+ box-shadow: 0 0 4px rgba(42, 69, 69, 0.3);
+ }
+ to {
+ box-shadow: 0 0 12px rgba(42, 69, 69, 0.7);
+ }
+}
+@keyframes pulse {
+ from {
+ transform: scale(1);
+ box-shadow: 0 0 4px rgba(0,0,0,0.15);
+ }
+ to {
+ transform: scale(1.03);
+ box-shadow: 0 0 8px rgba(0,0,0,0.25);
+ }
+}
+.player-chip {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #445;
+}
+.player-chip.red {
+ background: #4a2a3a;
+}
+.player-chip.yellow {
+ background: #2a4545;
+}
+.snake-board {
+ display: inline-grid;
+ gap: 0;
+ background: #556;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 3px solid #445;
+}
+.snake-row {
+ display: contents;
+}
+.snake-cell {
+ background: #667;
+ border: 1px solid rgba(0,0,0,0.08);
+}
+.snake-cell.snake-head {
+ border-radius: 4px;
+ animation: head-pop 50ms ease-out;
+}
+@keyframes head-pop {
+ 0% {
+ transform: scale(0.85);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+.snake-cell.snake-food {
+ background: #334;
+ border-radius: 50%;
+ box-shadow: 0 0 3px rgba(0,0,0,0.2);
+ animation: food-pulse 1.2s ease-in-out infinite alternate;
+}
+@keyframes food-pulse {
+ from {
+ box-shadow: 0 0 3px rgba(0,0,0,0.1);
+ transform: scale(0.85);
+ }
+ to {
+ box-shadow: 0 0 6px rgba(0,0,0,0.2);
+ transform: scale(1);
+ }
+}
+.snake-cell.snake-dead {
+ opacity: 0.35;
+ filter: grayscale(0.5);
+ transition: opacity 400ms ease-out;
+}
+.snake-wrapper:focus {
+ outline: none;
+}
+.snake-game-area {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+ justify-content: center;
+}
+@media (max-width: 768px) {
+ .snake-game-area {
+ flex-direction: column;
+ align-items: center;
+ }
+ .snake-game-area .snake-chat {
+ width: 100%;
+ max-width: 480px;
+ }
+ .snake-chat-history {
+ height: 150px;
+ }
+}
+.snake-chat {
+ width: 100%;
+ max-width: 480px;
+}
+.snake-game-area .snake-chat {
+ width: 260px;
+ max-width: none;
+ flex-shrink: 0;
+}
+.snake-chat-history {
+ height: 300px;
+ overflow-y: auto;
+ background: #334;
+ border-radius: 8px 8px 0 0;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.snake-chat-msg {
+ font-size: 0.85rem;
+ line-height: 1.3;
+}
+.snake-chat-input {
+ display: flex;
+ gap: 0;
+ background: #445;
+ border-radius: 0 0 8px 8px;
+ overflow: hidden;
+}
+.snake-chat-input input {
+ flex: 1;
+ padding: 6px 10px;
+ background: transparent;
+ border: none;
+ color: inherit;
+ outline: none;
+ font-size: 0.85rem;
+}
+.snake-chat-input button {
+ padding: 6px 14px;
+ background: #556;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+.snake-chat-input button:hover {
+ background: #667;
+}
+.c4-game-area {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+ justify-content: center;
+}
+@media (max-width: 768px) {
+ .c4-game-area {
+ flex-direction: column;
+ align-items: center;
+ }
+ .c4-game-area .c4-chat {
+ width: 100%;
+ max-width: 480px;
+ }
+ .c4-chat-history {
+ height: 150px;
+ }
+ .board {
+ gap: 4px;
+ padding: 8px;
+ }
+ .column {
+ gap: 4px;
+ padding: 2px;
+ }
+ .cell {
+ width: 36px;
+ height: 36px;
+ }
+}
+.c4-chat {
+ width: 100%;
+ max-width: 480px;
+}
+.c4-game-area .c4-chat {
+ width: 260px;
+ max-width: none;
+ flex-shrink: 0;
+}
+.c4-chat-history {
+ height: 300px;
+ overflow-y: auto;
+ background: #334;
+ border-radius: 8px 8px 0 0;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.c4-chat-msg {
+ font-size: 0.85rem;
+ line-height: 1.3;
+}
+.c4-chat-input {
+ display: flex;
+ gap: 0;
+ background: #445;
+ border-radius: 0 0 8px 8px;
+ overflow: hidden;
+}
+.c4-chat-input input {
+ flex: 1;
+ padding: 6px 10px;
+ background: transparent;
+ border: none;
+ color: inherit;
+ outline: none;
+ font-size: 0.85rem;
+}
+.c4-chat-input button {
+ padding: 6px 14px;
+ background: #556;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+.c4-chat-input button:hover {
+ background: #667;
+}
+@layer base {
+ :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
+ color-scheme: light;
+ --color-base-100: oklch(100% 0 0);
+ --color-base-200: oklch(98% 0 0);
+ --color-base-300: oklch(95% 0 0);
+ --color-base-content: oklch(21% 0.006 285.885);
+ --color-primary: oklch(45% 0.24 277.023);
+ --color-primary-content: oklch(93% 0.034 272.788);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+}
+@layer base {
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ color-scheme: dark;
+ --color-base-100: oklch(25.33% 0.016 252.42);
+ --color-base-200: oklch(23.26% 0.014 253.1);
+ --color-base-300: oklch(21.15% 0.012 254.09);
+ --color-base-content: oklch(97.807% 0.029 256.847);
+ --color-primary: oklch(58% 0.233 277.117);
+ --color-primary-content: oklch(96% 0.018 272.314);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+ }
+}
+@layer base {
+ :root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
+ color-scheme: light;
+ --color-base-100: oklch(100% 0 0);
+ --color-base-200: oklch(98% 0 0);
+ --color-base-300: oklch(95% 0 0);
+ --color-base-content: oklch(21% 0.006 285.885);
+ --color-primary: oklch(45% 0.24 277.023);
+ --color-primary-content: oklch(93% 0.034 272.788);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+}
+@layer base {
+ :root:has(input.theme-controller[value=dark]:checked),[data-theme=dark] {
+ color-scheme: dark;
+ --color-base-100: oklch(25.33% 0.016 252.42);
+ --color-base-200: oklch(23.26% 0.014 253.1);
+ --color-base-300: oklch(21.15% 0.012 254.09);
+ --color-base-content: oklch(97.807% 0.029 256.847);
+ --color-primary: oklch(58% 0.233 277.117);
+ --color-primary-content: oklch(96% 0.018 272.314);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+}
+@layer base {
+ :root {
+ --fx-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");
+ }
+}
+@layer base {
+ :root {
+ scrollbar-color: currentColor #0000;
+ @supports (color: color-mix(in lab, red, red)) {
+ scrollbar-color: color-mix(in oklch, currentColor 35%, #0000) #0000;
+ }
+ }
+}
+@layer base {
+ @property --radialprogress {
+ syntax: "";
+ inherits: true;
+ initial-value: 0%;
+ }
+}
+@layer base {
+ :root:not(span) {
+ overflow: var(--page-overflow);
+ }
+}
+@layer base {
+ :root {
+ background: var(--page-scroll-bg, var(--root-bg));
+ --page-scroll-bg-on: linear-gradient(var(--root-bg, #0000), var(--root-bg, #0000))
+ var(--root-bg, #0000);
+ @supports (color: color-mix(in lab, red, red)) {
+ --page-scroll-bg-on: linear-gradient(var(--root-bg, #0000), var(--root-bg, #0000))
+ color-mix(in srgb, var(--root-bg, #0000), oklch(0% 0 0) calc(var(--page-has-backdrop, 0) * 40%));
+ }
+ --page-scroll-transition-on: background-color 0.3s ease-out;
+ transition: var(--page-scroll-transition);
+ scrollbar-gutter: var(--page-scroll-gutter, unset);
+ scrollbar-gutter: if(style(--page-has-scroll: 1): var(--page-scroll-gutter, unset) ; else: unset);
+ }
+ @keyframes set-page-has-scroll {
+ 0%, to {
+ --page-has-scroll: 1;
+ }
+ }
+}
+@layer base {
+ :root, [data-theme] {
+ background: var(--page-scroll-bg, var(--root-bg));
+ color: var(--color-base-content);
+ }
+ :where(:root, [data-theme]) {
+ --root-bg: var(--color-base-100);
+ }
+}
+@keyframes rating {
+ 0%, 40% {
+ scale: 1.1;
+ filter: brightness(1.05) contrast(1.05);
+ }
+}
+@keyframes dropdown {
+ 0% {
+ opacity: 0;
+ }
+}
+@keyframes radio {
+ 0% {
+ padding: 5px;
+ }
+ 50% {
+ padding: 3px;
+ }
+}
+@keyframes toast {
+ 0% {
+ scale: 0.9;
+ opacity: 0;
+ }
+ 100% {
+ scale: 1;
+ opacity: 1;
+ }
+}
+@keyframes rotator {
+ 89.9999%, 100% {
+ --first-item-position: 0 0%;
+ }
+ 90%, 99.9999% {
+ --first-item-position: 0 calc(var(--items) * 100%);
+ }
+ 100% {
+ translate: 0 -100%;
+ }
+}
+@keyframes skeleton {
+ 0% {
+ background-position: 150%;
+ }
+ 100% {
+ background-position: -50%;
+ }
+}
+@keyframes menu {
+ 0% {
+ opacity: 0;
+ }
+}
+@keyframes progress {
+ 50% {
+ background-position-x: -115%;
+ }
+}
+@layer base {
+ :where(:root),:root:has(input.theme-controller[value=stealth]:checked),[data-theme="stealth"] {
+ color-scheme: light;
+ --color-base-100: oklch(78% 0.008 260);
+ --color-base-200: oklch(72% 0.008 260);
+ --color-base-300: oklch(64% 0.008 260);
+ --color-base-content: oklch(22% 0.01 260);
+ --color-primary: oklch(38% 0.02 260);
+ --color-primary-content: oklch(90% 0.006 260);
+ --color-secondary: oklch(52% 0.015 260);
+ --color-secondary-content: oklch(22% 0.01 260);
+ --color-accent: oklch(48% 0.02 280);
+ --color-accent-content: oklch(90% 0.006 260);
+ --color-neutral: oklch(35% 0.015 260);
+ --color-neutral-content: oklch(88% 0.006 260);
+ --color-success: oklch(52% 0.02 160);
+ --color-success-content: oklch(22% 0.01 160);
+ --color-warning: oklch(58% 0.02 80);
+ --color-warning-content: oklch(28% 0.01 80);
+ --color-error: oklch(45% 0.03 20);
+ --color-error-content: oklch(90% 0.006 20);
+ --color-info: oklch(48% 0.02 250);
+ --color-info-content: oklch(22% 0.01 250);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.5rem;
+ --radius-box: 0.75rem;
+ --border: 1px;
+ --depth: 0;
+ --noise: 0;
+ }
+}
+@property --tw-font-weight {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-blur {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-brightness {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-contrast {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-grayscale {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-hue-rotate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-invert {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-opacity {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-saturate {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-sepia {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-color {
+ syntax: "*";
+ inherits: false;
+}
+@property --tw-drop-shadow-alpha {
+ syntax: "";
+ inherits: false;
+ initial-value: 100%;
+}
+@property --tw-drop-shadow-size {
+ syntax: "*";
+ inherits: false;
+}
+@keyframes pulse {
+ 50% {
+ opacity: 0.5;
+ }
+}
+@layer properties {
+ @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
+ *, ::before, ::after, ::backdrop {
+ --tw-font-weight: initial;
+ --tw-blur: initial;
+ --tw-brightness: initial;
+ --tw-contrast: initial;
+ --tw-grayscale: initial;
+ --tw-hue-rotate: initial;
+ --tw-invert: initial;
+ --tw-opacity: initial;
+ --tw-saturate: initial;
+ --tw-sepia: initial;
+ --tw-drop-shadow: initial;
+ --tw-drop-shadow-color: initial;
+ --tw-drop-shadow-alpha: 100%;
+ --tw-drop-shadow-size: initial;
+ }
+ }
+}
diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go
index 6b22d5b..846e721 100644
--- a/features/c4game/handlers.go
+++ b/features/c4game/handlers.go
@@ -97,7 +97,7 @@ func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.
defer cleanupChat()
// Setup heartbeat BEFORE creating SSE
- heartbeat := time.NewTicker(10 * time.Second)
+ heartbeat := time.NewTicker(1 * time.Second)
defer heartbeat.Stop()
// NOW create SSE
diff --git a/features/common/components/shared.templ b/features/common/components/shared.templ
index b9f9d2d..58d8e1a 100644
--- a/features/common/components/shared.templ
+++ b/features/common/components/shared.templ
@@ -48,7 +48,7 @@ templ NicknamePrompt(returnPath string) {
}
-// LiveClock shows the current server time, updated with each SSE patch.
+// LiveClock shows the current server time, updated every second via SSE.
// If the clock stops updating, users know the connection is stale.
templ LiveClock() {
diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go
index 74cb84e..d30fddb 100644
--- a/features/snakegame/handlers.go
+++ b/features/snakegame/handlers.go
@@ -122,7 +122,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
return
}
- heartbeat := time.NewTicker(10 * time.Second)
+ heartbeat := time.NewTicker(1 * time.Second)
defer heartbeat.Stop()
// Chat subscription (multiplayer only)
--
2.49.1