5 Commits
Author SHA1 Message Date
prosolis c6c107b936 feedwatch: tell the owner when a feed breaks, comes back, or goes quiet
Every poll's outcome has always been written to source_health and shown on
/status, but only to someone who went and looked. Nobody did: Siliconera has
been refused on 3,769 polls in a row and Digital Foundry on 1,900, and
neither has ever fetched successfully. SendAdminWarning existed and nothing
called it.

A watcher now reads source_health every 15 minutes and sends one notice to
matrix.admin_room covering everything that changed since it last spoke:
feeds that have failed 5 polls in a row, feeds that have fetched since they
were reported down, and feeds that fetch fine but have kept nothing new for 7
days (every healthy source produces something inside about 4). What has been
said is stored on the row (down_alerted_at, quiet_alerted_at), so each
episode is reported once and a restart doesn't repeat it. A notice that
fails to send is not marked, and goes out on the next pass.

last_new_at is new, moved only when a poll keeps a story. Existing rows start
from their newest retained story, or from now if they have none, and a new
source starts at its first poll: nothing on disk says whether a source with
no stories is long dead or was added yesterday, so it gets a full window
before it can be called quiet.

With no admin room configured the watcher doesn't run at all, rather than
marking alerts as delivered to nowhere.
2026-09-22 23:14:12 -07:00
prosolis 517019d4a4 storage: keep the stories people saved, and prune more than once a deploy
Maintenance deleted every story older than 30 days and then swept up any
user_story_state row pointing at a story that no longer existed. A bookmark
is one of those rows, so every saved story quietly vanished a month after
Pete first saw it, which is exactly what saving it was meant to prevent. A
bookmarked story is now exempt from the prune; read markers on pruned
stories still go.

Maintenance also only ran at startup, on a service that can go weeks between
deploys, so nothing was pruned for weeks and then everything was at once. It
runs at startup and then daily.

Pruning daily makes one old hole steady-state: the stories row is Pete's only
memory of a GUID, so a slow feed still listing month-old items would meet
them again as new the day after their rows went. The poller now skips items
published before the retention window. Undated items can't be judged and
still pass.
2026-09-22 23:13:58 -07:00
prosolis a15d67e210 web: the muted text that was never muted, and the stylesheet fetched every time
Every text-[color:var(--ink)]/50, border-[color:var(--ink)]/10 and
bg-[color:var(--ink)]/5 in the templates compiled to nothing. Tailwind v3
cannot put an opacity on an arbitrary var() colour, so all 538 of them were
dropped from output.css without a word. Meta text rendered at full ink, card
borders fell back to Tailwind's default grey (bright white rings at night),
and the washes, backdrops and hover pills simply weren't there, on the news
site, the casino and the adventure pages alike.

The palette is now written once per phase and casino room as bare rgb
channels (--ink-rgb and friends), the plain vars are built from them, and
tailwind.config.js exposes ink, muted, accent, warn and card as real colours.
The classes are text-ink/80, border-ink/10, bg-accent/20 now, and they compile.

Restoring the intended opacities as-is would have traded flat for illegible:
ink at 50% measures 2.7 to 2.9:1 on the lit phases. So secondary text gets
its own colour, --muted, per phase and per casino room, taken as faint as it
can go while still clearing 4.5:1 on both the card and the page. Everything
that was ink at 35 to 70% is text-muted. The 20-30% ones are separators and
empty-cell dashes and stay faint.

The bg-theme-* chips carry white text everywhere they appear, and eight of
them measured 2.5 to 3.2:1. Same hues, taken just dark enough to pass (4.6 to
4.7:1). eu, lego and adventure already passed and are unchanged, and the
underline and border colours keep the old pastels since they carry no text.

Static files came out of the embedded FS with no Last-Modified, no ETag and
no Cache-Control, so every page view downloaded the stylesheet and every
script in full, and the service worker's "cheap 304" never happened. Each
file is hashed once now. Templates link through {{asset}}, which appends
?v=<hash>; a request naming the current hash is served immutable for a year,
and everything else gets the hash as its ETag and revalidates to a 304. The
service worker serves versioned URLs cache-first, drops older versions of the
same file as new ones land, and moves to cache v6.
2026-09-22 22:54:50 -07:00
prosolis 4eaf8ae366 web: refuse writes that come from somebody else's page
The session cookie is scoped to the parent domain and is SameSite=Lax, and
every *.parodia.dev host is the same site as Pete as far as Lax is concerned.
So a form on any sibling subdomain could post a bet, a mischief order or an
equip change and the browser would attach whoever's signed-in cookie to it.
Nothing on the server looked at where a request came from.

The whole handler now sits behind http.NewCrossOriginProtection. It reads
Sec-Fetch-Site (falling back to Origin against Host) and refuses a
state-changing request from any other origin with a 403. GETs are untouched,
and so is anything that carries neither header, which is exactly what
gogobee's bearer-token calls from Go look like.
2026-09-22 22:54:38 -07:00
prosolis 21ed051bc2 games: the natural the deal test didn't expect
TestDealTakesTheStakeAndHidesTheHoleCard checked for a 900 stack straight
after a 100 bet, and only afterwards looked at whether the hand had already
settled. A dealt natural settles inside the deal, so the stake is back (with
the win, or as a push) before the test looks, and it failed whenever the shoe
handed out a blackjack. It now checks for a settled natural first and, if it
finds one, only asks that the stack didn't lose more than the bet.
2026-09-22 22:54:31 -07:00
50 changed files with 1593 additions and 704 deletions
+237
View File
@@ -0,0 +1,237 @@
// Package feedwatch tells the owner when a feed breaks, when it comes back, and
// when it goes quiet.
//
// The poller has always recorded every poll's outcome in source_health, and
// /status shows it, but only to someone who goes and looks. Feeds died that way
// for months: Siliconera and Digital Foundry had been refused on every poll
// thousands of times over before anyone noticed.
//
// The watcher reads source_health on a timer rather than hooking the pollers, so
// that one pass can batch every change into a single message, and so that what
// has already been said lives in the database (down_alerted_at,
// quiet_alerted_at) instead of in a goroutine that forgets it on restart. Each
// episode is reported once: a feed that goes down is announced, stays silent
// while it stays down, and is announced again when it comes back.
package feedwatch
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"pete/internal/config"
"pete/internal/storage"
)
const (
// DownAfter is how many failed polls in a row make a feed "down". One or
// two failures is weather; five is a feed that has stopped working.
DownAfter = 5
// QuietAfter is how long a feed can fetch fine and yield nothing new before
// it is "quiet". Every healthy source Pete carries produces something within
// about four days.
QuietAfter = 7 * 24 * time.Hour
// Every is how often the watcher looks.
Every = 15 * time.Minute
)
// Report is what one pass found that the owner hasn't been told yet.
type Report struct {
Down []storage.FeedAlertRow
Back []storage.FeedAlertRow
Quiet []storage.FeedAlertRow
// quietOver are feeds whose quiet spell has ended. They are cleared without
// a message: a feed that simply started posting again needs no fanfare.
quietOver []string
}
// Empty reports whether there is nothing to say.
func (r Report) Empty() bool { return len(r.Down)+len(r.Back)+len(r.Quiet) == 0 }
// Check reads source_health and works out what has changed, without recording
// anything. Only sources that are enabled in the config are considered, so a
// feed that has been switched off or removed never alerts.
func Check(now time.Time, sources []config.SourceConfig) (Report, error) {
enabled := make(map[string]bool, len(sources))
for _, s := range sources {
if s.Enabled {
enabled[s.Name] = true
}
}
rows, err := storage.ListFeedAlertRows()
if err != nil {
return Report{}, err
}
var r Report
quietCutoff := now.Add(-QuietAfter).Unix()
for _, row := range rows {
if !enabled[row.Source] {
continue
}
down := row.ConsecutiveFailures >= DownAfter
switch {
case down && row.DownAlertedAt == 0:
r.Down = append(r.Down, row)
// Back means "has fetched since we said it was down", not "is fetching
// right now": a feed that recovers and breaks again between two passes
// still gets its all-clear, and then a fresh down alert next pass.
case row.DownAlertedAt != 0 && row.LastSuccessAt > row.DownAlertedAt:
r.Back = append(r.Back, row)
}
// A feed that isn't fetching can't be judged on whether it has news;
// "down" already covers it.
if row.ConsecutiveFailures > 0 {
continue
}
quiet := row.LastNewAt > 0 && row.LastNewAt < quietCutoff
switch {
case quiet && row.QuietAlertedAt == 0:
r.Quiet = append(r.Quiet, row)
case !quiet && row.QuietAlertedAt != 0:
r.quietOver = append(r.quietOver, row.Source)
}
}
return r, nil
}
// Commit records that the owner has now been told everything in r.
func Commit(now time.Time, r Report) error {
at := now.Unix()
for _, row := range r.Down {
if err := storage.SetFeedDownAlerted(row.Source, at); err != nil {
return err
}
}
for _, row := range r.Back {
if err := storage.SetFeedDownAlerted(row.Source, 0); err != nil {
return err
}
}
for _, row := range r.Quiet {
if err := storage.SetFeedQuietAlerted(row.Source, at); err != nil {
return err
}
}
for _, src := range r.quietOver {
if err := storage.SetFeedQuietAlerted(src, 0); err != nil {
return err
}
}
return nil
}
// Message renders r as the plain-text note Pete sends the owner.
func Message(now time.Time, r Report) string {
var b strings.Builder
b.WriteString("Feed check")
if len(r.Down) > 0 {
b.WriteString("\n\nNot fetching:")
for _, row := range r.Down {
since := "has never fetched successfully"
if row.LastSuccessAt > 0 {
since = "last worked " + ago(now, row.LastSuccessAt)
}
fmt.Fprintf(&b, "\n• %s: %s failed polls in a row, %s. Last error: %s",
row.Source, count(row.ConsecutiveFailures), since, short(row.LastError))
}
}
if len(r.Back) > 0 {
b.WriteString("\n\nWorking again:")
for _, row := range r.Back {
fmt.Fprintf(&b, "\n• %s is fetching again.", row.Source)
}
}
if len(r.Quiet) > 0 {
b.WriteString("\n\nGone quiet:")
for _, row := range r.Quiet {
fmt.Fprintf(&b, "\n• %s fetches fine but hasn't had anything new in %s.",
row.Source, span(now, row.LastNewAt))
}
}
return b.String()
}
// Send delivers a message to the owner. An error means it didn't arrive, and
// the watcher will try again next pass rather than mark it said.
type Send func(ctx context.Context, msg string) error
// Run checks once shortly after startup (so pollers have had a first go) and
// then every Every until ctx ends.
func Run(ctx context.Context, sources []config.SourceConfig, send Send) {
first := time.NewTimer(2 * time.Minute)
defer first.Stop()
t := time.NewTicker(Every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-first.C:
case <-t.C:
}
pass(ctx, time.Now(), sources, send)
}
}
func pass(ctx context.Context, now time.Time, sources []config.SourceConfig, send Send) {
r, err := Check(now, sources)
if err != nil {
slog.Error("feedwatch: check failed", "err", err)
return
}
if r.Empty() {
// Still commit: a quiet spell may have ended silently.
if err := Commit(now, r); err != nil {
slog.Error("feedwatch: commit failed", "err", err)
}
return
}
msg := Message(now, r)
if err := send(ctx, msg); err != nil {
slog.Error("feedwatch: alert not delivered, will retry", "err", err)
return
}
slog.Info("feedwatch: alert sent", "down", len(r.Down), "back", len(r.Back), "quiet", len(r.Quiet))
if err := Commit(now, r); err != nil {
slog.Error("feedwatch: commit failed", "err", err)
}
}
// span renders the time since unix as a rough human length ("9 days").
func span(now time.Time, unix int64) string {
d := now.Sub(time.Unix(unix, 0))
switch {
case d < 2*time.Hour:
return fmt.Sprintf("%d minutes", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d hours", int(d.Hours()))
default:
return fmt.Sprintf("%d days", int(d.Hours()/24))
}
}
// ago is span as a point in the past ("9 days ago").
func ago(now time.Time, unix int64) string { return span(now, unix) + " ago" }
// count renders n with thousands separators.
func count(n int) string {
s := fmt.Sprint(n)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
return s
}
// short trims an error to one readable line.
func short(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if s == "" {
return "none recorded"
}
if len(s) > 140 {
s = s[:140] + "…"
}
return s
}
+179
View File
@@ -0,0 +1,179 @@
package feedwatch
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
func setup(t *testing.T) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "feedwatch.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
}
func src(names ...string) []config.SourceConfig {
var out []config.SourceConfig
for _, n := range names {
out = append(out, config.SourceConfig{Name: n, Enabled: true})
}
return out
}
func fail(name string, n int) {
for range n {
storage.RecordPollResult(name, false, 0, errors.New("feed fetch https://x/feed: status 403"))
}
}
// setLastNew backdates a source's last new story.
func setLastNew(t *testing.T, name string, at time.Time) {
t.Helper()
if _, err := storage.Get().Exec(`UPDATE source_health SET last_new_at = ? WHERE source = ?`, at.Unix(), name); err != nil {
t.Fatal(err)
}
}
// capture is a Send that records what it was asked to deliver.
type capture struct {
msgs []string
err error
}
func (c *capture) send(_ context.Context, msg string) error {
if c.err != nil {
return c.err
}
c.msgs = append(c.msgs, msg)
return nil
}
func TestDownIsReportedOnceAndBackIsReported(t *testing.T) {
setup(t)
sources := src("Broken", "Fine")
now := time.Now()
storage.RecordPollResult("Fine", true, 10, nil)
fail("Broken", DownAfter-1)
c := &capture{}
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 0 {
t.Fatalf("alerted below the threshold: %q", c.msgs)
}
fail("Broken", 1)
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 1 || !strings.Contains(c.msgs[0], "Broken: 5 failed polls in a row, has never fetched successfully") {
t.Fatalf("down alert = %q", c.msgs)
}
if strings.Contains(c.msgs[0], "Fine") {
t.Errorf("a healthy feed was mentioned: %q", c.msgs[0])
}
// Still down on the next pass: nothing new to say.
fail("Broken", 3)
pass(context.Background(), now.Add(Every), sources, c.send)
if len(c.msgs) != 1 {
t.Fatalf("down alert repeated: %q", c.msgs[1:])
}
// It comes back. The success has to be stamped after the alert, so step
// past the second the alert was recorded in.
time.Sleep(1100 * time.Millisecond)
storage.RecordPollResult("Broken", true, 5, nil)
pass(context.Background(), time.Now(), sources, c.send)
if len(c.msgs) != 2 || !strings.Contains(c.msgs[1], "Broken is fetching again") {
t.Fatalf("recovery = %q", c.msgs)
}
pass(context.Background(), time.Now(), sources, c.send)
if len(c.msgs) != 2 {
t.Fatalf("recovery repeated: %q", c.msgs[2:])
}
}
func TestUndeliveredAlertIsRetried(t *testing.T) {
setup(t)
sources := src("Broken")
fail("Broken", DownAfter)
c := &capture{err: errors.New("homeserver unreachable")}
pass(context.Background(), time.Now(), sources, c.send)
c.err = nil
pass(context.Background(), time.Now(), sources, c.send)
if len(c.msgs) != 1 || !strings.Contains(c.msgs[0], "Broken") {
t.Fatalf("alert after a failed send = %q, want it delivered on the retry", c.msgs)
}
}
func TestQuietFeedIsReportedOnce(t *testing.T) {
setup(t)
sources := src("Sleepy", "Busy")
now := time.Now()
storage.RecordPollResult("Sleepy", true, 10, nil)
storage.RecordPollResult("Busy", true, 10, nil)
setLastNew(t, "Sleepy", now.Add(-9*24*time.Hour))
c := &capture{}
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 1 || !strings.Contains(c.msgs[0], "Sleepy fetches fine but hasn't had anything new in 9 days") {
t.Fatalf("quiet alert = %q", c.msgs)
}
if strings.Contains(c.msgs[0], "Busy") {
t.Errorf("a busy feed was called quiet: %q", c.msgs[0])
}
pass(context.Background(), now.Add(Every), sources, c.send)
if len(c.msgs) != 1 {
t.Fatalf("quiet alert repeated: %q", c.msgs[1:])
}
// New stories end the quiet spell without a message, and a later quiet
// spell is a new episode that gets reported again.
storage.RecordNewStories("Sleepy", 2)
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 1 {
t.Fatalf("end of a quiet spell was announced: %q", c.msgs[1:])
}
setLastNew(t, "Sleepy", now.Add(-8*24*time.Hour))
pass(context.Background(), now, sources, c.send)
if len(c.msgs) != 2 {
t.Fatalf("second quiet spell not reported: %q", c.msgs)
}
}
// A feed that has just been added has no history, so it must not be called
// quiet before it has had a full window to produce something.
func TestNewSourceGetsAGracePeriod(t *testing.T) {
setup(t)
storage.RecordPollResult("Fresh", true, 0, nil)
r, err := Check(time.Now().Add(QuietAfter-time.Hour), src("Fresh"))
if err != nil {
t.Fatal(err)
}
if !r.Empty() {
t.Fatalf("new source reported inside its grace period: %+v", r)
}
}
func TestDisabledSourcesNeverAlert(t *testing.T) {
setup(t)
fail("Retired", DownAfter*2)
sources := []config.SourceConfig{{Name: "Retired", Enabled: false}}
r, err := Check(time.Now(), sources)
if err != nil {
t.Fatal(err)
}
if !r.Empty() {
t.Fatalf("disabled source alerted: %+v", r)
}
}
+11
View File
@@ -118,6 +118,16 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
continue
}
// An item older than the retention window is one Pete has either
// already shown and since pruned, or never needs to: maintenance would
// delete it on the next pass. Without this, a slow feed that still
// lists month-old items re-ingests them as new the day after their row
// is pruned, since that row was the only memory of them. Items with no
// date can't be judged and pass through.
if items[i].PublishedAt > 0 && time.Since(time.Unix(items[i].PublishedAt, 0)) > storage.StoryRetention {
continue
}
// Drop items in languages this source isn't configured to surface.
// Items without a language tag pass through — only filter when the
// feed explicitly marks an item with a non-matching language.
@@ -255,6 +265,7 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e
if newCount > 0 {
slog.Info("ingested new stories", "source", src.Name, "count", newCount)
storage.RecordNewStories(src.Name, newCount)
}
return nil
+12 -9
View File
@@ -508,23 +508,26 @@ func (c *Client) sendImage(ctx context.Context, roomID id.RoomID, imageURL strin
return err
}
// SendAdminWarning sends a warning message to the admin room, if configured.
func (c *Client) SendAdminWarning(msg string) {
// SendAdminNotice sends a notice to the admin room. It returns an error when no
// admin room is configured or the send fails, so a caller that must not lose
// the message (the feed watcher) can try again later.
func (c *Client) SendAdminNotice(ctx context.Context, msg string) error {
if c.adminRoom == nil {
slog.Warn("admin warning (no admin room configured)", "msg", msg)
return
return fmt.Errorf("no admin room configured")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
MsgType: event.MsgNotice,
Body: msg,
}
if _, err := c.mx.SendMessageEvent(context.Background(), *c.adminRoom, event.EventMessage, content); err != nil {
slog.Error("failed to send admin warning", "err", err, "msg", msg)
if _, err := c.mx.SendMessageEvent(ctx, *c.adminRoom, event.EventMessage, content); err != nil {
return fmt.Errorf("send admin notice: %w", err)
}
return nil
}
// HasAdminRoom reports whether an admin room is configured.
func (c *Client) HasAdminRoom() bool { return c.adminRoom != nil }
// ChannelRoomID returns the room ID for a named channel.
// Returns false if the channel is not configured or has an empty room ID.
func (c *Client) ChannelRoomID(channel string) (id.RoomID, bool) {
+43 -4
View File
@@ -1,6 +1,7 @@
package storage
import (
"context"
"database/sql"
"fmt"
"log/slog"
@@ -8,6 +9,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
@@ -94,6 +96,14 @@ func runMigrations(d *sql.DB) error {
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
// Feed alerts. last_new_at is when a source last produced a story Pete kept;
// the two *_alerted_at stamps remember that the owner has already been told a
// feed is down or has gone quiet, so an alert fires once per episode and
// survives a restart. See backfillLastNewAt for what existing rows start with.
addColumnIfMissing(d, "source_health", "last_new_at", "INTEGER")
addColumnIfMissing(d, "source_health", "down_alerted_at", "INTEGER")
addColumnIfMissing(d, "source_health", "quiet_alerted_at", "INTEGER")
backfillLastNewAt(d)
// Occupancy of a shared table. Rows written before the casino went multiplayer
// are solo games and read as NULL, which is exactly what they are.
addColumnIfMissing(d, "game_live_hands", "table_id", "TEXT")
@@ -154,12 +164,41 @@ func runMigrations(d *sql.DB) error {
return nil
}
// RunMaintenance prunes stale data. Called periodically.
// StoryRetention is how long a story is kept after Pete first saw it. It is also
// how long Pete remembers having seen it: the stories row is the only record of
// its GUID, canonical URL and headline, so the poller ignores feed items older
// than this rather than meet them again as strangers once their row is gone.
const StoryRetention = 30 * 24 * time.Hour
// StartMaintenance runs RunMaintenance now and then once a day until ctx ends.
// It used to run only at startup, so on a service that went weeks between
// deploys nothing was pruned, and then everything was, all at once.
func StartMaintenance(ctx context.Context) {
RunMaintenance()
go func() {
t := time.NewTicker(24 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
RunMaintenance()
}
}
}()
}
// RunMaintenance prunes stale data.
func RunMaintenance() {
// Prune old stories (30 days) and their post logs / reactions
storyCutoff := nowUnix() - int64(30*86400)
// Prune old stories and their post logs / reactions. A story someone has
// bookmarked is kept however old it is: the orphan sweep below would
// otherwise take the bookmark with it, and a saved story that quietly
// vanishes after a month is exactly what saving it was meant to prevent.
storyCutoff := nowUnix() - int64(StoryRetention/time.Second)
exec("prune old stories",
`DELETE FROM stories WHERE seen_at < ? AND classified = 1`, storyCutoff)
`DELETE FROM stories WHERE seen_at < ? AND classified = 1
AND id NOT IN (SELECT story_id FROM user_story_state WHERE bookmarked_at IS NOT NULL)`, storyCutoff)
exec("prune old post_log",
`DELETE FROM post_log WHERE posted_at < ?`, storyCutoff)
exec("prune old reactions",
+86 -7
View File
@@ -3,6 +3,7 @@ package storage
import (
"database/sql"
"fmt"
"log/slog"
)
// SourceHealth is one source's persisted poll health, as written by the poller.
@@ -30,15 +31,17 @@ type SourceContentStat struct {
// RecordPollResult upserts a source's health row after a poll attempt. On
// success it clears the error and failure counter and records the item count;
// on failure it bumps consecutive_failures and stores the message while
// preserving the last successful timestamp and item count. Fire-and-forget:
// preserving the last successful timestamp and item count. A brand-new row
// starts its last_new_at clock at the first poll, so a just-added source gets a
// full quiet window before it can be called quiet. Fire-and-forget:
// a failure here must never disrupt polling, so errors are logged and swallowed.
func RecordPollResult(source string, ok bool, itemCount int, pollErr error) {
now := nowUnix()
if ok {
exec("record poll success",
`INSERT INTO source_health
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at)
VALUES (?, ?, ?, '', 0, ?, ?)
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at, last_new_at)
VALUES (?, ?, ?, '', 0, ?, ?, ?)
ON CONFLICT(source) DO UPDATE SET
last_poll_at = excluded.last_poll_at,
last_success_at = excluded.last_success_at,
@@ -46,7 +49,7 @@ func RecordPollResult(source string, ok bool, itemCount int, pollErr error) {
consecutive_failures = 0,
last_item_count = excluded.last_item_count,
updated_at = excluded.updated_at`,
source, now, now, itemCount, now)
source, now, now, itemCount, now, now)
return
}
msg := ""
@@ -55,14 +58,14 @@ func RecordPollResult(source string, ok bool, itemCount int, pollErr error) {
}
exec("record poll failure",
`INSERT INTO source_health
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at)
VALUES (?, ?, NULL, ?, 1, 0, ?)
(source, last_poll_at, last_success_at, last_error, consecutive_failures, last_item_count, updated_at, last_new_at)
VALUES (?, ?, NULL, ?, 1, 0, ?, ?)
ON CONFLICT(source) DO UPDATE SET
last_poll_at = excluded.last_poll_at,
last_error = excluded.last_error,
consecutive_failures = source_health.consecutive_failures + 1,
updated_at = excluded.updated_at`,
source, now, msg, now)
source, now, msg, now, now)
}
// ListSourceHealth returns the poll-health row for every source that has been
@@ -147,3 +150,79 @@ func SourceContentStats() (map[string]SourceContentStat, error) {
}
return out, prows.Err()
}
// backfillLastNewAt gives rows that predate last_new_at a starting value: the
// newest story still retained for that source, or, when none is, the time of
// the migration. The second case is a grace period rather than a guess: a
// source with no retained stories might be long dead or might have been added
// yesterday, and nothing on disk says which, so it gets a full quiet window
// before it can be called quiet.
func backfillLastNewAt(d *sql.DB) {
if _, err := d.Exec(
`UPDATE source_health
SET last_new_at = COALESCE(
(SELECT MAX(seen_at) FROM stories WHERE stories.source = source_health.source),
?)
WHERE last_new_at IS NULL`, nowUnix()); err != nil {
slog.Error("backfill source_health.last_new_at failed", "err", err)
}
}
// RecordNewStories notes that a poll of source kept n genuinely new stories.
// Only a non-zero n moves last_new_at: a poll that fetched fine but found
// nothing new is exactly what "gone quiet" is made of.
func RecordNewStories(source string, n int) {
if n <= 0 {
return
}
exec("record new stories",
`UPDATE source_health SET last_new_at = ? WHERE source = ?`, nowUnix(), source)
}
// FeedAlertRow is the slice of source_health the feed watcher reads.
type FeedAlertRow struct {
Source string
ConsecutiveFailures int
LastError string
LastSuccessAt int64 // 0 = never fetched successfully
LastNewAt int64 // 0 = unknown
DownAlertedAt int64 // 0 = owner not told it's down
QuietAlertedAt int64 // 0 = owner not told it's quiet
}
// ListFeedAlertRows returns the alert-relevant health of every polled source.
func ListFeedAlertRows() ([]FeedAlertRow, error) {
rows, err := Get().Query(
`SELECT source, consecutive_failures, COALESCE(last_error, ''),
COALESCE(last_success_at, 0), COALESCE(last_new_at, 0),
COALESCE(down_alerted_at, 0), COALESCE(quiet_alerted_at, 0)
FROM source_health ORDER BY source`)
if err != nil {
return nil, fmt.Errorf("list feed alert rows: %w", err)
}
defer rows.Close()
var out []FeedAlertRow
for rows.Next() {
var r FeedAlertRow
if err := rows.Scan(&r.Source, &r.ConsecutiveFailures, &r.LastError,
&r.LastSuccessAt, &r.LastNewAt, &r.DownAlertedAt, &r.QuietAlertedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// SetFeedDownAlerted records (at > 0) or clears (at == 0) that the owner has
// been told source is down.
func SetFeedDownAlerted(source string, at int64) error {
_, err := Get().Exec(`UPDATE source_health SET down_alerted_at = NULLIF(?, 0) WHERE source = ?`, at, source)
return err
}
// SetFeedQuietAlerted records (at > 0) or clears (at == 0) that the owner has
// been told source has gone quiet.
func SetFeedQuietAlerted(source string, at int64) error {
_, err := Get().Exec(`UPDATE source_health SET quiet_alerted_at = NULLIF(?, 0) WHERE source = ?`, at, source)
return err
}
+43
View File
@@ -124,3 +124,46 @@ func TestUserStoryState_ScopedBySub(t *testing.T) {
t.Fatalf("user-b count = %d, want 0", n)
}
}
// Maintenance prunes stories older than the retention window, and then sweeps
// away user rows pointing at them. A bookmark is someone asking for a story to
// be kept, so a bookmarked story must survive the prune, and its bookmark with
// it. Read markers on pruned stories are fair game.
func TestMaintenanceKeepsBookmarkedStories(t *testing.T) {
setupTestDB(t)
saved := seedStory(t, "saved") // seedStory stamps 2023, far past retention
readOnly := seedStory(t, "read") // read, not saved
seedStory(t, "untouched")
const sub = "ak-sub-keep"
if err := SetBookmark(sub, saved, true); err != nil {
t.Fatal(err)
}
if err := SetRead(sub, readOnly, true); err != nil {
t.Fatal(err)
}
RunMaintenance()
var n int
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("stories left after maintenance = %d, want 1 (the bookmarked one)", n)
}
marks, err := ListBookmarks(sub, 10, 0)
if err != nil {
t.Fatal(err)
}
if len(marks) != 1 || marks[0].GUID != "saved" {
t.Fatalf("bookmarks after maintenance = %+v, want just the saved story", marks)
}
read, _, err := UserStoryState(sub, []int64{readOnly})
if err != nil {
t.Fatal(err)
}
if read[readOnly] {
t.Error("read marker on a pruned story survived the orphan sweep")
}
}
+85
View File
@@ -0,0 +1,85 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"strings"
"sync"
)
// Static assets are served out of the embedded FS, and embedded files have no
// modification time, so http.FileServer sends no Last-Modified and no ETag. With
// no validator and no Cache-Control, every page view re-downloaded the whole
// stylesheet and every script in full.
//
// So each file is hashed once, at first use, and the hash does two jobs:
//
// - Templates link assets through {{asset "/static/..."}}, which appends
// ?v=<hash>. A request carrying the current hash is content-addressed, so it
// is served immutable for a year: the next deploy that changes the file
// changes the URL, and the browser never has to ask.
// - Every static response carries the hash as its ETag. Anything fetched by a
// bare URL (audio samples, scripts loading scripts, a stale page asking for
// an old ?v=) is served no-cache, and the revalidation is a cheap 304.
var (
assetOnce sync.Once
assetHashes map[string]string // path under static/ -> short content hash
)
func loadAssetHashes() map[string]string {
assetOnce.Do(func() {
assetHashes = map[string]string{}
sub, err := fs.Sub(staticFS, "static")
if err != nil {
return
}
_ = fs.WalkDir(sub, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := fs.ReadFile(sub, path)
if err != nil {
return nil
}
sum := sha256.Sum256(b)
assetHashes[path] = hex.EncodeToString(sum[:])[:12]
return nil
})
})
return assetHashes
}
// assetURL is the {{asset}} template func: "/static/css/output.css" becomes
// "/static/css/output.css?v=<hash>". A path that isn't an embedded file comes
// back unchanged rather than breaking the page.
func assetURL(path string) string {
h, ok := loadAssetHashes()[strings.TrimPrefix(path, "/static/")]
if !ok {
return path
}
return path + "?v=" + h
}
// staticHandler serves the embedded static tree (mounted under /static/ with the
// prefix already stripped) with a content-hash ETag and a cache policy that
// depends on whether the URL named the current hash.
func staticHandler(sub fs.FS) http.Handler {
files := http.FileServer(http.FS(sub))
hashes := loadAssetHashes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h, ok := hashes[strings.TrimPrefix(r.URL.Path, "/")]; ok {
// FileServer honours If-None-Match against an ETag already set on
// the response, so this alone turns revalidation into a 304.
w.Header().Set("ETag", `"`+h+`"`)
if r.URL.Query().Get("v") == h {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
}
files.ServeHTTP(w, r)
})
}
+69
View File
@@ -0,0 +1,69 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func serveStatic(t *testing.T, target string, hdr map[string]string) *httptest.ResponseRecorder {
t.Helper()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatal(err)
}
h := http.StripPrefix("/static/", staticHandler(sub))
req := httptest.NewRequest("GET", target, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// The whole point: a page's stylesheet link is content-addressed and cached for
// good, a bare URL revalidates, and revalidation costs a 304 rather than 90KB.
func TestStaticAssetsAreCacheable(t *testing.T) {
versioned := assetURL("/static/css/output.css")
if !strings.Contains(versioned, "?v=") {
t.Fatalf("assetURL did not version the stylesheet: %q", versioned)
}
rec := serveStatic(t, versioned, nil)
if rec.Code != 200 {
t.Fatalf("versioned GET = %d, want 200", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Errorf("versioned Cache-Control = %q, want immutable", cc)
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag on a static asset")
}
rec = serveStatic(t, "/static/css/output.css", nil)
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("bare Cache-Control = %q, want no-cache", cc)
}
// A stale page asking for a hash that no longer exists gets today's bytes,
// but must not be told to keep them for a year under the old name.
rec = serveStatic(t, "/static/css/output.css?v=000000000000", nil)
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("stale-hash Cache-Control = %q, want no-cache", cc)
}
rec = serveStatic(t, "/static/css/output.css", map[string]string{"If-None-Match": etag})
if rec.Code != http.StatusNotModified {
t.Errorf("revalidation = %d, want 304", rec.Code)
}
}
func TestAssetURLLeavesUnknownPathsAlone(t *testing.T) {
if got := assetURL("/static/nope.js"); got != "/static/nope.js" {
t.Errorf("assetURL(unknown) = %q, want it unchanged", got)
}
}
+51
View File
@@ -0,0 +1,51 @@
package web
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"pete/internal/config"
"pete/internal/storage"
)
// A sibling parodia.dev host is the same *site* as Pete, so SameSite=Lax lets
// the session cookie ride along on its forms. The server has to refuse those
// itself, and must still let a same-origin page and a headerless server-to-server
// call (gogobee, with its bearer token) through.
func TestCrossOriginWritesAreRefused(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "csrf.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
post := func(hdr map[string]string) int {
req := httptest.NewRequest("POST", "https://news.parodia.dev/api/state/read", nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
s.srv.Handler.ServeHTTP(rec, req)
return rec.Code
}
if code := post(map[string]string{"Sec-Fetch-Site": "cross-site"}); code != http.StatusForbidden {
t.Errorf("cross-site POST = %d, want 403", code)
}
if code := post(map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://evil.parodia.dev"}); code != http.StatusForbidden {
t.Errorf("sibling-subdomain POST = %d, want 403", code)
}
if code := post(map[string]string{"Sec-Fetch-Site": "same-origin"}); code == http.StatusForbidden {
t.Error("same-origin POST was refused")
}
if code := post(nil); code == http.StatusForbidden {
t.Error("headerless server-to-server POST was refused")
}
}
+9 -4
View File
@@ -93,9 +93,6 @@ func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
if code != 200 {
t.Fatalf("deal = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if v.Hand == nil {
t.Fatal("deal returned no hand")
}
@@ -103,10 +100,18 @@ func TestDealTakesTheStakeAndHidesTheHoleCard(t *testing.T) {
t.Fatalf("player was dealt %d cards, want 2", len(v.Hand.Hands[0].Cards))
}
// A natural settles on the spot and legitimately shows both dealer cards.
// A natural settles on the spot: the stake has already come back (with the
// win, or as a push), and both dealer cards are legitimately shown. Checking
// the stack for 900 before this made the test fail on every dealt natural.
if v.Hand.Phase == "done" {
if v.Chips < 900 {
t.Fatalf("chips after a settled natural = %d, want at least the 900 left after the bet", v.Chips)
}
return
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if !v.Hand.Hole || len(v.Hand.Dealer) != 1 {
t.Fatalf("dealer shows %d cards (hole=%v), want 1 with the hole card held back",
len(v.Hand.Dealer), v.Hand.Hole)
+1
View File
@@ -700,6 +700,7 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) {
var funcs = template.FuncMap{
"thumb": thumbURL,
"asset": assetURL,
"channel": func(slug string) Channel {
for _, ch := range channels {
if ch.Slug == slug {
+9 -2
View File
@@ -180,7 +180,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
if err != nil {
return nil, err
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler(staticSub)))
mux.HandleFunc("GET /img/{name}", s.handleImg)
mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
@@ -353,9 +353,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
}
}
// Every state-changing request from a browser must come from this site's
// own origin. The session cookie is scoped to the parent domain and is
// SameSite=Lax, and every *.parodia.dev host counts as the same site, so
// without this a form on any sibling subdomain could bet, order or equip as
// whoever is signed in. It keys off Sec-Fetch-Site (falling back to Origin
// vs Host) and passes requests carrying neither, so gogobee's bearer-token
// calls from Go are untouched. GET/HEAD/OPTIONS are never blocked.
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: s.hostRouter(mux),
Handler: http.NewCrossOriginProtection().Handler(s.hostRouter(mux)),
ReadHeaderTimeout: 5 * time.Second,
}
return s, nil
+67 -31
View File
@@ -17,41 +17,68 @@
amber-400 on cream at 1.55:1 — half of the four combinations unreadable, and
which half depends on a setting outside the page. Only night has a dark card;
dusk and dawn are lit despite their names. */
/* Each colour is written once, as bare rgb channels (--ink-rgb), and the plain
var (--ink) is built from it. Tailwind needs the channels: tailwind.config.js
maps ink/muted/accent/warn/card to rgb(var(--x-rgb) / <alpha-value>), which is
what makes text-ink/80 or border-ink/10 actually compile. The old spelling,
text-[color:var(--ink)]/80, never did: Tailwind v3 cannot put an opacity on
an arbitrary var() colour, so all 538 of those classes silently produced no
rule at all.
--muted is the secondary-text colour: ink mixed into the card, as faint as it
can go while still clearing 4.5:1 on both the card and the page background.
It exists because ink-at-50% looks right but measures 2.7–2.9:1 on the lit
phases. Use text-muted for meta text, not text-ink/50. */
:root,
html[data-phase="day"] {
--bg: #fff7e4; /* warm cream */
--bg-grad: #ffeec2; /* soft sun wash */
--card: #ffffff;
--ink: #3a2e1f;
--accent: #f2a541; /* sunshine yellow */
--warn: #b45309;
--card-rgb: 255 255 255;
--ink-rgb: 58 46 31;
--muted-rgb: 119 111 100; /* 4.95:1 card, 4.64:1 bg */
--accent-rgb: 242 165 65; /* sunshine yellow */
--warn-rgb: 180 83 9;
}
html[data-phase="dawn"] {
--bg: #ffe7d6;
--bg-grad: #ffc9c9;
--card: #fff4ea;
--ink: #4a2e2a;
--accent: #ff8a65;
--warn: #b45309;
--card-rgb: 255 244 234;
--ink-rgb: 74 46 42;
--muted-rgb: 123 99 94; /* 5.12:1 card, 4.66:1 bg */
--accent-rgb: 255 138 101;
--warn-rgb: 180 83 9;
}
html[data-phase="dusk"] {
--bg: #ffd6a8;
--bg-grad: #f7a07e;
--card: #fff1de;
--ink: #3d2417;
--accent: #e6553a;
--warn: #b45309;
--card-rgb: 255 241 222;
--ink-rgb: 61 36 23;
--muted-rgb: 113 91 77; /* 5.71:1 card, 4.67:1 bg */
--accent-rgb: 230 85 58;
--warn-rgb: 180 83 9;
}
html[data-phase="night"] {
--bg: #1a1f3a;
--bg-grad: #2a3358;
--card: #2d365a;
--ink: #f1ecd8; /* moonlight */
--accent: #f9d976; /* lantern */
--warn: #fbbf24; /* the only dark card, so the only light amber */
--card-rgb: 45 54 90;
--ink-rgb: 241 236 216; /* moonlight */
--muted-rgb: 163 163 166; /* 4.67:1 card, 6.42:1 bg */
--accent-rgb: 249 217 118; /* lantern */
--warn-rgb: 251 191 36; /* the only dark card, so the only light amber */
}
/* The plain vars, built from the channels. Declared on html alongside every
palette block (phase and casino room alike), so a block that only swaps the
channels still gets matching plain vars. */
html {
--card: rgb(var(--card-rgb));
--ink: rgb(var(--ink-rgb));
--muted: rgb(var(--muted-rgb));
--accent: rgb(var(--accent-rgb));
--warn: rgb(var(--warn-rgb));
}
@layer base {
@@ -81,16 +108,23 @@ html[data-phase="night"] {
.shadow-pete-lg { box-shadow: 0 6px 0 rgba(60,40,20,0.12), 0 16px 32px rgba(60,40,20,0.12); }
/* Per-channel theme colors. Kept as plain utilities so Tailwind doesn't
need to know to safelist them. */
.bg-theme-gaming { background-color: #4caf7d; }
.bg-theme-tech { background-color: #5aa9e6; }
.bg-theme-politics { background-color: #e07a5f; }
need to know to safelist them.
Every bg-theme-* carries white text (chips, the active nav pill, channel
heroes), so each one has to clear 4.5:1 against white. The original pastels
measured 2.5–3.2:1; these are the same hues taken just dark enough to pass
(4.6–4.7:1). eu, lego and adventure already passed and are untouched. The
decoration-theme-* underlines below keep the original pastels: they carry
no text. */
.bg-theme-gaming { background-color: #38815c; } /* was #4caf7d */
.bg-theme-tech { background-color: #1d79c0; } /* was #5aa9e6 */
.bg-theme-politics { background-color: #cb4a27; } /* was #e07a5f */
.bg-theme-eu { background-color: #003399; }
.bg-theme-music { background-color: #b079d6; }
.bg-theme-anime { background-color: #ec5e8a; }
.bg-theme-foss { background-color: #d97706; }
.bg-theme-kids { background-color: #14b8a6; }
.bg-theme-finance { background-color: #10b981; }
.bg-theme-music { background-color: #9a54cb; } /* was #b079d6 */
.bg-theme-anime { background-color: #e01a58; } /* was #ec5e8a */
.bg-theme-foss { background-color: #af6005; } /* was #d97706 */
.bg-theme-kids { background-color: #0e8376; } /* was #14b8a6 */
.bg-theme-finance { background-color: #0c855d; } /* was #10b981 */
.bg-theme-lego { background-color: #d01012; }
.bg-theme-adventure { background-color: #6d4bd8; }
@@ -2285,9 +2319,10 @@ html[data-phase="night"] {
html[data-room="casinopolis"] {
--bg: #16211c; /* the room, lights low */
--card: #23342b; /* a table in it */
--ink: #f6ecd8; /* warm cream, not white */
--accent: #f2b53d; /* honey */
--card-rgb: 35 52 43; /* a table in it */
--ink-rgb: 246 236 216; /* warm cream, not white */
--muted-rgb: 153 155 140; /* 4.65:1 card, 5.85:1 bg */
--accent-rgb: 242 181 61; /* honey */
--felt-a: #2f7d5b;
--felt-b: #24614a;
--felt-c: #1c4d3c;
@@ -2296,9 +2331,10 @@ html[data-room="casinopolis"] {
html[data-room="casino-night"] {
--bg: #140f2e; /* the machine's cabinet */
--card: #241a4d;
--ink: #f2ecff;
--accent: #ffcc2f; /* the jackpot bulb */
--card-rgb: 36 26 77;
--ink-rgb: 242 236 255;
--muted-rgb: 143 135 170; /* 4.67:1 card, 5.46:1 bg */
--accent-rgb: 255 204 47; /* the jackpot bulb */
--felt-a: #4a2fa8; /* the table is a pinball table now */
--felt-b: #351f80;
--felt-c: #241659;
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -156,12 +156,12 @@
// and pushed the verb itself onto two lines.
var li = document.createElement('li');
var verb = document.createElement('div');
verb.className = 'font-semibold text-[color:var(--ink)]/70';
verb.className = 'font-semibold text-muted';
verb.textContent = VERB[o.action] || o.action;
var said = document.createElement('div');
said.className = 'mt-0.5 leading-snug ' + (o.status === 'pending'
? 'text-[color:var(--ink)]/45'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
? 'text-muted'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-warn'));
said.textContent = o.detail || STATUS[o.status] || o.status;
li.appendChild(verb); li.appendChild(said);
list.appendChild(li);
@@ -247,14 +247,14 @@
if (existing) existing.remove();
var boxEl = document.createElement('div');
boxEl.className = 'adv-action-confirm mt-3 rounded-xl bg-[color:var(--ink)]/5 p-3 text-sm';
boxEl.className = 'adv-action-confirm mt-3 rounded-xl bg-ink/5 p-3 text-sm';
var p = document.createElement('p');
p.className = 'text-[color:var(--ink)]/70';
p.className = 'text-muted';
p.textContent = btn.getAttribute('data-confirm') || 'Are you sure?';
var cost = parseFloat(btn.getAttribute('data-cost') || '0');
if (cost > 0) {
var money = document.createElement('p');
money.className = 'mt-1.5 font-semibold text-[color:var(--ink)]/80';
money.className = 'mt-1.5 font-semibold text-ink/80';
if (panelBalance - cost < 0) {
// No arrow when it does not cover. The game can carry a small debt, so
// the resulting figure is not always nonsense — but printing "€-6,300"
@@ -262,7 +262,7 @@
// lands on the wrong side of the sign.
money.textContent = '€' + euroFmt(cost) + " — you have €" + euroFmt(panelBalance) +
". That won't cover it.";
money.className += ' text-[color:var(--warn)]';
money.className += ' text-warn';
} else {
money.textContent = '€' + euroFmt(cost) + ' — balance €' + euroFmt(panelBalance) +
' → €' + euroFmt(panelBalance - cost) + '.';
@@ -293,7 +293,7 @@
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
var no = document.createElement('button');
no.type = 'button';
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-3 py-1';
no.className = 'rounded-full border border-ink/20 text-muted px-3 py-1';
no.textContent = 'Not yet';
no.addEventListener('click', function () { boxEl.remove(); });
row.appendChild(yes); row.appendChild(no);
+2 -2
View File
@@ -844,12 +844,12 @@
var stakes = tierBtns.filter(function (b) { return b.dataset.tier === t.tier; })[0];
var btn = document.createElement("button");
btn.type = "button";
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition";
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-ink/10 p-3 text-left hover:bg-ink/5 transition";
var label = document.createElement("span");
label.className = "font-display font-bold";
label.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
var seats = document.createElement("span");
seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums";
seats.className = "text-xs font-semibold text-muted tabular-nums";
seats.textContent = t.humans + "/" + t.seats + " seated";
btn.appendChild(label);
btn.appendChild(seats);
+8 -8
View File
@@ -58,29 +58,29 @@
}
const html = items.map((r, i) => {
const thumb = r.thumb_url
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-[color:var(--ink)]/5">
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-ink/5">
<img src="${escapeHTML(r.thumb_url)}" alt="${escapeHTML(r.headline || "")}" loading="lazy" decoding="async"
class="h-full w-full object-cover">
</div>`
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-ink/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
const postedRing = r.posted ? ` border-theme-${escapeHTML(r.channel_theme)}` : "";
const href = safeURL(r.article_url);
if (!href) return "";
return `
<a href="${escapeHTML(href)}" target="_blank" rel="noopener noreferrer"
data-idx="${i}"
class="search-result group flex items-start gap-4 rounded-2xl p-3 transition border-2 border-transparent hover:bg-[color:var(--ink)]/5${postedRing}">
class="search-result group flex items-start gap-4 rounded-2xl p-3 transition border-2 border-transparent hover:bg-ink/5${postedRing}">
${thumb}
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center gap-2 text-xs flex-wrap">
<span class="inline-flex items-center rounded-full bg-theme-${escapeHTML(r.channel_theme)} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
<span aria-hidden="true" class="mr-1">${escapeHTML(r.channel_emoji)}</span>${escapeHTML(r.channel_title)}
</span>
<span class="text-[color:var(--ink)]/60 font-semibold">${escapeHTML(r.source)}</span>
<span class="text-[color:var(--ink)]/50">${escapeHTML(r.time_ago)}</span>
<span class="text-muted font-semibold">${escapeHTML(r.source)}</span>
<span class="text-muted">${escapeHTML(r.time_ago)}</span>
</div>
<h3 class="font-display text-base sm:text-lg font-semibold leading-snug">${escapeHTML(r.headline)}</h3>
${r.lede ? `<p class="text-sm text-[color:var(--ink)]/70 line-clamp-2">${escapeHTML(r.lede)}</p>` : ""}
${r.lede ? `<p class="text-sm text-muted line-clamp-2">${escapeHTML(r.lede)}</p>` : ""}
</div>
</a>`;
}).join("");
@@ -92,10 +92,10 @@
const nodes = list.querySelectorAll(".search-result");
nodes.forEach((n, i) => {
if (i === activeIndex) {
n.classList.add("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15");
n.classList.add("bg-ink/5", "border-ink/15");
n.scrollIntoView({ block: "nearest" });
} else {
n.classList.remove("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15");
n.classList.remove("bg-ink/5", "border-ink/15");
}
});
}
+4 -4
View File
@@ -67,28 +67,28 @@
var sources = knownSources();
list.innerHTML = "";
if (sources.length === 0) {
list.innerHTML = '<p class="px-2 py-4 text-sm text-center text-[color:var(--ink)]/50">No feeds configured.</p>';
list.innerHTML = '<p class="px-2 py-4 text-sm text-center text-muted">No feeds configured.</p>';
} else {
var lastChannel = null;
sources.forEach(function (src, i) {
if (src.channel !== lastChannel) {
lastChannel = src.channel;
var hdr = document.createElement("div");
hdr.className = "px-3 pt-3 pb-1 text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 font-bold";
hdr.className = "px-3 pt-3 pb-1 text-[11px] uppercase tracking-wider text-muted font-bold";
hdr.textContent = src.channel || "other";
list.appendChild(hdr);
}
var id = "pete-feed-" + i;
var row = document.createElement("label");
row.setAttribute("for", id);
row.className = "flex items-center justify-between gap-3 rounded-2xl px-3 py-2 hover:bg-[color:var(--ink)]/5 cursor-pointer";
row.className = "flex items-center justify-between gap-3 rounded-2xl px-3 py-2 hover:bg-ink/5 cursor-pointer";
var nameEl = document.createElement("span");
nameEl.className = "text-sm font-semibold truncate";
nameEl.textContent = src.name;
var box = document.createElement("input");
box.type = "checkbox";
box.id = id;
box.className = "h-4 w-4 shrink-0 accent-[color:var(--accent)]";
box.className = "h-4 w-4 shrink-0 accent-accent";
box.checked = !disabled[src.name];
box.addEventListener("change", function () {
if (box.checked) delete disabled[src.name];
+2 -2
View File
@@ -1018,12 +1018,12 @@
var stakes = root.querySelector('[data-tier="' + t.tier + '"]');
var btn = document.createElement("button");
btn.type = "button";
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition";
btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-ink/10 p-3 text-left hover:bg-ink/5 transition";
var lbl = document.createElement("span");
lbl.className = "font-display font-bold";
lbl.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
var seats = document.createElement("span");
seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums";
seats.className = "text-xs font-semibold text-muted tabular-nums";
seats.textContent = t.humans + "/" + t.seats + " seated";
btn.appendChild(lbl);
btn.appendChild(seats);
+23 -23
View File
@@ -207,10 +207,10 @@
if (!cardMount) return;
var air = describeAqi(entry.aqi);
var aqiRow = air
? '<div class="flex items-center gap-2 border-t border-[color:var(--ink)]/10 px-5 py-3 text-sm">' +
? '<div class="flex items-center gap-2 border-t border-ink/10 px-5 py-3 text-sm">' +
'<span class="inline-block w-2.5 h-2.5 rounded-full shrink-0" style="background:' + air.color + '"></span>' +
'<span class="font-bold tabular-nums">AQI ' + entry.aqi + '</span>' +
'<span class="text-[color:var(--ink)]/60">' + air.label + '</span>' +
'<span class="text-muted">' + air.label + '</span>' +
'</div>'
: '';
var daily = entry.daily;
@@ -219,28 +219,28 @@
var dd = describe(daily.weather_code[i], 1);
days +=
'<div class="flex flex-col items-center gap-1 rounded-2xl px-2 py-2 min-w-0">' +
'<span class="text-xs font-semibold text-[color:var(--ink)]/60">' + (i === 0 ? "Today" : weekday(daily.time[i])) + '</span>' +
'<span class="text-xs font-semibold text-muted">' + (i === 0 ? "Today" : weekday(daily.time[i])) + '</span>' +
'<span class="text-2xl leading-none" aria-hidden="true">' + dd.emoji + '</span>' +
'<span class="text-sm font-bold tabular-nums">' + Math.round(daily.temperature_2m_max[i]) + '°</span>' +
'<span class="text-xs tabular-nums text-[color:var(--ink)]/50">' + Math.round(daily.temperature_2m_min[i]) + '°</span>' +
'<span class="text-xs tabular-nums text-muted">' + Math.round(daily.temperature_2m_min[i]) + '°</span>' +
'</div>';
}
cardMount.innerHTML =
'<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">' +
'<div class="rounded-3xl bg-card border-2 border-ink/10 shadow-pete overflow-hidden">' +
'<div class="flex flex-wrap items-center justify-between gap-4 p-5">' +
'<div class="flex items-center gap-4 min-w-0">' +
'<span class="text-5xl leading-none" aria-hidden="true">' + desc.emoji + '</span>' +
'<div class="min-w-0">' +
'<div class="flex items-baseline gap-2">' +
'<span class="font-display text-4xl font-bold tabular-nums">' + temp(cur.temperature_2m, sym) + '</span>' +
'<span class="text-sm font-semibold text-[color:var(--ink)]/70 truncate">' + desc.label + '</span>' +
'<span class="text-sm font-semibold text-muted truncate">' + desc.label + '</span>' +
'</div>' +
'<div class="text-sm text-[color:var(--ink)]/60 truncate">📍 ' + escapeHtml(loc.name) + '</div>' +
'<div class="text-sm text-muted truncate">📍 ' + escapeHtml(loc.name) + '</div>' +
'</div>' +
'</div>' +
'<button type="button" data-weather-loc class="shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5 border-2 border-[color:var(--ink)]/10 transition">Change location</button>' +
'<button type="button" data-weather-loc class="shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold text-muted hover:bg-ink/5 border-2 border-ink/10 transition">Change location</button>' +
'</div>' +
'<div class="grid grid-cols-5 gap-1 border-t border-[color:var(--ink)]/10 bg-[color:var(--ink)]/[0.03] px-2 py-3">' + days + '</div>' +
'<div class="grid grid-cols-5 gap-1 border-t border-ink/10 bg-ink/[0.03] px-2 py-3">' + days + '</div>' +
aqiRow +
'</div>';
// The injected "Change location" button needs the same handler.
@@ -259,7 +259,7 @@
setAqiChip(null);
if (cardMount && getLoc()) {
cardMount.innerHTML =
'<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-5 text-center text-sm text-[color:var(--ink)]/60">' +
'<div class="rounded-3xl bg-card border-2 border-dashed border-ink/15 p-5 text-center text-sm text-muted">' +
escapeHtml(msg) + ' · <button type="button" data-weather-loc class="font-semibold underline">try again</button>' +
'</div>';
var b = cardMount.querySelector("[data-weather-loc]");
@@ -278,9 +278,9 @@
if (!popover) return;
popover.querySelectorAll("[data-loc-unit]").forEach(function (b) {
var on = b.getAttribute("data-loc-unit") === selectedUnit;
b.classList.toggle("bg-[color:var(--accent)]", on);
b.classList.toggle("bg-accent", on);
b.classList.toggle("text-white", on);
b.classList.toggle("text-[color:var(--ink)]/60", !on);
b.classList.toggle("text-muted", !on);
});
}
@@ -293,32 +293,32 @@
function buildPopover() {
var dlg = document.createElement("div");
dlg.id = "pete-weather-loc";
dlg.className = "hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[12vh] px-4";
dlg.className = "hidden fixed inset-0 z-50 bg-ink/40 backdrop-blur-sm flex items-start justify-center pt-[12vh] px-4";
dlg.setAttribute("role", "dialog");
dlg.setAttribute("aria-modal", "true");
dlg.innerHTML =
'<div class="w-full max-w-sm rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">' +
'<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">' +
'<div class="w-full max-w-sm rounded-3xl bg-card shadow-pete-lg border-2 border-ink/10 overflow-hidden">' +
'<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-ink/10">' +
'<h2 class="font-display text-lg font-bold">Weather location</h2>' +
'<button type="button" data-loc-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>' +
'<button type="button" data-loc-close class="rounded-full px-2 py-1 text-sm text-muted hover:bg-ink/5">esc</button>' +
'</div>' +
'<div class="p-5 space-y-3">' +
'<p class="text-xs text-[color:var(--ink)]/60">Enter your postal code to see the local forecast. The background follows the weather. Saved in this browser only.</p>' +
'<p class="text-xs text-muted">Enter your postal code to see the local forecast. The background follows the weather. Saved in this browser only.</p>' +
'<div class="flex gap-2">' +
'<select data-loc-country class="rounded-2xl border-2 border-[color:var(--ink)]/10 bg-[color:var(--bg)] px-3 py-2 text-sm font-semibold outline-none focus:border-[color:var(--accent)]"></select>' +
'<input data-loc-postal type="text" inputmode="text" autocomplete="postal-code" placeholder="Postal code" class="flex-1 min-w-0 rounded-2xl border-2 border-[color:var(--ink)]/10 bg-[color:var(--bg)] px-3 py-2 text-sm outline-none focus:border-[color:var(--accent)]">' +
'<select data-loc-country class="rounded-2xl border-2 border-ink/10 bg-[color:var(--bg)] px-3 py-2 text-sm font-semibold outline-none focus:border-accent"></select>' +
'<input data-loc-postal type="text" inputmode="text" autocomplete="postal-code" placeholder="Postal code" class="flex-1 min-w-0 rounded-2xl border-2 border-ink/10 bg-[color:var(--bg)] px-3 py-2 text-sm outline-none focus:border-accent">' +
'</div>' +
'<div class="flex items-center justify-between gap-2">' +
'<span class="text-xs font-semibold text-[color:var(--ink)]/60">Units</span>' +
'<div class="inline-flex rounded-full border-2 border-[color:var(--ink)]/10 p-0.5 text-xs font-bold">' +
'<span class="text-xs font-semibold text-muted">Units</span>' +
'<div class="inline-flex rounded-full border-2 border-ink/10 p-0.5 text-xs font-bold">' +
'<button type="button" data-loc-unit="celsius" class="rounded-full px-3 py-1 transition">°C</button>' +
'<button type="button" data-loc-unit="fahrenheit" class="rounded-full px-3 py-1 transition">°F</button>' +
'</div>' +
'</div>' +
'<p data-loc-error class="hidden text-xs font-semibold text-red-500"></p>' +
'<div class="flex items-center justify-between gap-2 pt-1">' +
'<button type="button" data-loc-clear class="rounded-full px-3 py-2 text-xs font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]">Clear</button>' +
'<button type="button" data-loc-save class="rounded-full bg-[color:var(--accent)] px-5 py-2 text-sm font-bold text-white shadow-pete hover:brightness-105 transition disabled:opacity-50">Save</button>' +
'<button type="button" data-loc-clear class="rounded-full px-3 py-2 text-xs font-semibold text-muted hover:text-ink">Clear</button>' +
'<button type="button" data-loc-save class="rounded-full bg-accent px-5 py-2 text-sm font-bold text-white shadow-pete hover:brightness-105 transition disabled:opacity-50">Save</button>' +
'</div>' +
'</div>' +
'</div>';
+34 -16
View File
@@ -4,12 +4,13 @@
//
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
// drops every cache that doesn't match the current version.
var CACHE_VERSION = "v5";
var CACHE_VERSION = "v6";
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
// App shell: the static assets every page needs. Versioned by URL content only
// loosely, so we lean on network-first for HTML and cache-first for these.
// App shell: the static assets every page needs, precached by bare URL so an
// offline launch has something to fall back on. Pages ask for them with a
// ?v=<hash> suffix; see the fetch handler for how the two meet.
var SHELL_ASSETS = [
"/static/css/output.css",
"/static/js/prefs.js",
@@ -117,25 +118,42 @@ self.addEventListener("fetch", function (event) {
return;
}
// Static assets: network-first. Our asset URLs are NOT content-hashed
// (/static/js/weather-gl.js stays the same URL across deploys), so a
// cache-first strategy would pin whatever bytes were first cached and never
// notice a redeployed file changed — leaving stale JS/CSS in place until the
// CACHE_VERSION bump below. Go's FileServer sets ETag/Last-Modified, so an
// online refetch is a cheap 304 when nothing changed. We still fill the cache
// on every success and fall back to it when the network is unreachable, so
// offline reading keeps the shell it needs.
// Static assets. Pages link them as /static/...?v=<content hash> (the
// {{asset}} template func), so a versioned URL names exactly one set of bytes
// and is safe to serve cache-first: a redeploy that changes the file changes
// the URL. When one is cached, older versions of the same file are dropped so
// the shell cache doesn't grow by a copy per deploy.
//
// Anything requested by a bare URL (the precache list above, audio samples)
// stays network-first; the server sends those no-cache with a content-hash
// ETag, so the refetch is a 304 when nothing changed. Offline, either kind
// falls back to whatever copy of that path is cached, whatever its version.
if (url.pathname.indexOf("/static/") === 0) {
event.respondWith(
fetch(req).then(function (res) {
var versioned = url.searchParams.has("v");
var fromNetwork = function () {
return fetch(req).then(function (res) {
if (res && res.ok) {
var copy = res.clone();
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
caches.open(SHELL_CACHE).then(function (cache) {
if (!versioned) return cache.put(req, copy);
return cache.keys().then(function (keys) {
keys.forEach(function (k) {
var ku = new URL(k.url);
if (ku.pathname === url.pathname && ku.search !== url.search) cache.delete(k);
});
return cache.put(req, copy);
});
});
}
return res;
}).catch(function () {
return caches.match(req);
})
return caches.match(req, { ignoreSearch: true });
});
};
event.respondWith(
versioned
? caches.match(req).then(function (hit) { return hit || fromNetwork(); })
: fromNetwork()
);
return;
}
+7 -7
View File
@@ -15,7 +15,7 @@
a class: the palette is a Go table of hex values, and a generated
Tailwind class would be purged out of the stylesheet and fail silently. */}}
{{if .Story.Accent}}style="border-color:{{.Story.Accent}}{{if .Story.Ceremony}};box-shadow:0 0 0 4px {{.Story.Accent}}33, 0 10px 30px -12px {{.Story.Accent}}{{end}}"{{end}}
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
class="group relative block rounded-3xl bg-card border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-ink/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
<span role="button" tabindex="0" data-bookmark-btn data-story-id="{{.Story.ID}}"
aria-label="Bookmark this story" aria-pressed="false"
style="display:none;position:absolute;top:.6rem;left:.6rem;z-index:6;height:1.9rem;width:1.9rem;align-items:center;justify-content:center;border-radius:9999px;background:rgba(20,14,6,.62);color:#fff;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)">
@@ -24,7 +24,7 @@
</svg>
</span>
{{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
<div class="aspect-[16/10] w-full overflow-hidden bg-ink/5">
<img src="{{thumb .Story.ImageURL}}" alt="{{.Story.Headline}}" loading="lazy" decoding="async"
class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
</div>
@@ -35,7 +35,7 @@
<span class="inline-flex items-center rounded-full bg-theme-{{$ch.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
<span aria-hidden="true" class="mr-1">{{$ch.Emoji}}</span>{{$ch.Title}}
</span>
<span class="text-[color:var(--ink)]/60 font-semibold">{{.Story.Source}}</span>
<span class="text-muted font-semibold">{{.Story.Source}}</span>
{{else}}
<span class="inline-flex items-center rounded-full bg-theme-{{.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
{{.Story.Source}}
@@ -47,9 +47,9 @@
<span aria-hidden="true" class="mr-1">★</span>Realm first
</span>
{{end}}
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
{{if .Story.ReadMins}}<span class="text-[color:var(--ink)]/45">· {{.Story.ReadMins}} min read</span>{{end}}
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-[color:var(--ink)]/45 tabular-nums" title="{{.Story.Views}} reads">
<span class="text-muted">{{timeAgo .Story.SeenAt}}</span>
{{if .Story.ReadMins}}<span class="text-muted">· {{.Story.ReadMins}} min read</span>{{end}}
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-muted tabular-nums" title="{{.Story.Views}} reads">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"></path><circle cx="12" cy="12" r="3"></circle></svg>
{{.Story.Views}}</span>{{end}}
</div>
@@ -57,7 +57,7 @@
{{.Story.Headline}}
</h3>
{{if .Story.Lede}}
<p class="text-sm text-[color:var(--ink)]/70 line-clamp-3">{{.Story.Lede}}</p>
<p class="text-sm text-muted line-clamp-3">{{.Story.Lede}}</p>
{{end}}
</div>
{{if .Story.Paywalled}}
+15 -15
View File
@@ -1,40 +1,40 @@
{{define "_chipbar"}}
<section data-chipbar
class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-x-6 gap-y-4">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Chips in front of you</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Chips in front of you</div>
<div class="flex items-baseline gap-2">
<span data-chips class="font-display text-3xl font-bold tabular-nums">—</span>
<span data-pending class="hidden text-sm font-semibold text-[color:var(--ink)]/60 animate-pulse"></span>
<span data-pending class="hidden text-sm font-semibold text-muted animate-pulse"></span>
</div>
</div>
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your wallet</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Your wallet</div>
<div class="flex items-baseline gap-1.5">
<span data-euros class="font-display text-2xl font-bold tabular-nums text-[color:var(--ink)]/70">—</span>
<span class="text-sm text-[color:var(--ink)]/40" title="Pete reads this off the game box every couple of minutes, so it lags a little.">€, give or take</span>
<span data-euros class="font-display text-2xl font-bold tabular-nums text-muted">—</span>
<span class="text-sm text-muted" title="Pete reads this off the game box every couple of minutes, so it lags a little.">€, give or take</span>
</div>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 p-1.5">
<div class="flex items-center gap-1.5 rounded-full bg-ink/5 p-1.5">
<label for="buyin-amount" class="sr-only">How many chips?</label>
<input id="buyin-amount" data-buyin-amount type="number" min="1" step="1" value="100"
inputmode="numeric" enterkeyhint="go"
class="w-24 rounded-full bg-[color:var(--card)] px-3 py-1.5 text-sm font-semibold tabular-nums
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
class="w-24 rounded-full bg-card px-3 py-1.5 text-sm font-semibold tabular-nums
border-2 border-ink/10 focus:outline-none focus:border-accent">
<button type="button" data-buyin
class="rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-white shadow-pete
class="rounded-full bg-accent px-4 py-1.5 text-sm font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Buy chips
</button>
</div>
<button type="button" data-cashout
class="rounded-full bg-[color:var(--card)] px-4 py-2 text-sm font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full bg-card px-4 py-2 text-sm font-bold shadow-pete border-2 border-ink/10
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash out
</button>
@@ -42,8 +42,8 @@
one thing every table has, and the sound is the room's rather than any
one game's. -->
<button type="button" data-sfx-toggle aria-pressed="false"
class="grid h-10 w-10 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px transition">
class="grid h-10 w-10 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10
hover:bg-ink/5 active:translate-y-px transition">
<svg data-sfx-on viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
@@ -59,6 +59,6 @@
</div>
</div>
<p data-chipbar-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-chipbar-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
{{end}}
+2 -2
View File
@@ -9,10 +9,10 @@
off .Path. */}}
{{define "realmnav"}}
<nav class="mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<a href="/adventure" class="inline-flex items-center gap-1.5 font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<a href="/adventure" class="inline-flex items-center gap-1.5 font-semibold text-muted hover:text-ink transition">
<span aria-hidden="true">←</span> All dispatches
</a>
<span class="text-[color:var(--ink)]/20" aria-hidden="true">·</span>
<span class="text-ink/20" aria-hidden="true">·</span>
<a href="/adventure/realm" class="realm-tab{{if eq .Path "/adventure/realm"}} realm-tab-on{{end}}">The map</a>
<a href="/adventure/standings" class="realm-tab{{if eq .Path "/adventure/standings"}} realm-tab-on{{end}}">The board</a>
<a href="/adventure/firsts" class="realm-tab{{if eq .Path "/adventure/firsts"}} realm-tab-on{{end}}">Hall of firsts</a>
+21 -21
View File
@@ -5,7 +5,7 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
@@ -13,7 +13,7 @@
</a>
<h1 class="font-display text-3xl font-bold">Blackjack</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Six decks · pays 3:2 · dealer hits soft 17</p>
<p class="text-sm text-muted">Six decks · pays 3:2 · dealer hits soft 17</p>
</div>
{{template "_chipbar" .}}
@@ -21,7 +21,7 @@
<!-- The felt. The two things on it that aren't cards are the shoe the cards
come out of and the rack the money comes out of — a chip on this table is
always travelling between one of those and the spot in front of you. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-ink/10">
<div class="pete-rack" data-at="shoe" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
@@ -86,10 +86,10 @@
</section>
<!-- Betting: shown between hands. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-betting class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
@@ -101,43 +101,43 @@
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
class="rounded-full px-3 py-2 text-sm font-semibold text-muted hover:text-ink transition">Clear</button>
</div>
<button type="button" data-deal
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Deal
</button>
</div>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Playing: shown while a hand is live. -->
<section data-actions class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-actions class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center justify-center gap-3">
<button type="button" data-move="hit"
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Hit
</button>
<button type="button" data-move="stand"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full bg-card px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-ink/10
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Stand
</button>
<button type="button" data-move="double"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full bg-card px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-ink/10
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Double
</button>
<button type="button" data-move="split"
class="rounded-full bg-[color:var(--card)] px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full bg-card px-8 py-3 font-display text-lg font-bold shadow-pete border-2 border-ink/10
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Split
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<p class="mt-3 text-center text-xs text-muted">
<kbd>h</kbd> hit · <kbd>s</kbd> stand · <kbd>d</kbd> double · <kbd>p</kbd> split
</p>
</section>
@@ -146,8 +146,8 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/casino-cards.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/blackjack.js" defer></script>
<script src="{{asset "/static/js/casino-fx.js"}}" defer></script>
<script src="{{asset "/static/js/casino-cards.js"}}" defer></script>
<script src="{{asset "/static/js/games.js"}}" defer></script>
<script src="{{asset "/static/js/blackjack.js"}}" defer></script>
{{end}}
+5 -5
View File
@@ -2,7 +2,7 @@
{{define "main"}}
<section class="mt-2 mb-8">
<div class="rounded-3xl bg-[color:var(--accent)] text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="rounded-3xl bg-accent text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🔖</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-70">saved for later</p>
@@ -22,15 +22,15 @@
<nav class="mt-10 flex items-center justify-between">
{{if .HasPrev}}
<a href="{{.PrevURL}}" class="rounded-full bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
<a href="{{.PrevURL}}" class="rounded-full bg-card border-2 border-ink/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
{{else}}<span></span>{{end}}
<span class="text-sm text-[color:var(--ink)]/60">page {{.Page}}</span>
<span class="text-sm text-muted">page {{.Page}}</span>
{{if .HasNext}}
<a href="{{.NextURL}}" class="rounded-full bg-[color:var(--accent)] text-[#1c1305] px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
<a href="{{.NextURL}}" class="rounded-full bg-accent text-[#1c1305] px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
{{else}}<span></span>{{end}}
</nav>
{{else}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
<div class="rounded-3xl bg-card border-2 border-dashed border-ink/15 p-10 text-center text-muted">
Nothing saved yet. Browse the feed and tap the 🔖 on a story to keep it here.
</div>
{{end}}
+43 -43
View File
@@ -19,10 +19,10 @@
has happened to since their last visit. Above the Siege because everything
below this line is the realm's news and this is the reader's own. */}}
{{if .Away.Has}}
<section class="mb-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 sm:p-6 shadow-pete">
<section class="mb-6 rounded-3xl bg-card border-2 border-theme-adventure/30 p-5 sm:p-6 shadow-pete">
<div class="flex items-baseline justify-between gap-3 flex-wrap">
<h2 class="font-display text-xl font-bold">While you were away</h2>
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45">{{.Away.Name}} · past {{.Away.Since}}</span>
<span class="text-xs uppercase tracking-wider text-muted">{{.Away.Name}} · past {{.Away.Since}}</span>
</div>
<ul class="mt-3 space-y-2">
{{range .Away.Lines}}
@@ -31,9 +31,9 @@
<span class="shrink-0" aria-hidden="true">{{.Emoji}}</span>
<span class="flex-1 text-sm">
<span class="font-semibold group-hover:text-theme-adventure group-hover:underline">{{.Label}}</span>
{{if .Line}}<span class="text-[color:var(--ink)]/60"> — {{.Line}}</span>{{end}}
{{if .Line}}<span class="text-muted"> — {{.Line}}</span>{{end}}
</span>
<span class="text-xs text-[color:var(--ink)]/40 shrink-0 tabular-nums">{{.When}}</span>
<span class="text-xs text-muted shrink-0 tabular-nums">{{.When}}</span>
</a>
</li>
{{end}}
@@ -64,22 +64,22 @@
</div>
</a>
{{else if .Siege.History}}
<a href="/adventure/siege" class="flex items-center gap-3 mb-6 rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-3 shadow-pete hover:border-theme-adventure/40 transition">
<a href="/adventure/siege" class="flex items-center gap-3 mb-6 rounded-2xl bg-card border-2 border-ink/10 px-5 py-3 shadow-pete hover:border-theme-adventure/40 transition">
<span class="text-lg" aria-hidden="true">🏰</span>
<span class="text-sm text-[color:var(--ink)]/70">Nothing camped outside town right now — <span class="font-semibold text-theme-adventure">the sieges we've fought</span></span>
<span class="text-sm text-muted">Nothing camped outside town right now — <span class="font-semibold text-theme-adventure">the sieges we've fought</span></span>
</a>
{{end}}
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
<p class="text-xs uppercase tracking-wider text-muted" id="roster-status">
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
</p>
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
<div class="rounded-3xl bg-card border-2 border-ink/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-ink/10" id="roster-list">
{{range .Roster}}
{{/* The layout is .roster-row, not utilities: this markup has a twin in the
script below that re-renders the same list on every poll, and the two
@@ -87,22 +87,22 @@
<li class="roster-row" data-token="{{.Token}}" data-name="{{.Name}}">
<span class="roster-row-icon" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<a href="/adventure/who/{{.Token}}" class="roster-row-name font-semibold truncate hover:text-theme-adventure hover:underline">{{.Name}}</a>
<span class="roster-row-meta text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
<span class="roster-row-where text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
<span class="roster-row-meta text-sm text-muted">lv {{.Level}} {{.ClassRace}}</span>
<span class="roster-row-where text-sm {{if .OnRun}}font-semibold{{else}}text-muted{{end}}">
{{.Where}}{{if .Idle}} <span class="text-muted">· {{.Idle}}</span>{{end}}
</span>
{{if and $.User .OnRun}}
<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-ink/5 hover:bg-red-500 hover:text-white border border-ink/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
{{end}}
</li>
{{else}}
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
<li class="px-5 py-8 text-center text-muted">Nobody's out at the moment. Quiet week in the realm.</li>
{{end}}
</ul>
</div>
{{if not .User}}
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
<p class="mt-3 text-xs text-muted">
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
</p>
{{end}}
@@ -111,30 +111,30 @@
<!-- Your open contracts. Rendered from filed facts, never from live game state:
Pete only knows what gogobee told it happened. -->
<div id="mischief-orders" class="mt-4 hidden">
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
<h3 class="text-xs uppercase tracking-wider text-muted mb-2">Trouble you've paid for</h3>
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
</div>
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
nothing here asserts a number of its own. -->
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
<div class="w-full max-w-md rounded-3xl bg-card border-2 border-ink/10 shadow-pete overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-ink/10">
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target">—</span></h3>
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
<button type="button" id="mischief-close" class="text-2xl leading-none text-muted hover:text-ink" aria-label="close">×</button>
</div>
<div class="px-5 py-4 space-y-3">
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
<p class="text-sm text-muted">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
<div id="mischief-tiers" class="space-y-2"></div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
<input type="checkbox" id="mischief-signed" class="rounded border-ink/30">
<span>Sign my name to it <span class="text-muted">(+25%, and they'll know)</span></span>
</label>
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
<p id="mischief-balance" class="text-xs text-muted"></p>
<p id="mischief-result" class="text-sm hidden"></p>
</div>
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-ink/10">
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-muted hover:text-ink">Never mind</button>
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
</div>
</div>
@@ -160,17 +160,17 @@
}
function row(a) {
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
var idle = a.Idle ? ' <span class="text-muted">· ' + esc(a.Idle) + '</span>' : '';
// The server-rendered twin of this row is above, in the {{"{{range .Roster}}"}}
// block. Keep the class names identical: the layout is all in .roster-row.
var button = (signedIn && a.OnRun)
? '<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
? '<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-ink/5 hover:bg-red-500 hover:text-white border border-ink/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
: '';
return '<li class="roster-row" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
'<span class="roster-row-icon" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<a href="/adventure/who/' + esc(a.Token) + '" class="roster-row-name font-semibold truncate hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
'<span class="roster-row-meta text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="roster-row-where text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
'<span class="roster-row-meta text-sm text-muted">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="roster-row-where text-sm ' + (a.OnRun ? 'font-semibold' : 'text-muted') + '">' +
esc(a.Where) + idle + '</span>' + button + '</li>';
}
@@ -182,7 +182,7 @@
var rows = data.adventurers || [];
list.innerHTML = rows.length
? rows.map(row).join('')
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
: '<li class="px-5 py-8 text-center text-muted">Nobody\'s out at the moment. Quiet week in the realm.</li>';
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
card.classList.toggle('opacity-60', !!data.stale);
})
@@ -226,7 +226,7 @@ function initMischief(list, esc) {
function drawTiers() {
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
tiersEl.innerHTML = '<p class="text-sm text-muted">The catalogue is between snapshots. Try again in a moment.</p>';
return;
}
var signed = signedEl.checked;
@@ -236,11 +236,11 @@ function initMischief(list, esc) {
var price = signed ? t.signed_fee : t.fee;
var tooDear = known && euro < price;
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-ink/10 hover:border-ink/25') +
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
'<span class="font-semibold">' + euros(price) + '</span></span>' +
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
(t.blurb ? '<span class="block text-xs text-muted mt-0.5">' + esc(t.blurb) + '</span>' : '') +
'</button>';
}).join('');
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
@@ -275,10 +275,10 @@ function initMischief(list, esc) {
var map = {
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
bounced_funds: ["couldn't afford it", 'bg-ink/10 text-muted'],
bounced_ineligible: ['no longer a mark', 'bg-ink/10 text-muted']
};
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
var m = map[statusText] || [statusText, 'bg-ink/10 text-muted'];
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
}
@@ -289,10 +289,10 @@ function initMischief(list, esc) {
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
ordersWrap.classList.remove('hidden');
ordersList.innerHTML = orders.map(function (o) {
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
var detail = o.detail ? ' <span class="text-muted">· ' + esc(o.detail) + '</span>' : '';
return '<li class="flex items-center gap-3">' +
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
'<span class="text-muted">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
}).join('');
})
@@ -328,14 +328,14 @@ function initMischief(list, esc) {
loadOrders();
setTimeout(close, 1200);
} else {
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
resultEl.className = 'text-sm text-muted';
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
confirmEl.disabled = false;
}
})
.catch(function () {
resultEl.classList.remove('hidden');
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
resultEl.className = 'text-sm text-muted';
resultEl.textContent = 'The line dropped. Try again.';
confirmEl.disabled = false;
});
@@ -356,15 +356,15 @@ function initMischief(list, esc) {
<nav class="mt-10 flex items-center justify-between">
{{if .HasPrev}}
<a href="{{.PrevURL}}" class="rounded-full bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
<a href="{{.PrevURL}}" class="rounded-full bg-card border-2 border-ink/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
{{else}}<span></span>{{end}}
<span class="text-sm text-[color:var(--ink)]/60">page {{.Page}}</span>
<span class="text-sm text-muted">page {{.Page}}</span>
{{if .HasNext}}
<a href="{{.NextURL}}" class="rounded-full bg-theme-{{.Channel.Theme}} text-white px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
{{else}}<span></span>{{end}}
</nav>
{{else}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
<div class="rounded-3xl bg-card border-2 border-dashed border-ink/15 p-10 text-center text-muted">
Pete hasn't catalogued anything for {{.Channel.Title}} yet.
</div>
{{end}}
+10 -10
View File
@@ -39,15 +39,15 @@
{{range .Firsts}}
<li class="firsts-entry firsts-entry-{{.Kind}}">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 font-semibold">{{.Label}}</span>
<span class="text-[11px] uppercase tracking-wider text-muted font-semibold">{{.Label}}</span>
<span class="font-display font-bold text-base">{{.Display}}</span>
{{if .Tier}}<span class="text-[11px] text-[color:var(--ink)]/35 tabular-nums">T{{.Tier}}</span>{{end}}
{{if .Tier}}<span class="text-[11px] text-muted tabular-nums">T{{.Tier}}</span>{{end}}
</div>
<p class="mt-0.5 text-sm text-[color:var(--ink)]/60">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold text-[color:var(--ink)]/80 hover:text-theme-adventure transition">{{.Holder}}</a>
{{else if .Holder}}<span class="font-semibold text-[color:var(--ink)]/80">{{.Holder}}</span>
{{else}}<span class="italic text-[color:var(--ink)]/40">nobody left who'll admit to it</span>{{end}}
{{if .When}}<span class="text-[color:var(--ink)]/35"> · {{.When}}</span>{{end}}
<p class="mt-0.5 text-sm text-muted">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold text-ink/80 hover:text-theme-adventure transition">{{.Holder}}</a>
{{else if .Holder}}<span class="font-semibold text-ink/80">{{.Holder}}</span>
{{else}}<span class="italic text-muted">nobody left who'll admit to it</span>{{end}}
{{if .When}}<span class="text-muted"> · {{.When}}</span>{{end}}
</p>
</li>
{{end}}
@@ -55,15 +55,15 @@
</section>
{{end}}
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center max-w-xl mx-auto">
<p class="mt-8 text-xs text-muted text-center max-w-xl mx-auto">
An entry with no name is one where the record has outlived the record-holder —
a thing found and long since given away, or somebody who'd rather I didn't say.
The claim still stands.
</p>
{{else}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">
<div class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<p class="text-sm text-muted">
{{if .Firsts.Known}}
Nothing's happened for the first time yet. Everything is about to be a first.
{{else}}
+2 -2
View File
@@ -2,7 +2,7 @@
{{define "main"}}
<section class="mt-2 mb-8">
<div class="rounded-3xl bg-[color:var(--accent)] text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="rounded-3xl bg-accent text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">✨</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-70">picked for you</p>
@@ -19,7 +19,7 @@
{{end}}
</div>
{{else}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
<div class="rounded-3xl bg-card border-2 border-dashed border-ink/15 p-10 text-center text-muted">
Nothing to tune yet. Read a few stories or bookmark the ones you like, and Pete will start building a feed around them.
</div>
{{end}}
+34 -34
View File
@@ -3,11 +3,11 @@
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-8 shadow-pete border-2 border-[color:var(--ink)]/10">
<section class="rounded-3xl bg-card p-6 sm:p-8 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="font-display text-3xl sm:text-4xl font-bold">Welcome in 🎲</h1>
<p class="mt-2 max-w-xl text-[color:var(--ink)]/70">
<p class="mt-2 max-w-xl text-muted">
Real euros, from the same wallet as everything else. Chips are one euro each,
and whatever you don't spend goes straight back when you cash out.
</p>
@@ -27,80 +27,80 @@
<div class="grid gap-4 sm:grid-cols-2">
<a href="/games/blackjack"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🃏</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">🃏</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Blackjack</h3>
<p class="text-sm text-[color:var(--ink)]/60">Six decks, blackjack pays 3:2.</p>
<p class="text-sm text-muted">Six decks, blackjack pays 3:2.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
<p class="mt-4 text-sm text-muted">
The dealer stands on 17 and hits a soft one. House takes {{.RakePct}}% of what you win,
and nothing at all when you lose or push.
</p>
</a>
<a href="/games/hangman"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🪢</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">🪢</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Hangman</h3>
<p class="text-sm text-[color:var(--ink)]/60">Guess the phrase, keep the multiple.</p>
<p class="text-sm text-muted">Guess the phrase, keep the multiple.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
<p class="mt-4 text-sm text-muted">
Short phrases pay up to 2.6×. You get {{.MaxWrong}} lives, and every wrong guess
takes a tenth off what a win is worth.
</p>
</a>
<a href="/games/solitaire"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🂡</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">🂡</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Solitaire</h3>
<p class="text-sm text-[color:var(--ink)]/60">Buy the deck, win it back a card at a time.</p>
<p class="text-sm text-muted">Buy the deck, win it back a card at a time.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
<p class="mt-4 text-sm text-muted">
Vegas rules. Your stake buys the deck and doesn't come back — every card you
get home pays a slice of it in. Cash the board whenever you like.
</p>
</a>
<a href="/games/trivia"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🧠</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">🧠</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Trivia</h3>
<p class="text-sm text-[color:var(--ink)]/60">Climb the ladder, or take the money.</p>
<p class="text-sm text-muted">Climb the ladder, or take the money.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
<p class="mt-4 text-sm text-muted">
{{.Rungs}} questions, and every right answer multiplies what you're holding. A wrong
one loses the lot. Answer fast: the multiple decays as the clock runs.
</p>
</a>
<a href="/games/uno"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🎴</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">🎴</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">UNO</h3>
<p class="text-sm text-[color:var(--ink)]/60">Go out first, take the table.</p>
<p class="text-sm text-muted">Go out first, take the table.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
<p class="mt-4 text-sm text-muted">
One to three bots, and the more of them there are the more it pays: up to 4.1× for
beating a full table. Anybody else going out first takes your stake. Or throw the
No Mercy switch: 168 cards, draws that stack, and twenty-five in your hand puts you out.
@@ -108,30 +108,30 @@
</a>
<a href="/games/holdem"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">♠️</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">♠️</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">Texas Hold'em</h3>
<p class="text-sm text-[color:var(--ink)]/60">Buy in. Beat them. Get up.</p>
<p class="text-sm text-muted">Buy in. Beat them. Get up.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
<p class="mt-4 text-sm text-muted">
A real cash game against bots that were trained on it, not scripted. No multiple, no
3:2 — you leave with whatever is in front of you, less the rake on the pots you win.
</p>
</a>
{{range .Soon}}
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
<div class="rounded-3xl bg-card/60 p-6 shadow-pete border-2 border-dashed border-ink/15">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl grayscale">{{.Emoji}}</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-ink/5 text-2xl grayscale">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold text-[color:var(--ink)]/50">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/40">{{.Blurb}}</p>
<h3 class="font-display text-xl font-bold text-muted">{{.Name}}</h3>
<p class="text-sm text-muted">{{.Blurb}}</p>
</div>
<span class="ml-auto shrink-0 rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-[color:var(--ink)]/40">Soon</span>
<span class="ml-auto shrink-0 rounded-full border-2 border-ink/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-muted">Soon</span>
</div>
</div>
{{end}}
@@ -139,9 +139,9 @@
</div>
</section>
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section class="rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10">
<h2 class="font-display text-xl font-bold mb-2">House rules</h2>
<ul class="space-y-1.5 text-sm text-[color:var(--ink)]/70">
<ul class="space-y-1.5 text-sm text-muted">
<li>· A chip is a euro. Nothing is worth more here than it is out there.</li>
<li>· You can have {{.Cap}} chips in front of you at most. That's the limit for one sitting.</li>
<li>· The house takes {{.RakePct}}% of your winnings. Never your stake: a push hands your bet straight back.</li>
@@ -153,4 +153,4 @@
</div>
{{end}}
{{define "scripts"}}<script src="/static/js/games.js" defer></script>{{end}}
{{define "scripts"}}<script src="{{asset "/static/js/games.js"}}" defer></script>{{end}}
+9 -9
View File
@@ -10,16 +10,16 @@
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 sm:p-10 shadow-pete border-2 border-[color:var(--ink)]/10">
<section class="rounded-3xl bg-card p-6 sm:p-10 shadow-pete border-2 border-ink/10">
{{/* No decoration in here on purpose. The chip stacks want the casino's JS,
which the door doesn't load, and an emoji big enough to balance the hero
is an emoji that isn't on every machine. The share card carries the
pictures; the door carries the way in. */}}
<div class="flex flex-wrap items-center justify-between gap-6">
<div class="min-w-0 max-w-2xl">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[color:var(--accent)]">The doors are open</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-accent">The doors are open</p>
<h1 class="mt-2 font-display text-3xl sm:text-5xl font-bold">{{.Room.Name}}</h1>
<p class="mt-3 text-[color:var(--ink)]/70">
<p class="mt-3 text-muted">
Six tables, played against the house and its bots, for the same euros you
already have. A chip is worth a euro, the house takes {{.RakePct}}% of what you
win and nothing at all when you lose, and whatever you don't spend comes
@@ -27,10 +27,10 @@
</p>
<div class="mt-6 flex flex-wrap items-center gap-3">
<a href="/auth/login?next=/games"
class="inline-flex items-center gap-2 rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 font-display text-lg font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 hover:shadow-pete-lg transition">
Sign in and sit down
</a>
<span class="text-sm text-[color:var(--ink)]/45">You'll need a parodia.dev account.</span>
<span class="text-sm text-muted">You'll need a parodia.dev account.</span>
</div>
</div>
@@ -49,7 +49,7 @@
</div>
</section>
<p class="pb-2 text-center text-sm text-[color:var(--ink)]/40">
<p class="pb-2 text-center text-sm text-muted">
{{if eq .Room.Slug "casinopolis"}}The lights come on at six, and the place changes its name.
{{else}}It goes back to being Casinopolis at six in the morning.{{end}}
</p>
@@ -58,12 +58,12 @@
{{define "_door_table"}}
<a href="{{.Href}}"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
class="group rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">{{.Emoji}}</span>
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-accent/25 text-2xl">{{.Emoji}}</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">{{.Name}}</h3>
<p class="text-sm text-[color:var(--ink)]/60">{{.Line}}</p>
<p class="text-sm text-muted">{{.Line}}</p>
</div>
</div>
</a>
+7 -7
View File
@@ -25,7 +25,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="stylesheet" href="{{asset "/static/css/output.css"}}">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,3 27,9.5 27,22.5 16,29 5,22.5 5,9.5' fill='%23f2b53d' stroke='%232b2118' stroke-width='2.5' stroke-linejoin='round'/%3E%3C/svg%3E" type="image/svg+xml">
<meta name="theme-color" content="#17231d">
<script>
@@ -59,7 +59,7 @@
})();
</script>
</head>
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)]">
<body class="min-h-screen bg-[color:var(--bg)] text-ink">
<div class="pointer-events-none fixed inset-0 -z-10 cs-room" aria-hidden="true"></div>
<header class="mx-auto max-w-5xl px-4 pt-6 pb-4 sm:pt-10">
@@ -73,14 +73,14 @@
{{if .User}}
<a href="/auth/logout" title="Signed in as {{.User.Display}} — sign out"
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-card px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<span class="grid h-6 w-6 place-items-center rounded-full bg-accent text-xs font-bold text-[#20180c]">{{.User.Initial}}</span>
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a>
{{else}}
{{/* Nobody is signed in, which on any page but the front door can't happen. */}}
<a href="/auth/login?next=/games"
class="inline-flex shrink-0 items-center rounded-full bg-[color:var(--accent)] px-4 py-1.5 text-sm font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 transition">
class="inline-flex shrink-0 items-center rounded-full bg-accent px-4 py-1.5 text-sm font-bold text-[#20180c] shadow-pete hover:-translate-y-0.5 transition">
Sign in
</a>
{{end}}
@@ -91,7 +91,7 @@
{{block "main" .}}{{end}}
</main>
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-[color:var(--ink)]/40">
<footer class="mx-auto max-w-5xl px-4 pb-10 text-center text-xs text-muted">
Play for what you can lose. Cash out whenever you like.
</footer>
@@ -99,7 +99,7 @@
wants it and none of them owns it, and because it has to be defined before
casino-fx.js — which reaches for it on every chip it throws. `defer` runs
these in document order, so that's guaranteed. -->
<script src="/static/js/casino-sfx.js" defer></script>
<script src="{{asset "/static/js/casino-sfx.js"}}" defer></script>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}
+20 -20
View File
@@ -5,7 +5,7 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
@@ -13,14 +13,14 @@
</a>
<h1 class="font-display text-3xl font-bold">Hangman</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win</p>
<p class="text-sm text-muted">{{.MaxWrong}} lives · every wrong guess costs you a tenth of the win</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The gallows is on it because the gallows is the meter: it counts
down your lives and your winnings at the same time. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-ink/10">
<div class="pete-rack" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
@@ -102,47 +102,47 @@
</section>
<!-- Guessing: shown while a game is live. -->
<section data-guessing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-guessing class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div data-keyboard class="pete-keys"></div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<label for="solve" class="sr-only">Guess the whole phrase</label>
<input id="solve" data-solve-input type="text" autocomplete="off" enterkeyhint="go"
placeholder="Or just say what it is…"
class="min-w-0 flex-1 rounded-full bg-[color:var(--ink)]/5 px-4 py-2.5 text-sm font-semibold
border-2 border-[color:var(--ink)]/10 focus:outline-none focus:border-[color:var(--accent)]">
class="min-w-0 flex-1 rounded-full bg-ink/5 px-4 py-2.5 text-sm font-semibold
border-2 border-ink/10 focus:outline-none focus:border-accent">
<button type="button" data-solve
class="rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
class="rounded-full bg-accent px-6 py-2.5 font-display font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Solve
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<p class="mt-3 text-center text-xs text-muted">
Type a letter to guess it. A wrong solve costs a life, same as a wrong letter.
</p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Betting: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-betting class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How long a phrase?</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">How long a phrase?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tiers}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
<span class="font-display text-lg font-bold tabular-nums text-accent">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-0.5 text-xs text-muted">{{.Blurb}}</p>
</button>
{{end}}
</div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
@@ -154,23 +154,23 @@
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
class="rounded-full px-3 py-2 text-sm font-semibold text-muted hover:text-ink transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Play
</button>
</div>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/hangman.js" defer></script>
<script src="{{asset "/static/js/casino-fx.js"}}" defer></script>
<script src="{{asset "/static/js/games.js"}}" defer></script>
<script src="{{asset "/static/js/hangman.js"}}" defer></script>
{{end}}
+44 -44
View File
@@ -5,7 +5,7 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
@@ -13,7 +13,7 @@
</a>
<h1 class="font-display text-3xl font-bold">Texas Hold'em</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Buy in, play as long as you like, leave with what's in front of you</p>
<p class="text-sm text-muted">Buy in, play as long as you like, leave with what's in front of you</p>
</div>
{{template "_chipbar" .}}
@@ -23,7 +23,7 @@
travelling between one of those and the pot — which is the whole difference
between poker and every other game in the room, where the chips only ever
move between you and the house. -->
<section class="pete-felt pete-poker relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<section class="pete-felt pete-poker relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-ink/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0">
@@ -75,95 +75,95 @@
</section>
<!-- Acting: shown when the hand is yours to play. -->
<section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-acting class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-move="fold"
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full border-2 border-ink/15 bg-card px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Fold
</button>
<button type="button" data-move="check"
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full border-2 border-ink/15 bg-card px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Check
</button>
<button type="button" data-move="call"
class="rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
class="rounded-full bg-accent px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Call <span data-call-amount class="tabular-nums"></span>
</button>
</div>
<div data-raise-row class="mt-4 flex flex-wrap items-center gap-3 border-t-2 border-[color:var(--ink)]/5 pt-4">
<div data-raise-row class="mt-4 flex flex-wrap items-center gap-3 border-t-2 border-ink/5 pt-4">
<div class="pete-raise">
<input type="range" data-raise-slider aria-label="How much to raise to">
<span data-raise-to class="pete-raise-to">0</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<button type="button" data-raise-preset="0.5" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">½ pot</button>
<button type="button" data-raise-preset="1" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">Pot</button>
<button type="button" data-raise-preset="max" class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1.5 text-xs font-bold hover:bg-[color:var(--ink)]/5 transition">Max</button>
<button type="button" data-raise-preset="0.5" class="rounded-full border-2 border-ink/10 px-3 py-1.5 text-xs font-bold hover:bg-ink/5 transition">½ pot</button>
<button type="button" data-raise-preset="1" class="rounded-full border-2 border-ink/10 px-3 py-1.5 text-xs font-bold hover:bg-ink/5 transition">Pot</button>
<button type="button" data-raise-preset="max" class="rounded-full border-2 border-ink/10 px-3 py-1.5 text-xs font-bold hover:bg-ink/5 transition">Max</button>
</div>
<button type="button" data-move="raise"
class="ml-auto rounded-full bg-[color:var(--accent)] px-7 py-3 font-display text-lg font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-7 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
<span data-raise-verb>Raise to</span> <span data-raise-label class="tabular-nums"></span>
</button>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Between hands: deal the next one, put more chips out, or get up. -->
<section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-between class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">In front of you</div>
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">
Bought in for <span data-bought-in class="tabular-nums">0</span> · the house has taken <span data-session-rake class="tabular-nums">0</span> in rake
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-topup
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full border-2 border-ink/15 bg-card px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Top up
</button>
<button type="button" data-leave
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full border-2 border-ink/15 bg-card px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Get up
</button>
<button type="button" data-deal
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Next hand
</button>
</div>
</div>
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-between-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- The rail talk. Chat lives at the felt and does not leave it for Matrix;
every line is stamped with the hand it was said during. Shown while you're
seated. -->
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-chat class="hidden rounded-3xl bg-card p-4 shadow-pete border-2 border-ink/10">
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
<form data-chat-form class="mt-3 flex items-center gap-2">
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
class="flex-1 min-w-0 rounded-full border-2 border-ink/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-accent">
<button type="submit"
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
class="rounded-full bg-accent px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
Say
</button>
</form>
</section>
<!-- Sitting down: shown when you aren't at a table. -->
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-sitting class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">What are you playing for?</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">What are you playing for?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Stakes}}
<button type="button" data-tier="{{.Slug}}"
@@ -171,10 +171,10 @@
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.SB}}/{{.BB}}</span>
<span class="font-display text-lg font-bold tabular-nums text-accent">{{.SB}}/{{.BB}}</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-muted">
Buy in {{.MinBuy}}–{{.MaxBuy}}
</p>
</button>
@@ -183,7 +183,7 @@
<div class="mt-5 grid gap-5 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How many of them?</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">How many of them?</div>
<div class="mt-2 flex flex-wrap gap-1.5" data-bots>
<button type="button" data-bot-count="1" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">1</button>
<button type="button" data-bot-count="2" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">2</button>
@@ -191,21 +191,21 @@
<button type="button" data-bot-count="4" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">4</button>
<button type="button" data-bot-count="5" class="pete-tier rounded-xl border-2 px-4 py-2 text-sm font-bold transition">5</button>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-bots-note></p>
<p class="mt-1.5 text-xs text-muted" data-bots-note></p>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Buying in for</div>
<div class="mt-2 flex items-center gap-3">
<input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
<p class="mt-1.5 text-xs text-muted" data-buyin-note></p>
</div>
</div>
<button type="button" data-sit
class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="mt-5 w-full rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Sit down
</button>
@@ -213,22 +213,22 @@
<!-- Or pull up a chair at a table someone else has already opened. Bots keep
every open table full, so there is always a seat to take. -->
<div data-lobby-wrap class="mt-6 hidden">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Or join a table in progress</div>
<div data-lobby class="mt-2 grid gap-2"></div>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<p class="mt-3 text-center text-xs text-muted">
The bots are trained, not scripted. The house takes {{.RakePct}}% of a pot that sees a flop, capped at three big blinds, and nothing at all from a hand that doesn't.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/casino-cards.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/holdem.js" defer></script>
<script src="{{asset "/static/js/casino-fx.js"}}" defer></script>
<script src="{{asset "/static/js/casino-cards.js"}}" defer></script>
<script src="{{asset "/static/js/games.js"}}" defer></script>
<script src="{{asset "/static/js/holdem.js"}}" defer></script>
{{end}}
+14 -14
View File
@@ -5,7 +5,7 @@
<h1 class="font-display text-4xl sm:text-6xl font-bold leading-tight">
a friendlier news feed.
</h1>
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70">
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-muted">
{{if .PostingEnabled}}
Pete reads RSS, sorts stories, and posts them to Matrix.
Glowing cards are the ones he's posted.
@@ -13,8 +13,8 @@
Pete reads RSS and sorts stories into channels, fresh from his feeds.
{{end}}
</p>
<p class="mx-auto mt-5 inline-flex items-center gap-2 rounded-full bg-[color:var(--card)] px-4 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10">
<img src="/static/img/pete.avif" alt="Pete" width="24" height="24"
<p class="mx-auto mt-5 inline-flex items-center gap-2 rounded-full bg-card px-4 py-2 text-sm font-semibold shadow-pete border-2 border-ink/10">
<img src="{{asset "/static/img/pete.avif"}}" alt="Pete" width="24" height="24"
class="h-6 w-6 rounded-full object-cover">
<span data-pete-greeting>Hey, I'm Pete. Here's what's new.</span>
</p>
@@ -28,7 +28,7 @@
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">🔥</span> Popular this week
</h2>
<span class="text-xs text-[color:var(--ink)]/50">what readers are opening most</span>
<span class="text-xs text-muted">what readers are opening most</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{range .Trending}}
@@ -44,7 +44,7 @@
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">✨</span> For you
</h2>
<a href="/for-you" class="text-xs font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">more →</a>
<a href="/for-you" class="text-xs font-semibold text-muted hover:text-ink">more →</a>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{range .ForYou}}
@@ -60,7 +60,7 @@
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">📡</span> Pete just posted
</h2>
<span class="text-xs text-[color:var(--ink)]/50">most recent posts to Matrix</span>
<span class="text-xs text-muted">most recent posts to Matrix</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .JustPosted}}
@@ -75,34 +75,34 @@
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">📊</span> Channels
</h2>
<span class="text-xs text-[color:var(--ink)]/50">{{if .PostingEnabled}}last 24 hours{{else}}what Pete's tracking{{end}}</span>
<span class="text-xs text-muted">{{if .PostingEnabled}}last 24 hours{{else}}what Pete's tracking{{end}}</span>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{{range .Stats}}
<a href="/{{.Channel.Slug}}"
class="block rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete p-4 hover:-translate-y-1 hover:shadow-pete-lg transition">
class="block rounded-3xl bg-card border-2 border-ink/10 shadow-pete p-4 hover:-translate-y-1 hover:shadow-pete-lg transition">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center h-9 w-9 rounded-2xl bg-theme-{{.Channel.Theme}} text-white text-lg">
{{.Channel.Emoji}}
</span>
<span class="font-display text-lg font-semibold">{{.Channel.Title}}</span>
</div>
<dl class="mt-3 space-y-1 text-xs text-[color:var(--ink)]/70">
<dl class="mt-3 space-y-1 text-xs text-muted">
{{if $.PostingEnabled}}
<div class="flex justify-between gap-2">
<dt>last post</dt>
<dd class="font-semibold text-[color:var(--ink)]">
<dd class="font-semibold text-ink">
{{if .HasLastPosted}}{{timeAgo .LastPostedAt}}{{else}}—{{end}}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt>posted 24h</dt>
<dd class="font-semibold text-[color:var(--ink)]">{{.PostsToday}}</dd>
<dd class="font-semibold text-ink">{{.PostsToday}}</dd>
</div>
{{end}}
<div class="flex justify-between gap-2">
<dt>stories</dt>
<dd class="font-semibold text-[color:var(--ink)]">{{.Total}}</dd>
<dd class="font-semibold text-ink">{{.Total}}</dd>
</div>
</dl>
</a>
@@ -115,7 +115,7 @@
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">🗞️</span> Latest across the feed
</h2>
<span class="text-xs text-[color:var(--ink)]/50">newest first, all channels</span>
<span class="text-xs text-muted">newest first, all channels</span>
</div>
{{if .Latest}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -124,7 +124,7 @@
{{end}}
</div>
{{else}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-8 text-center text-[color:var(--ink)]/60">
<div class="rounded-3xl bg-card border-2 border-dashed border-ink/15 p-8 text-center text-muted">
Nothing here yet. Pete's still listening for the first story.
</div>
{{end}}
+60 -60
View File
@@ -9,14 +9,14 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="icon" href="/static/img/pete.avif" type="image/avif">
<link rel="stylesheet" href="{{asset "/static/css/output.css"}}">
<link rel="icon" href="{{asset "/static/img/pete.avif"}}" type="image/avif">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#fbf3e3">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Pete">
<link rel="apple-touch-icon" href="/static/img/apple-touch-icon.png">
<link rel="apple-touch-icon" href="{{asset "/static/img/apple-touch-icon.png"}}">
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — all stories" href="/feed.xml">
<link rel="alternate" type="application/feed+json" title="{{.SiteTitle}} — all stories" href="/feed.json">
{{if .Active}}{{if ne .Active "bookmarks"}}
@@ -40,32 +40,32 @@
})();
</script>
</head>
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)] transition-colors duration-1000">
<body class="min-h-screen bg-[color:var(--bg)] text-ink transition-colors duration-1000">
<div class="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--bg-grad)] transition-colors duration-1000"></div>
<canvas id="pete-weather" class="pointer-events-none fixed inset-0 -z-[5] h-full w-full" aria-hidden="true"></canvas>
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
<div class="flex items-center justify-between gap-3 sm:gap-4">
<a href="/" class="flex items-center gap-3 group min-w-0">
<img src="/static/img/pete.avif" alt="Pete" width="48" height="48"
class="h-12 w-12 shrink-0 rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 object-cover group-hover:-rotate-6 transition-transform">
<img src="{{asset "/static/img/pete.avif"}}" alt="Pete" width="48" height="48"
class="h-12 w-12 shrink-0 rounded-2xl bg-card shadow-pete border-2 border-ink/10 object-cover group-hover:-rotate-6 transition-transform">
<span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span>
<span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span>
<span class="hidden sm:inline rounded-full bg-accent/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-muted" data-phase-label>day</span>
</a>
{{if .AuthEnabled}}
{{if .User}}
<a href="/auth/logout" data-account
title="Signed in as {{.User.Display}}{{if .User.Email}} · {{.User.Email}}{{end}} — sign out"
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-white text-xs font-bold">{{.User.Initial}}</span>
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-card px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<span class="grid h-6 w-6 place-items-center rounded-full bg-accent text-white text-xs font-bold">{{.User.Initial}}</span>
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
</a>
{{else}}
<a href="/auth/login?next={{.Path}}" data-signin
title="Sign in to sync your preferences"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-card px-3 py-2 text-sm font-semibold shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-muted">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
@@ -78,7 +78,7 @@
<div class="mt-3 flex flex-wrap items-center gap-2 sm:gap-3">
<button type="button" data-settings-trigger
title="Feed settings"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
class="inline-flex shrink-0 items-center justify-center rounded-full bg-card p-2 shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<circle cx="12" cy="12" r="3"></circle>
@@ -88,7 +88,7 @@
</button>
<button type="button" data-reader-open
title="Feed mode — read one story at a time (press f)"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
class="inline-flex shrink-0 items-center justify-center rounded-full bg-card p-2 shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M2 4h6a3 3 0 0 1 3 3v13a2.5 2.5 0 0 0-2.5-2.5H2z"></path>
@@ -99,7 +99,7 @@
{{if .Weather.Variant}}
<button type="button" data-weather-toggle aria-pressed="true"
title="Toggle weather animation"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
class="inline-flex shrink-0 items-center justify-center rounded-full bg-card p-2 shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 transition-colors"
data-weather-star fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<polygon points="12,2.7 14.85,9.27 21.95,10.05 16.6,14.83 18.18,21.8 12,18.13 5.82,21.8 7.4,14.83 2.05,10.05 9.15,9.27"/>
@@ -109,14 +109,14 @@
{{end}}
<button type="button" data-weather-loc
title="Set your location"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-card px-3 py-2 text-sm font-semibold shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<span data-weather-chip class="tabular-nums">📍 Weather</span>
</button>
<span data-aqi-chip
class="hidden shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 tabular-nums"></span>
class="hidden shrink-0 items-center gap-1.5 rounded-full bg-card px-3 py-2 text-sm font-semibold shadow-pete border-2 border-ink/10 tabular-nums"></span>
<a href="/status" data-status-link
title="Source health"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "status"}} bg-[color:var(--accent)]/20{{end}}">
class="inline-flex shrink-0 items-center justify-center rounded-full bg-card p-2 shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition{{if eq .Active "status"}} bg-accent/20{{end}}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M3 12h4l2 6 4-14 2 8h6"></path>
@@ -126,7 +126,7 @@
{{if .User}}
<a href="/bookmarks" data-bookmarks-link
title="Your bookmarks"
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "bookmarks"}} bg-[color:var(--accent)]/20{{end}}">
class="inline-flex shrink-0 items-center justify-center rounded-full bg-card p-2 shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition{{if eq .Active "bookmarks"}} bg-accent/20{{end}}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path d="M6 2h12a1 1 0 0 1 1 1v18l-7-4-7 4V3a1 1 0 0 1 1-1z"></path>
@@ -136,29 +136,29 @@
{{end}}
<div class="flex basis-full items-center gap-2 min-w-0 max-w-full sm:basis-auto sm:ml-auto">
<button type="button" data-search-trigger title="Search (⌘K)"
class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
class="flex shrink-0 items-center gap-2 rounded-full bg-card px-3 py-2 text-sm shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-muted">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<span class="hidden sm:inline text-[color:var(--ink)]/60 font-semibold">Search</span>
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
<span class="hidden sm:inline text-muted font-semibold">Search</span>
<kbd class="hidden sm:inline-flex items-center rounded-md bg-ink/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-muted">⌘K</kbd>
</button>
<nav class="pete-channel-nav flex min-w-0 items-center gap-1 sm:gap-2 overflow-x-auto rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
<nav class="pete-channel-nav flex min-w-0 items-center gap-1 sm:gap-2 overflow-x-auto rounded-full bg-card p-1 shadow-pete border-2 border-ink/10">
{{$active := .Active}}
{{if .User}}
<a href="/for-you"
title="Your personalized feed"
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
{{if eq $active "for-you"}}bg-[color:var(--accent)] text-[#1c1305] shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
{{if eq $active "for-you"}}bg-accent text-[#1c1305] shadow-sm{{else}}hover:bg-ink/5{{end}}">
<span aria-hidden="true" class="mr-1">✨</span><span class="hidden sm:inline">For you</span>
</a>
{{end}}
{{range .Channels}}
<a href="/{{.Slug}}"
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-ink/5{{end}}">
<span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span>
</a>
{{end}}
@@ -167,37 +167,37 @@
</div>
</header>
<div id="pete-search" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
<div class="w-full max-w-2xl rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
<div id="pete-search" class="hidden fixed inset-0 z-50 bg-ink/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
<div class="w-full max-w-2xl rounded-3xl bg-card shadow-pete-lg border-2 border-ink/10 overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 border-b border-ink/10">
<kbd class="hidden sm:inline-flex items-center rounded-md bg-ink/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-muted">⌘K</kbd>
<input type="search" data-search-input autocomplete="off" spellcheck="false"
placeholder="Search headlines…"
class="flex-1 bg-transparent text-lg outline-none placeholder:text-[color:var(--ink)]/40">
class="flex-1 bg-transparent text-lg outline-none placeholder:text-muted">
</div>
<div data-search-list class="max-h-[60vh] overflow-y-auto p-3 space-y-1"></div>
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs text-[color:var(--ink)]/50">
<div class="px-5 py-3 border-t border-ink/10 flex items-center justify-between text-xs text-muted">
<span><kbd class="font-mono">↑↓</kbd> navigate · <kbd class="font-mono">⏎</kbd> open · <kbd class="font-mono">esc</kbd> close</span>
<span data-search-meta></span>
</div>
</div>
</div>
<div id="pete-settings" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
<div id="pete-settings" class="hidden fixed inset-0 z-50 bg-ink/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
<div class="w-full max-w-md rounded-3xl bg-card shadow-pete-lg border-2 border-ink/10 overflow-hidden">
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-ink/10">
<h2 class="font-display text-lg font-bold">Feed settings</h2>
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-muted hover:bg-ink/5">esc</button>
</div>
{{if .PushEnabled}}{{if .User}}
<div data-push-section hidden class="px-5 pt-4 pb-1">
<div class="flex items-center justify-between gap-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
<div class="flex items-center justify-between gap-3 rounded-2xl bg-ink/5 px-4 py-3">
<div class="min-w-0">
<div class="text-sm font-bold">🔔 Notifications</div>
<div data-push-note class="text-xs text-[color:var(--ink)]/60">Get a nudge when new stories land.</div>
<div data-push-note class="text-xs text-muted">Get a nudge when new stories land.</div>
</div>
<button type="button" data-push-toggle aria-pressed="false"
class="shrink-0 rounded-full bg-[color:var(--accent)] px-3 py-2 text-sm font-semibold text-[#1c1305] shadow-sm hover:brightness-105 transition disabled:opacity-50 disabled:cursor-not-allowed">
class="shrink-0 rounded-full bg-accent px-3 py-2 text-sm font-semibold text-[#1c1305] shadow-sm hover:brightness-105 transition disabled:opacity-50 disabled:cursor-not-allowed">
Turn on notifications
</button>
</div>
@@ -206,37 +206,37 @@
because a category toggle with no subscription behind it is a switch
wired to nothing. Every box starts unchecked: turning on news
notifications is not consent to be told about the game. -->
<div data-adv-push hidden class="mt-2 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
<div data-adv-push hidden class="mt-2 rounded-2xl bg-ink/5 px-4 py-3">
<div class="text-sm font-bold">⚔️ Adventure alerts</div>
<div class="text-xs text-[color:var(--ink)]/60">Pick what's worth a buzz. Off unless you say so.</div>
<div class="text-xs text-muted">Pick what's worth a buzz. Off unless you say so.</div>
<div class="mt-2 space-y-1.5">
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="siege" class="mt-0.5 accent-[color:var(--accent)]">
<input type="checkbox" data-adv-cat="siege" class="mt-0.5 accent-accent">
<span><span class="font-semibold">The Siege</span>: when a world boss camps outside town, and when it's settled.</span>
</label>
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="run" class="mt-0.5 accent-[color:var(--accent)]">
<input type="checkbox" data-adv-cat="run" class="mt-0.5 accent-accent">
<span><span class="font-semibold">Your expeditions</span>: cleared, backed out, or worse.</span>
</label>
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="departure" class="mt-0.5 accent-[color:var(--accent)]">
<input type="checkbox" data-adv-cat="departure" class="mt-0.5 accent-accent">
<span><span class="font-semibold">Wandering off</span>: your adventurer got bored and left without you.</span>
</label>
<label class="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" data-adv-cat="contract" class="mt-0.5 accent-[color:var(--accent)]">
<input type="checkbox" data-adv-cat="contract" class="mt-0.5 accent-accent">
<span><span class="font-semibold">Contracts on you</span>: somebody paid to have something sent after you.</span>
</label>
</div>
<div data-adv-push-note class="mt-2 text-xs text-[color:var(--ink)]/50"></div>
<div data-adv-push-note class="mt-2 text-xs text-muted"></div>
</div>
{{end}}
</div>
{{end}}{{end}}
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
<p class="px-5 pt-3 text-xs text-muted">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
<button type="button" data-settings-reset class="text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] font-semibold">Enable all</button>
<span data-settings-meta class="text-[color:var(--ink)]/50"></span>
<div class="px-5 py-3 border-t border-ink/10 flex items-center justify-between text-xs">
<button type="button" data-settings-reset class="text-muted hover:text-ink font-semibold">Enable all</button>
<span data-settings-meta class="text-muted"></span>
</div>
</div>
</div>
@@ -302,8 +302,8 @@
{{block "main" .}}{{end}}
</main>
<footer class="mx-auto max-w-6xl px-4 pb-10 text-center text-sm text-[color:var(--ink)]/50">
<img src="/static/img/pete.avif" alt="" width="20" height="20"
<footer class="mx-auto max-w-6xl px-4 pb-10 text-center text-sm text-muted">
<img src="{{asset "/static/img/pete.avif"}}" alt="" width="20" height="20"
class="inline-block h-5 w-5 rounded-full object-cover align-[-4px] mr-1">
served by <span class="font-semibold">Pete</span> · also a Matrix bot · <span data-clock>—</span>
</footer>
@@ -342,15 +342,15 @@
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
window.PETE_TTS = {{.TTS}};
</script>
<script src="/static/js/prefs.js" defer></script>
<script src="/static/js/weather-2d.js" defer></script>
<script src="/static/js/weather-gl.js" defer></script>
<script src="/static/js/weather.js" defer></script>
<script src="/static/js/weather-forecast.js" defer></script>
<script src="/static/js/search.js" defer></script>
<script src="/static/js/settings.js" defer></script>
<script src="/static/js/reader.js" defer></script>
<script src="/static/js/pwa.js" defer></script>
<script src="{{asset "/static/js/prefs.js"}}" defer></script>
<script src="{{asset "/static/js/weather-2d.js"}}" defer></script>
<script src="{{asset "/static/js/weather-gl.js"}}" defer></script>
<script src="{{asset "/static/js/weather.js"}}" defer></script>
<script src="{{asset "/static/js/weather-forecast.js"}}" defer></script>
<script src="{{asset "/static/js/search.js"}}" defer></script>
<script src="{{asset "/static/js/settings.js"}}" defer></script>
<script src="{{asset "/static/js/reader.js"}}" defer></script>
<script src="{{asset "/static/js/pwa.js"}}" defer></script>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}
+15 -15
View File
@@ -8,39 +8,39 @@
<li class="realm-zone{{if .Unbeaten}} realm-zone-unbeaten{{end}}{{if .Busy}} realm-zone-busy{{end}}">
<div class="flex items-baseline justify-between gap-3">
<h3 class="font-display font-bold text-base flex-1 min-w-0 truncate">{{.Display}}</h3>
{{if .Levels}}<span class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 shrink-0">{{.Levels}}</span>{{end}}
{{if .Levels}}<span class="text-[11px] uppercase tracking-wider text-muted shrink-0">{{.Levels}}</span>{{end}}
</div>
{{if .Atmosphere}}
<p class="mt-1 text-xs text-[color:var(--ink)]/55 leading-relaxed">{{.Atmosphere}}</p>
<p class="mt-1 text-xs text-muted leading-relaxed">{{.Atmosphere}}</p>
{{end}}
<div class="mt-2.5 text-xs">
{{if .Unbeaten}}
<p class="realm-unbeaten-line">Nobody has ever come back out of it.</p>
{{else}}
<p class="text-[color:var(--ink)]/60">
<p class="text-muted">
{{/* FirstClearBy empty with Clears > 0 is the anonymised case: the zone
has been beaten, the clearer opted out. It must not read as unbeaten. */}}
First through:
{{if .FirstClearToken}}<a href="/adventure/who/{{.FirstClearToken}}" class="font-semibold hover:text-theme-adventure transition">{{.FirstClearBy}}</a>
{{else if .FirstClearBy}}<span class="font-semibold">{{.FirstClearBy}}</span>
{{else}}<span class="italic text-[color:var(--ink)]/45">somebody who'd rather not say</span>{{end}}
{{if .FirstWhen}}<span class="text-[color:var(--ink)]/40"> · {{.FirstWhen}}</span>{{end}}
{{else}}<span class="italic text-muted">somebody who'd rather not say</span>{{end}}
{{if .FirstWhen}}<span class="text-muted"> · {{.FirstWhen}}</span>{{end}}
</p>
<p class="mt-0.5 text-[color:var(--ink)]/45 tabular-nums">
<p class="mt-0.5 text-muted tabular-nums">
{{.Clears}} clear{{if ne .Clears 1}}s{{end}} by {{.Clearers}} adventurer{{if ne .Clearers 1}}s{{end}}
</p>
{{end}}
</div>
{{if .Occupants}}
<div class="mt-2.5 pt-2.5 border-t border-[color:var(--ink)]/10">
<div class="mt-2.5 pt-2.5 border-t border-ink/10">
<p class="text-[11px] uppercase tracking-wider text-theme-adventure font-semibold">In there now</p>
<p class="mt-1 text-xs">
{{range $i, $o := .Occupants}}{{if $i}}<span class="text-[color:var(--ink)]/30">, </span>{{end}}<!--
{{range $i, $o := .Occupants}}{{if $i}}<span class="text-ink/30">, </span>{{end}}<!--
-->{{if $o.Token}}<a href="/adventure/who/{{$o.Token}}" class="font-semibold hover:text-theme-adventure transition">{{$o.Name}}</a>{{else}}<span class="font-semibold">{{$o.Name}}</span>{{end}}<!--
-->{{if $o.Day}}<span class="text-[color:var(--ink)]/40"> (day {{$o.Day}})</span>{{end}}{{end}}
-->{{if $o.Day}}<span class="text-muted"> (day {{$o.Day}})</span>{{end}}{{end}}
</p>
</div>
{{end}}
@@ -80,8 +80,8 @@
</header>
{{if not .Realm.Known}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">
<div class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<p class="text-sm text-muted">
I haven't had the survey through from the field yet. When it lands, the whole
world lives on this page.
</p>
@@ -92,11 +92,11 @@
<section class="mt-8{{if .Postgame}} realm-band-postgame rounded-3xl p-5 sm:p-6{{end}}">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-1">
<h2 class="font-display text-xl sm:text-2xl font-bold">{{.Label}}</h2>
<span class="text-xs text-[color:var(--ink)]/45 tabular-nums shrink-0">
<span class="text-xs text-muted tabular-nums shrink-0">
{{.Cleared}} of {{len .Zones}} beaten
</span>
</div>
{{if .Blurb}}<p class="text-sm text-[color:var(--ink)]/55 mb-4 max-w-2xl">{{.Blurb}}</p>{{end}}
{{if .Blurb}}<p class="text-sm text-muted mb-4 max-w-2xl">{{.Blurb}}</p>{{end}}
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{range .Zones}}{{template "realmzone" .}}{{end}}
</ul>
@@ -104,8 +104,8 @@
{{end}}
{{if .Realm.Known}}
<p class="mt-8 text-xs text-[color:var(--ink)]/40 text-center">
Say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono">!expedition start</code>
<p class="mt-8 text-xs text-muted text-center">
Say <code class="rounded bg-ink/[.08] px-1.5 py-0.5 font-mono">!expedition start</code>
to me in Matrix to pick one and go.
</p>
{{end}}
+11 -11
View File
@@ -3,11 +3,11 @@
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
<nav class="mb-4 flex items-center gap-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-muted hover:text-ink transition">
<span aria-hidden="true">←</span> All dispatches
</a>
{{if .WhoURL}}
<a href="{{.WhoURL}}" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<a href="{{.WhoURL}}" class="inline-flex items-center gap-1.5 text-sm font-semibold text-muted hover:text-ink transition">
{{.Name}}'s sheet <span aria-hidden="true">→</span>
</a>
{{end}}
@@ -29,17 +29,17 @@
happened: the game's own model reading the finished run back. Guarded at
ingest, and absent entirely when the model is off — which is why it sits
above the numbers rather than instead of them. -->
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Summary}}</p>
<section class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-ink/90 whitespace-pre-line">{{.Summary}}</p>
</section>
{{end}}
{{if .Stats}}
<section class="mt-6 grid grid-cols-2 sm:grid-cols-3 gap-2">
{{range .Stats}}
<div class="rounded-2xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-2 py-3 text-center shadow-pete">
<div class="rounded-2xl bg-card border-2 border-ink/10 px-2 py-3 text-center shadow-pete">
<div class="font-display text-2xl font-bold leading-none tabular-nums">{{.Value}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">{{.Label}}</div>
<div class="text-[10px] uppercase tracking-wider text-muted mt-1.5">{{.Label}}</div>
</div>
{{end}}
</section>
@@ -48,7 +48,7 @@
{{if .Turning}}
<!-- The worst single thing that happened, pulled out of the middle of the log
where it would otherwise read as one line among forty. -->
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-3">Where it turned</h2>
<p class="flex items-baseline gap-2.5">
<span aria-hidden="true">{{.Turning.Emoji}}</span>
@@ -58,10 +58,10 @@
</section>
{{end}}
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<div class="flex items-baseline justify-between mb-4 gap-3">
<h2 class="font-display text-xl font-bold">Room by room</h2>
{{if .Live}}<span class="text-sm text-[color:var(--ink)]/50">still under way</span>{{end}}
{{if .Live}}<span class="text-sm text-muted">still under way</span>{{end}}
</div>
<ol class="runlog runlog-full">
{{range .Lines}}
@@ -73,9 +73,9 @@
{{end}}
</ol>
{{if .Truncated}}
<p class="mt-4 text-xs text-[color:var(--ink)]/45">Only the last {{len .Lines}} moments of this run are shown — it beat out more than the report keeps.</p>
<p class="mt-4 text-xs text-muted">Only the last {{len .Lines}} moments of this run are shown — it beat out more than the report keeps.</p>
{{end}}
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
<p class="mt-8 pt-6 border-t border-ink/10 text-sm italic text-theme-adventure font-semibold">
Reporting from the realm, this is Pete.
</p>
</section>
+23 -23
View File
@@ -5,16 +5,16 @@
carries no token, so they get no link — their damage still counts and still
holds its rank, they just aren't named. */}}
{{define "defenderrow"}}
<li class="flex items-baseline gap-3 py-1.5 border-b border-[color:var(--ink)]/5 last:border-0">
<li class="flex items-baseline gap-3 py-1.5 border-b border-ink/5 last:border-0">
<span class="flex-1 min-w-0">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition truncate">{{.Name}}</a>
{{else}}<span class="font-semibold text-[color:var(--ink)]/55 italic truncate">{{.Name}}</span>{{end}}
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/40 ml-1.5">lv {{.Level}}</span>{{end}}
{{else}}<span class="font-semibold text-muted italic truncate">{{.Name}}</span>{{end}}
{{if .Level}}<span class="text-xs text-muted ml-1.5">lv {{.Level}}</span>{{end}}
</span>
{{if .Fights}}
<span class="text-xs text-[color:var(--ink)]/50 shrink-0 tabular-nums">{{.Damage}} dmg · {{.Fights}} bout{{if ne .Fights 1}}s{{end}}</span>
<span class="text-xs text-muted shrink-0 tabular-nums">{{.Damage}} dmg · {{.Fights}} bout{{if ne .Fights 1}}s{{end}}</span>
{{else}}
<span class="text-xs text-[color:var(--ink)]/35 shrink-0">not yet in it</span>
<span class="text-xs text-muted shrink-0">not yet in it</span>
{{end}}
</li>
{{end}}
@@ -62,10 +62,10 @@
Matrix command stays on the page for everyone else, because it is still
the only door for a visitor who isn't signed in — and the blow-by-blow of
the fight arrives there whichever door you came through. -->
<div id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
<div id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-card border-2 border-theme-adventure/30 p-5 shadow-pete">
{{if .YouOnBoard}}
{{if .YouFought}}
<p class="text-sm text-[color:var(--ink)]/75">
<p class="text-sm text-ink/75">
<span class="font-semibold text-theme-adventure">You've taken your bout today.</span>
Come back tomorrow. One fight each, per day, and everyone in the right-hand column below still has theirs.
</p>
@@ -75,7 +75,7 @@
the page can't go on saying "unspent" over a fight that just
happened. */}}
<div data-offer>
<p class="text-sm text-[color:var(--ink)]/75 mb-3">
<p class="text-sm text-ink/75 mb-3">
<span class="font-semibold text-theme-adventure">Your bout is unspent.</span>
Damage counts whether you win the fight or not. Turning up is the mechanic, and the blow-by-blow lands in Matrix.
</p>
@@ -88,9 +88,9 @@
</div>
{{end}}
{{else}}
<p class="text-sm text-[color:var(--ink)]/75">
<p class="text-sm text-ink/75">
<span class="font-semibold text-theme-adventure">Taking your bout:</span>
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
say <code class="rounded bg-ink/[.08] px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
to me in Matrix. One a day, each. Damage counts whether you win the fight or not; turning up is the mechanic.
</p>
{{end}}
@@ -103,7 +103,7 @@
<!-- The muster. Two columns, and the right-hand one is the point: a bout not
taken today is damage the pool never sees. -->
<section class="mt-6 grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-xl font-bold">In it today</h2>
<span class="siege-chip siege-chip-fought">{{len .Siege.Fought}}</span>
@@ -111,31 +111,31 @@
{{if .Siege.Fought}}
<ul class="text-sm">{{range .Siege.Fought}}{{template "defenderrow" .}}{{end}}</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Nobody's swung at it yet today. The pool doesn't move on its own.</p>
<p class="text-sm text-muted">Nobody's swung at it yet today. The pool doesn't move on its own.</p>
{{end}}
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-xl font-bold">Bout still going spare</h2>
<span class="siege-chip siege-chip-waiting">{{len .Siege.Waiting}}</span>
</div>
{{if .Siege.Waiting}}
<ul class="text-sm">{{range .Siege.Waiting}}{{template "defenderrow" .}}{{end}}</ul>
<p class="mt-3 text-xs text-[color:var(--ink)]/45">Each of these is a free hit the town hasn't taken.</p>
<p class="mt-3 text-xs text-muted">Each of these is a free hit the town hasn't taken.</p>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Everyone's been out. Good turnout.</p>
<p class="text-sm text-muted">Everyone's been out. Good turnout.</p>
{{end}}
</div>
</section>
{{else}}
<header class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-10 shadow-pete relative overflow-hidden">
<header class="rounded-3xl bg-card border-2 border-ink/10 p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-8 -right-4 text-[12rem] opacity-10 select-none" aria-hidden="true">🏰</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">🏰 The Siege</p>
<p class="text-sm uppercase tracking-[0.2em] text-muted">🏰 The Siege</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">Nothing's camped outside town.</h1>
<p class="mt-3 text-[color:var(--ink)]/70">
<p class="mt-3 text-muted">
{{if .Siege.Known}}
Quiet month so far. One comes for the town every month — a named thing with a shared health pool, and everybody gets a swing a day at it.
{{else}}
@@ -150,21 +150,21 @@
<!-- The history is what makes the live bar mean something. A won siege ends
at zero and draws as an empty track; a lost one shows exactly how much
was left standing when the window shut. -->
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section class="mt-8 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Sieges past</h2>
<ul class="space-y-4">
{{range .Siege.History}}
<li>
<div class="flex items-baseline justify-between gap-3">
<span class="font-semibold flex-1 min-w-0 truncate">{{.BossName}} <span class="text-xs font-normal text-[color:var(--ink)]/40">T{{.Tier}}</span></span>
<span class="font-semibold flex-1 min-w-0 truncate">{{.BossName}} <span class="text-xs font-normal text-muted">T{{.Tier}}</span></span>
<span class="siege-chip {{if .Won}}siege-chip-held{{else}}siege-chip-fell{{end}} shrink-0">{{if .Won}}town held{{else}}broke through{{end}}</span>
</div>
<div class="mt-1.5 siege-track siege-track-sm">
<div class="siege-fill {{if .Won}}siege-fill-spent{{end}}" style="width: {{.HPPercent}}%"></div>
</div>
<div class="mt-1.5 flex flex-wrap items-baseline justify-between gap-x-3 text-xs text-[color:var(--ink)]/50">
<div class="mt-1.5 flex flex-wrap items-baseline justify-between gap-x-3 text-xs text-muted">
<span>
{{if .Won}}Felled by {{.Defenders}} defender{{if ne .Defenders 1}}s{{end}}{{else}}{{.HPRemaining}} HP still standing when the window shut{{end}}{{if .MVP}} · most bouts: <span class="font-semibold text-[color:var(--ink)]/70">{{.MVP}}</span>{{if .MVPFights}} ({{.MVPFights}}){{end}}{{end}}
{{if .Won}}Felled by {{.Defenders}} defender{{if ne .Defenders 1}}s{{end}}{{else}}{{.HPRemaining}} HP still standing when the window shut{{end}}{{if .MVP}} · most bouts: <span class="font-semibold text-muted">{{.MVP}}</span>{{if .MVPFights}} ({{.MVPFights}}){{end}}{{end}}
</span>
{{if .When}}<span class="shrink-0">{{.When}}</span>{{end}}
</div>
@@ -239,4 +239,4 @@
</script>
{{end}}
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
{{define "scripts"}}<script src="{{asset "/static/js/adventure-actions.js"}}" defer></script>{{end}}
+25 -25
View File
@@ -5,7 +5,7 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
@@ -13,14 +13,14 @@
</a>
<h1 class="font-display text-3xl font-bold">Solitaire</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">You buy the deck. Every card you get home buys some of it back.</p>
<p class="text-sm text-muted">You buy the deck. Every card you get home buys some of it back.</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The board takes the room; the money lives in a rail down the
right of it, where the house rack can't collide with the foundations. -->
<section class="pete-felt pete-solitaire relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<section class="pete-felt pete-solitaire relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-ink/10">
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<!-- The board. -->
@@ -85,22 +85,22 @@
</section>
<!-- Playing: shown while a board is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-playing class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<!-- The ordinary controls, while there's still a game to play. -->
<div data-play-controls class="flex flex-wrap items-center gap-3">
<button type="button" data-auto
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full bg-ink/5 px-5 py-2.5 font-display font-bold border-2 border-ink/10
hover:bg-ink/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Send home ⤴
</button>
<p class="text-xs text-[color:var(--ink)]/45">
<p class="text-xs text-muted">
Drag a card where it goes, or click it and then click the spot. Double-click sends it home.
</p>
<button type="button" data-cash
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-2.5 font-display font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-6 py-2.5 font-display font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
</button>
@@ -111,33 +111,33 @@
<div data-won-controls class="hidden">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-finish
class="pete-finish rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="pete-finish rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:pointer-events-none transition">
You've cracked it. Send them all home 🎉
</button>
<p class="text-xs text-[color:var(--ink)]/45">
<p class="text-xs text-muted">
The whole board's face up now, so the rest plays itself.
</p>
</div>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Buying a deck: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-betting class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which deal?</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Which deal?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Deals}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
<span class="font-display text-lg font-bold tabular-nums text-accent">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-muted">
Square with the house at {{.BreakEven}} cards home.
</p>
</button>
@@ -146,7 +146,7 @@
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">The deck costs</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">The deck costs</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
@@ -158,29 +158,29 @@
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
class="rounded-full px-3 py-2 text-sm font-semibold text-muted hover:text-ink transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Buy the deck
</button>
</div>
<p class="mt-3 text-xs text-[color:var(--ink)]/40">
<p class="mt-3 text-xs text-muted">
The stake buys the deck outright, so it doesn't come back. What comes back is
whatever you get home, a fifty-second of the multiple at a time. Stop whenever
you like and keep it. There's no undo.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/casino-cards.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/solitaire.js" defer></script>
<script src="{{asset "/static/js/casino-fx.js"}}" defer></script>
<script src="{{asset "/static/js/casino-cards.js"}}" defer></script>
<script src="{{asset "/static/js/games.js"}}" defer></script>
<script src="{{asset "/static/js/solitaire.js"}}" defer></script>
{{end}}
+17 -17
View File
@@ -23,11 +23,11 @@
</header>
{{if .Standings.Rows}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
<div class="mt-6 rounded-3xl bg-card border-2 border-ink/10 shadow-pete overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-[11px] uppercase tracking-wider text-[color:var(--ink)]/45 border-b-2 border-[color:var(--ink)]/10">
<tr class="text-[11px] uppercase tracking-wider text-muted border-b-2 border-ink/10">
<th class="text-left font-semibold px-4 py-3 w-10">#</th>
<th class="text-left font-semibold px-2 py-3">Adventurer</th>
<th class="text-right font-semibold px-2 py-3" title="The deepest tier they have actually beaten a boss in">Deepest</th>
@@ -40,28 +40,28 @@
</thead>
<tbody>
{{range .Standings.Rows}}
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 hover:bg-[color:var(--ink)]/[0.03] transition">
<td class="px-4 py-3 tabular-nums text-[color:var(--ink)]/35 font-semibold">{{.Rank}}</td>
<tr class="border-b border-ink/5 last:border-0 hover:bg-ink/[0.03] transition">
<td class="px-4 py-3 tabular-nums text-muted font-semibold">{{.Rank}}</td>
<td class="px-2 py-3 min-w-0">
{{if .Token}}<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure transition">{{.Name}}</a>
{{else}}<span class="font-semibold">{{.Name}}</span>{{end}}
<span class="block text-xs text-[color:var(--ink)]/40">
<span class="block text-xs text-muted">
lv {{.Level}}{{if .ClassRace}} · {{.ClassRace}}{{end}}
</span>
</td>
<td class="px-2 py-3 text-right tabular-nums">
{{if .DeepestTier}}<span class="standings-tier standings-tier-{{.DeepestTier}}">T{{.DeepestTier}}</span>
{{else}}<span class="text-[color:var(--ink)]/25">—</span>{{end}}
{{else}}<span class="text-ink/25">—</span>{{end}}
</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Zones}} text-[color:var(--ink)]/25{{end}}">{{if .Zones}}{{.Zones}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Clears}} text-[color:var(--ink)]/25{{end}}">{{if .Clears}}{{.Clears}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Zones}} text-ink/25{{end}}">{{if .Zones}}{{.Zones}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .Clears}} text-ink/25{{end}}">{{if .Clears}}{{.Clears}}{{else}}—{{end}}</td>
<td class="px-2 py-3 text-right tabular-nums">
{{if .Firsts}}<span class="standings-firsts">{{.Firsts}}</span>{{else}}<span class="text-[color:var(--ink)]/25">—</span>{{end}}
{{if .Firsts}}<span class="standings-firsts">{{.Firsts}}</span>{{else}}<span class="text-ink/25">—</span>{{end}}
</td>
<td class="px-2 py-3 text-right tabular-nums{{if not .SiegeDamage}} text-[color:var(--ink)]/25{{end}}">
<td class="px-2 py-3 text-right tabular-nums{{if not .SiegeDamage}} text-ink/25{{end}}">
{{if .SiegeDamage}}{{.SiegeDamage}}{{else}}—{{end}}
</td>
<td class="px-4 py-3 text-right tabular-nums{{if not .Deaths}} text-[color:var(--ink)]/25{{end}}">{{if .Deaths}}{{.Deaths}}{{else}}—{{end}}</td>
<td class="px-4 py-3 text-right tabular-nums{{if not .Deaths}} text-ink/25{{end}}">{{if .Deaths}}{{.Deaths}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
@@ -69,14 +69,14 @@
</div>
</div>
<p class="mt-3 text-xs text-[color:var(--ink)]/40 px-1">
<p class="mt-3 text-xs text-muted px-1">
Deepest is the hardest tier they've actually put a boss down in, not the hardest
one they've walked into. Deaths is my own count, off the dispatches I've filed.
</p>
{{else}}
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">
<div class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<p class="text-sm text-muted">
{{if .Standings.Known}}
Nobody on the board yet. Make a character and it fills up.
{{else}}
@@ -89,16 +89,16 @@
{{/* Pete keeps score on himself. He can be hired onto an expedition and he
duels; a paper that ranks everybody else and quietly leaves itself off the
table is doing something slightly dishonest. */}}
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-6 shadow-pete">
<section class="mt-8 rounded-3xl bg-card border-2 border-theme-adventure/30 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold">And my own record, since you asked</h2>
{{if .Standings.PeteFought}}
<p class="mt-2 text-sm text-[color:var(--ink)]/70">
<p class="mt-2 text-sm text-muted">
<span class="font-semibold text-theme-adventure tabular-nums text-lg">{{.Standings.PeteWins}}</span> won,
<span class="font-semibold tabular-nums text-lg">{{.Standings.PeteLosses}}</span> lost.
I file those myself, which you're welcome to hold against me.
</p>
{{else}}
<p class="mt-2 text-sm text-[color:var(--ink)]/60">
<p class="mt-2 text-sm text-muted">
No bouts yet. I'll report them when there are, wins and the other kind.
</p>
{{end}}
+18 -18
View File
@@ -3,15 +3,15 @@
{{define "main"}}
{{$admin := .Admin}}
<section class="mt-2 mb-8">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">{{if $admin}}owner view{{else}}live status{{end}}</p>
<div class="rounded-3xl bg-card border-2 border-ink/10 p-6 sm:p-8 shadow-pete">
<p class="text-sm uppercase tracking-[0.2em] text-muted">{{if $admin}}owner view{{else}}live status{{end}}</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-1">Source health</h1>
<p class="mt-2 max-w-2xl text-[color:var(--ink)]/70">
<p class="mt-2 max-w-2xl text-muted">
{{if $admin}}Per-feed poll status and content stats. Rows that are currently failing float to the top.
{{else}}Every feed Pete follows and whether it's still updating. This runs on a best-effort basis, so a source going quiet for a bit is normal.{{end}}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 px-3 py-1 text-sm font-semibold tabular-nums">
<span class="inline-flex items-center gap-1.5 rounded-full bg-ink/5 px-3 py-1 text-sm font-semibold tabular-nums">
{{len .Sources}} sources
</span>
{{if .DegradedCnt}}
@@ -34,15 +34,15 @@
<section class="mb-8">
<div class="rounded-3xl bg-amber-500/10 border-2 border-amber-500/30 p-6">
<h2 class="font-display text-xl font-bold">Dispatches with no template</h2>
<p class="mt-1 text-sm text-[color:var(--ink)]/70 max-w-2xl">
<p class="mt-1 text-sm text-muted max-w-2xl">
gogobee sent these event types since the last restart and Pete published them on the
generic fallback. Nothing was lost — they just read thin until he learns the words.
</p>
<ul class="mt-3 flex flex-wrap gap-2">
{{range .UnknownAdv}}
<li class="inline-flex items-center gap-2 rounded-full bg-[color:var(--card)] border border-amber-500/40 px-3 py-1 text-sm">
<li class="inline-flex items-center gap-2 rounded-full bg-card border border-amber-500/40 px-3 py-1 text-sm">
<code class="font-mono">{{.EventType}}</code>
<span class="tabular-nums text-[color:var(--ink)]/60">×{{.Count}}</span>
<span class="tabular-nums text-muted">×{{.Count}}</span>
</li>
{{end}}
</ul>
@@ -50,10 +50,10 @@
</section>
{{end}}
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
<div class="overflow-x-auto rounded-3xl bg-card border-2 border-ink/10 shadow-pete">
<table class="w-full {{if $admin}}min-w-[52rem]{{else}}min-w-[28rem]{{end}} text-sm">
<thead>
<tr class="text-left text-[color:var(--ink)]/50 uppercase text-xs tracking-wider border-b-2 border-[color:var(--ink)]/10">
<tr class="text-left text-muted uppercase text-xs tracking-wider border-b-2 border-ink/10">
<th class="px-4 py-3 font-semibold">Source</th>
<th class="px-4 py-3 font-semibold">Channel</th>
<th class="px-4 py-3 font-semibold">Status</th>
@@ -70,12 +70,12 @@
</thead>
<tbody>
{{range .Sources}}
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 align-top">
<tr class="border-b border-ink/5 last:border-0 align-top">
<td class="px-4 py-3 font-semibold whitespace-nowrap">{{.Name}}</td>
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{.Channel}}</td>
<td class="px-4 py-3 text-muted whitespace-nowrap">{{.Channel}}</td>
<td class="px-4 py-3 whitespace-nowrap">
{{if .NeverRun}}
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/10 px-2.5 py-1 text-xs font-semibold">◌ idle</span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-ink/10 px-2.5 py-1 text-xs font-semibold">◌ idle</span>
{{else if .Healthy}}
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-2.5 py-1 text-xs font-semibold">● live</span>
{{else if $admin}}
@@ -85,25 +85,25 @@
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-2.5 py-1 text-xs font-semibold">✕ delayed</span>
{{end}}
</td>
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}</td>
<td class="px-4 py-3 text-muted whitespace-nowrap">{{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}</td>
{{if $admin}}
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}</td>
<td class="px-4 py-3 text-muted whitespace-nowrap">{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}</td>
<td class="px-4 py-3 text-right tabular-nums">{{.LastItemCount}}</td>
<td class="px-4 py-3 text-right tabular-nums" title="{{.Classified}} classified of {{.Total}}">{{.Total}}</td>
<td class="px-4 py-3 text-right tabular-nums {{if gt .PaywallRate 50}}text-amber-600 dark:text-amber-400 font-semibold{{end}}">
{{if .Total}}{{.PaywallRate}}%{{else}}—{{end}}
</td>
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}}</td>
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}</td>
<td class="px-4 py-3 text-muted whitespace-nowrap">{{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}}</td>
<td class="px-4 py-3 text-muted whitespace-nowrap">{{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}</td>
{{end}}
</tr>
{{if $admin}}{{if .LastError}}{{if not .Healthy}}
<tr class="border-b border-[color:var(--ink)]/5">
<tr class="border-b border-ink/5">
<td colspan="10" class="px-4 pb-3 -mt-1 text-xs text-red-600 dark:text-red-400 font-mono break-all">{{.LastError}}</td>
</tr>
{{end}}{{end}}{{end}}
{{else}}
<tr><td colspan="{{if $admin}}10{{else}}4{{end}}" class="px-4 py-8 text-center text-[color:var(--ink)]/50">No sources configured.</td></tr>
<tr><td colspan="{{if $admin}}10{{else}}4{{end}}" class="px-4 py-8 text-center text-muted">No sources configured.</td></tr>
{{end}}
</tbody>
</table>
+4 -4
View File
@@ -3,7 +3,7 @@
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-muted hover:text-ink transition">
<span aria-hidden="true">←</span> All dispatches
</a>
</nav>
@@ -17,8 +17,8 @@
</div>
</header>
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
<div class="mt-8 rounded-3xl bg-card border-2 border-ink/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-ink/90 whitespace-pre-line">{{.Body}}</p>
{{if .RunReportURL}}
<!-- The way back to what actually happened. This dispatch is the ending of an
expedition; the report is the expedition. -->
@@ -26,7 +26,7 @@
<span aria-hidden="true">📜</span> Read the run, room by room <span aria-hidden="true">→</span>
</a>
{{end}}
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
<p class="mt-8 pt-6 border-t border-ink/10 text-sm italic text-theme-adventure font-semibold">
Reporting from the realm, this is Pete.
</p>
</div>
+20 -20
View File
@@ -5,7 +5,7 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
@@ -13,14 +13,14 @@
</a>
<h1 class="font-display text-3xl font-bold">Trivia</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">{{.Rungs}} questions · answer fast, or don't bother</p>
<p class="text-sm text-muted">{{.Rungs}} questions · answer fast, or don't bother</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The clock is the biggest thing on it, because the clock is the
game: a right answer is worth what it's worth *when you give it*. -->
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<section class="pete-felt relative overflow-hidden rounded-3xl p-6 sm:p-10 shadow-pete-lg border-2 border-ink/10">
<div class="pete-rack" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
@@ -76,34 +76,34 @@
</section>
<!-- Playing: shown while a ladder is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-playing class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-3">
<p class="text-sm text-[color:var(--ink)]/50">
<p class="text-sm text-muted">
Press <span class="font-bold">1</span>–<span class="font-bold">4</span>, or click one. A wrong answer, or the clock, loses the lot.
</p>
<button type="button" data-walk
class="ml-auto rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Take the money · <span data-walk-amount>0</span>
</button>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Betting: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-betting class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">How hard?</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">How hard?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Quizzes}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.2f" .Fast}}×</span>
<span class="font-display text-lg font-bold tabular-nums text-accent">{{printf "%.2f" .Fast}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-muted">
{{.Limit}}s a question · slowest answer still pays {{printf "%.2f" .Buzzer}}×
</p>
</button>
@@ -112,7 +112,7 @@
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
@@ -124,26 +124,26 @@
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
class="rounded-full px-3 py-2 text-sm font-semibold text-muted hover:text-ink transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="ml-auto rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Play
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<p class="mt-3 text-center text-xs text-muted">
The first question is the price of sitting down: you can only walk once you've answered one.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/trivia.js" defer></script>
<script src="{{asset "/static/js/casino-fx.js"}}" defer></script>
<script src="{{asset "/static/js/games.js"}}" defer></script>
<script src="{{asset "/static/js/trivia.js"}}" defer></script>
{{end}}
+37 -37
View File
@@ -5,7 +5,7 @@
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-card shadow-pete border-2 border-ink/10 hover:bg-ink/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
@@ -13,7 +13,7 @@
</a>
<h1 class="font-display text-3xl font-bold">UNO</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Sit down, ante up, and go out first — the pot's yours</p>
<p class="text-sm text-muted">Sit down, ante up, and go out first — the pot's yours</p>
</div>
{{template "_chipbar" .}}
@@ -21,7 +21,7 @@
<!-- The felt. Other seats along the top, the deck and the pile in the middle
with the pot beside them, your hand at the bottom. Every ante on this table
is riding in the pot until somebody goes out and takes it. -->
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-ink/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0 space-y-5">
@@ -109,15 +109,15 @@
</section>
<!-- Acting: shown while it's your turn. -->
<section data-acting class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-acting class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-3">
<p data-hint class="text-sm text-[color:var(--ink)]/50">
<p data-hint class="text-sm text-muted">
Click a card that lights up. Nothing lights up? Draw one.
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-draw
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full border-2 border-ink/15 bg-card px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Draw
</button>
<button type="button" data-take
@@ -126,60 +126,60 @@
Take <span data-take-n>0</span>
</button>
<button type="button" data-pass
class="hidden rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
class="hidden rounded-full bg-accent px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Keep it
</button>
</div>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Between hands: deal the next one, or get up. -->
<section data-between class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-between class="hidden rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">In front of you</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">In front of you</div>
<div class="font-display text-3xl font-bold tabular-nums" data-table-stack>0</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">
Bought in for <span data-bought-in class="tabular-nums">0</span> · <span data-ante-note class="tabular-nums">0</span> to ante
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-leave
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
class="rounded-full border-2 border-ink/15 bg-card px-5 py-3 font-display text-base font-bold shadow-pete
hover:bg-ink/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Get up
</button>
<button type="button" data-deal
class="rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Next hand
</button>
</div>
</div>
<p data-between-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-between-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- The rail talk, shown while you're seated. -->
<section data-chat class="hidden rounded-3xl bg-[color:var(--card)] p-4 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-chat class="hidden rounded-3xl bg-card p-4 shadow-pete border-2 border-ink/10">
<div data-chat-log class="flex flex-col gap-1 max-h-40 overflow-y-auto pr-1 text-sm" aria-live="polite" aria-label="Table chat"></div>
<form data-chat-form class="mt-3 flex items-center gap-2">
<input data-chat-input type="text" maxlength="240" autocomplete="off" placeholder="Say something to the table…"
class="flex-1 min-w-0 rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-[color:var(--accent)]">
class="flex-1 min-w-0 rounded-full border-2 border-ink/15 bg-[color:var(--bg)] px-4 py-2 text-sm focus:outline-none focus:border-accent">
<button type="submit"
class="rounded-full bg-[color:var(--accent)] px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
class="rounded-full bg-accent px-5 py-2 font-display text-sm font-bold text-white shadow-pete hover:brightness-105 active:translate-y-px transition">
Say
</button>
</form>
</section>
<!-- Sitting down: shown when you aren't at a table. -->
<section data-sitting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section data-sitting class="rounded-3xl bg-card p-5 sm:p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Which table?</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Which table?</div>
<div class="pete-seg" role="group" aria-label="Which rules">
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
@@ -193,10 +193,10 @@
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
<span class="font-display text-lg font-bold tabular-nums text-accent">{{.Ante}} ante</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-muted">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · buy in {{.MinBuy}}–{{.MaxBuy}}
</p>
</button>
@@ -211,10 +211,10 @@
class="pete-tier pete-tier-nm rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</span>
<span class="font-display text-lg font-bold tabular-nums text-accent">{{.Ante}} ante</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
<p class="mt-0.5 text-xs text-muted">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-muted">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · 168 cards
</p>
</button>
@@ -223,39 +223,39 @@
</div>
<div class="mt-5">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Buying in for</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Buying in for</div>
<div class="mt-2 flex items-center gap-3">
<input type="range" data-buyin-slider class="flex-1 min-w-0" aria-label="How much to buy in for">
<span data-buyin class="font-display text-2xl font-bold tabular-nums">0</span>
</div>
<p class="mt-1.5 text-xs text-[color:var(--ink)]/40" data-buyin-note></p>
<p class="mt-1.5 text-xs text-muted" data-buyin-note></p>
</div>
<button type="button" data-sit
class="mt-5 w-full rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
class="mt-5 w-full rounded-full bg-accent px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Sit down
</button>
<div data-lobby-wrap class="mt-6 hidden">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Or join a table in progress</div>
<div class="text-xs font-semibold uppercase tracking-wider text-muted">Or join a table in progress</div>
<div data-lobby class="mt-2 grid gap-2"></div>
</div>
<p data-note="normal" class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<p data-note="normal" class="mt-3 text-center text-xs text-muted">
Everyone antes, and the first one out takes the pot less {{.RakePct}}% rake. Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
</p>
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
<p data-note="nomercy" class="hidden mt-3 text-center text-xs text-muted">
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in your hand puts you out of the hand. The pot still goes to whoever goes out first.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-ink/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/uno.js" defer></script>
<script src="{{asset "/static/js/casino-fx.js"}}" defer></script>
<script src="{{asset "/static/js/games.js"}}" defer></script>
<script src="{{asset "/static/js/uno.js"}}" defer></script>
{{end}}
+21 -21
View File
@@ -2,65 +2,65 @@
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<section class="rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10">
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
<div class="flex items-center gap-2 text-xs font-semibold">
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
<span data-demo-renderer class="rounded-full border-2 border-ink/10 px-3 py-1 uppercase tracking-wider text-muted">renderer: …</span>
<span data-demo-fps class="rounded-full border-2 border-ink/10 px-3 py-1 tabular-nums text-muted">— fps</span>
</div>
</div>
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
<p class="text-sm text-muted mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
<div class="space-y-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-muted">Variant</span>
{{range $v := .Variants}}
<a href="?variant={{$v}}&amp;intensity={{$.Intensity}}&amp;phase={{$.PhaseSel}}"
data-demo-variant="{{$v}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
class="rounded-full px-3 py-1 transition border-2 border-ink/10
{{if eq $.Variant $v}}bg-accent text-white font-semibold{{else}}hover:bg-ink/5{{end}}">{{$v}}</a>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-muted">Intensity</span>
{{range $i := .Intensities}}
<a href="?variant={{$.Variant}}&amp;intensity={{$i}}&amp;phase={{$.PhaseSel}}"
data-demo-intensity="{{$i}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
class="rounded-full px-3 py-1 transition border-2 border-ink/10
{{if eq $.Intensity $i}}bg-accent text-white font-semibold{{else}}hover:bg-ink/5{{end}}">{{$i}}</a>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-muted">Phase</span>
{{range $p := .Phases}}
<a href="?variant={{$.Variant}}&amp;intensity={{$.Intensity}}&amp;phase={{$p}}"
data-demo-phase="{{$p}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
class="rounded-full px-3 py-1 transition border-2 border-ink/10
{{if eq $.PhaseSel $p}}bg-accent text-white font-semibold{{else}}hover:bg-ink/5{{end}}">{{$p}}</a>
{{end}}
</div>
</div>
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
<div class="mt-5 text-xs text-muted">
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
</div>
</section>
<section class="rounded-3xl bg-[color:var(--card)] p-10 shadow-pete border-2 border-[color:var(--ink)]/10 text-center">
<section class="rounded-3xl bg-card p-10 shadow-pete border-2 border-ink/10 text-center">
<p class="font-display text-lg">Sample card</p>
<p class="mt-2 text-sm text-[color:var(--ink)]/60">Particles should drift in the margins around this block, not over the top of it.</p>
<p class="mt-2 text-sm text-muted">Particles should drift in the margins around this block, not over the top of it.</p>
</section>
<section class="grid gap-4 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10">
<p class="font-display">Card A</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Two cards with a gap so you can see the canvas between them.</p>
<p class="text-sm text-muted mt-1">Two cards with a gap so you can see the canvas between them.</p>
</div>
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="rounded-3xl bg-card p-6 shadow-pete border-2 border-ink/10">
<p class="font-display">Card B</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
<p class="text-sm text-muted mt-1">Make your window taller to give the particles more room.</p>
</div>
</section>
</div>
@@ -80,7 +80,7 @@
function markActive(attr, value) {
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
var on = el.getAttribute(attr) === value;
el.classList.toggle("bg-[color:var(--accent)]", on);
el.classList.toggle("bg-accent", on);
el.classList.toggle("text-white", on);
el.classList.toggle("font-semibold", on);
});
+95 -95
View File
@@ -9,16 +9,16 @@
<li>
<div class="flex items-baseline justify-between gap-3">
<span class="flex-1 font-semibold">{{.Name}}{{if .Temper}} <span class="text-theme-adventure">+{{.Temper}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">{{if .Tier}}T{{.Tier}}{{end}}{{if .Value}}{{if .Tier}} · {{end}}{{.Value}}g{{end}}</span>
<span class="text-xs text-muted shrink-0">{{if .Tier}}T{{.Tier}}{{end}}{{if .Value}}{{if .Tier}} · {{end}}{{.Value}}g{{end}}</span>
</div>
{{if or .Slot .SkillSource .Attunement}}
<div class="flex flex-wrap gap-1.5 mt-1">
{{if .Slot}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.Slot}}</span>{{end}}
{{if .SkillSource}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/5 px-2 py-0.5 text-[color:var(--ink)]/60">{{.SkillSource}}</span>{{end}}
{{if .Attunement}}{{if .Attuned}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">bonded</span>{{else if .Worn}}<span class="text-[11px] rounded-full bg-amber-400/20 px-2 py-0.5 text-[color:var(--warn)] font-semibold">inert · no bond free</span>{{else}}<span class="text-[11px] rounded-full bg-[color:var(--ink)]/10 px-2 py-0.5 text-[color:var(--ink)]/60">needs a bond</span>{{end}}{{end}}
{{if .Slot}}<span class="text-[11px] rounded-full bg-ink/5 px-2 py-0.5 text-muted">{{.Slot}}</span>{{end}}
{{if .SkillSource}}<span class="text-[11px] rounded-full bg-ink/5 px-2 py-0.5 text-muted">{{.SkillSource}}</span>{{end}}
{{if .Attunement}}{{if .Attuned}}<span class="text-[11px] rounded-full bg-theme-adventure/20 px-2 py-0.5 text-theme-adventure font-semibold">bonded</span>{{else if .Worn}}<span class="text-[11px] rounded-full bg-amber-400/20 px-2 py-0.5 text-warn font-semibold">inert · no bond free</span>{{else}}<span class="text-[11px] rounded-full bg-ink/10 px-2 py-0.5 text-muted">needs a bond</span>{{end}}{{end}}
</div>
{{end}}
{{if .Desc}}<p class="text-xs text-[color:var(--ink)]/55 mt-1 leading-snug">{{.Desc}}</p>{{end}}
{{if .Desc}}<p class="text-xs text-muted mt-1 leading-snug">{{.Desc}}</p>{{end}}
{{if .Effect}}<p class="text-xs text-theme-adventure/80 mt-0.5">{{.Effect}}</p>{{end}}
{{/* The compare card: how this backpack item stacks up against what's worn in
the slot it would equip into. gogobee decided the verdict and the per-stat
@@ -35,7 +35,7 @@
{{else if eq .Verdict "same"}}<span class="cmp-chip cmp-same">= no change</span>{{end}}
{{range .Deltas}}<span class="cmp-delta {{if .Better}}cmp-delta-up{{else}}cmp-delta-down{{end}}">{{.Text}}</span>{{end}}
</div>
{{if .VsName}}<p class="text-[11px] text-[color:var(--ink)]/45 mt-0.5">vs worn {{.VsName}}</p>{{end}}
{{if .VsName}}<p class="text-[11px] text-muted mt-0.5">vs worn {{.VsName}}</p>{{end}}
{{end}}
{{if .EquipAction}}
<div class="mt-1.5">
@@ -64,7 +64,7 @@
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto" id="who" data-token="{{.Mark.Token}}">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-muted hover:text-ink transition">
<span aria-hidden="true">←</span> The board
</a>
</nav>
@@ -84,35 +84,35 @@
{{if .HasDetail}}
<!-- Stats + gear: public, the same anonymity model as the board. -->
<section class="mt-8 grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Vitals</h2>
<div class="flex items-baseline justify-between">
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Hit points</span>
<span class="text-sm uppercase tracking-wider text-muted">Hit points</span>
<span class="font-semibold" id="who-hp">{{.Detail.HPCurrent}} / {{.Detail.HPMax}}{{if .Detail.TempHP}} <span class="text-theme-adventure">+{{.Detail.TempHP}}</span>{{end}}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-[color:var(--ink)]/10 overflow-hidden">
<div class="mt-2 h-2 rounded-full bg-ink/10 overflow-hidden">
<div class="h-full rounded-full bg-theme-adventure transition-all" id="who-hp-bar" style="width:0%"></div>
</div>
<div class="mt-4 flex items-baseline justify-between">
<span class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50">Armor class</span>
<span class="text-sm uppercase tracking-wider text-muted">Armor class</span>
<span class="font-semibold" id="who-ac">{{.Detail.ArmorClass}}</span>
</div>
<div class="mt-5 grid grid-cols-3 gap-2">
{{range .Abilities}}
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50">{{.Label}}</div>
<div class="rounded-2xl bg-ink/5 px-2 py-3 text-center">
<div class="text-[10px] uppercase tracking-wider text-muted">{{.Label}}</div>
<div class="font-display text-lg font-bold leading-none mt-1">{{.Score}}</div>
<div class="text-xs text-[color:var(--ink)]/60">{{.ModStr}}</div>
<div class="text-xs text-muted">{{.ModStr}}</div>
</div>
{{end}}
</div>
{{if .Mark.OnRun}}
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 space-y-1.5 text-sm" id="who-expedition">
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
<div class="mt-5 pt-4 border-t border-ink/10 space-y-1.5 text-sm" id="who-expedition">
{{if .Detail.Room}}<div class="flex justify-between"><span class="text-muted">Room</span><span class="font-semibold" id="who-room">{{.Detail.Room}}</span></div>{{end}}
<div class="flex justify-between"><span class="text-muted">Supplies</span><span class="font-semibold" id="who-supplies">{{.Detail.Supplies}}</span></div>
{{if .Detail.ThreatLevel}}<div class="flex justify-between"><span class="text-muted">Threat</span><span class="font-semibold" id="who-threat">{{.Detail.ThreatLevel}}</span></div>{{end}}
</div>
{{end}}
@@ -123,8 +123,8 @@
An unnamed seat is an opted-out player, kept on purpose — see
partySeat.Anonymous for why it is not simply dropped. */}}
{{if .Detail.Party}}
<div class="mt-5 pt-4 border-t border-[color:var(--ink)]/10">
<h3 class="text-sm uppercase tracking-wider text-[color:var(--ink)]/50 mb-1">Down there together</h3>
<div class="mt-5 pt-4 border-t border-ink/10">
<h3 class="text-sm uppercase tracking-wider text-muted mb-1">Down there together</h3>
<ul>
{{range .Detail.Party}}
<li class="party-seat">
@@ -136,7 +136,7 @@
{{else}}
<span class="flex-1 text-sm font-semibold">{{.Name}}</span>
{{end}}
{{if .Level}}<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}</span>{{end}}
{{if .Level}}<span class="text-xs text-muted shrink-0">lv {{.Level}}</span>{{end}}
</li>
{{end}}
</ul>
@@ -148,20 +148,20 @@
actually render (it needs pushed Slots) — otherwise an owner whose detail
predates ask 7's Slots would see no standard gear at all. */}}
{{if not .Self.Slots}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Gear</h2>
{{if .Detail.Gear}}
<ul class="space-y-2">
{{range .Detail.Gear}}
<li class="flex items-baseline justify-between gap-3">
<span class="text-xs uppercase tracking-wider text-[color:var(--ink)]/45 w-16 shrink-0">{{.Slot}}</span>
<span class="text-xs uppercase tracking-wider text-muted w-16 shrink-0">{{.Slot}}</span>
<span class="font-semibold flex-1">{{.Name}}{{if .Masterwork}} <span title="masterwork">★</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
<span class="text-xs text-muted shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Traveling light — nothing equipped.</p>
<p class="text-sm text-muted">Traveling light — nothing equipped.</p>
{{end}}
</div>
{{end}}
@@ -172,15 +172,15 @@
full, the doors leading out of them, and the unopened rooms behind those
doors as question marks. Pete lays it out; it is never told what a room
holds, only that a door is there. -->
<section class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<div class="flex items-baseline justify-between mb-4">
<h2 class="font-display text-xl font-bold">The map</h2>
{{if .Detail.Room}}<span class="text-sm text-[color:var(--ink)]/50">Room <span class="font-semibold text-[color:var(--ink)]/70">{{.Detail.Room}}</span></span>{{end}}
{{if .Detail.Room}}<span class="text-sm text-muted">Room <span class="font-semibold text-muted">{{.Detail.Room}}</span></span>{{end}}
</div>
<div class="overflow-x-auto">
{{template "dungeonmap" .MapView}}
</div>
<div class="mt-4 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-[color:var(--ink)]/55">
<div class="mt-4 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-muted">
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-entry"></span>entrance</span>
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-boss"></span>boss</span>
<span class="inline-flex items-center gap-1.5"><span class="map-dot map-node-unknown"></span>unexplored</span>
@@ -189,8 +189,8 @@
</section>
{{end}}
{{else}}
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<p class="text-sm text-[color:var(--ink)]/60">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
<section class="mt-8 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<p class="text-sm text-muted">No detailed sheet on file for this adventurer yet — check back after the next snapshot.</p>
</section>
{{end}}
@@ -199,10 +199,10 @@
own channel and can outlive the snapshot that drew the map — a run that
just ended still has a story after the board has moved the mark back to
town. Rebuilt in place by the same poll that moves the HP bar. -->
<section id="who-runlog-section" class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete{{if not .RunLog.Has}} hidden{{end}}">
<section id="who-runlog-section" class="mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete{{if not .RunLog.Has}} hidden{{end}}">
<div class="flex items-baseline justify-between mb-4 gap-3">
<h2 class="font-display text-xl font-bold">The run</h2>
<span id="who-runlog-status" class="text-sm text-[color:var(--ink)]/50">{{if .RunLog.Live}}under way{{else if .RunLog.Outcome}}{{.RunLog.Outcome}}{{else}}finished{{end}}{{if .RunLog.Rooms}} · room {{.RunLog.Rooms}}{{end}}</span>
<span id="who-runlog-status" class="text-sm text-muted">{{if .RunLog.Live}}under way{{else if .RunLog.Outcome}}{{.RunLog.Outcome}}{{else}}finished{{end}}{{if .RunLog.Rooms}} · room {{.RunLog.Rooms}}{{end}}</span>
</div>
<ol id="who-runlog" class="runlog">
{{range .RunLog.Lines}}
@@ -230,10 +230,10 @@
would be the page refusing an action the game would have allowed. gogobee
answers rejected_not_running if it really has ended, and that answer shows
up in the strip below. -->
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete"
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete"
data-balance="{{.Self.Balance}}">
<h2 class="font-display text-xl font-bold mb-1">Your call</h2>
<p class="text-sm text-[color:var(--ink)]/60 mb-4">
<p class="text-sm text-muted mb-4">
Asked for here, done on the game box. It picks these up within a few seconds.
</p>
{{/* data-offer marks what stops being true once the extraction lands: there
@@ -271,7 +271,7 @@
{{if .CanAbandon}}
<div data-offer class="mt-3">
<button type="button"
class="adv-action-btn rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-[color:var(--warn)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
class="adv-action-btn rounded-full border border-warn/40 text-warn hover:bg-warn/10 px-4 py-1.5 text-sm font-semibold transition-colors"
data-action="expedition_abandon"
data-label="Call the whole thing off"
data-confirm-tone="warn"
@@ -283,7 +283,7 @@
{{if .CanLeave}}
<div data-offer class="mt-3">
<button type="button"
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
class="adv-action-btn rounded-full border border-ink/25 text-muted hover:bg-ink/10 px-4 py-1.5 text-sm font-semibold transition-colors"
data-action="expedition_leave"
data-label="Turn back alone"
data-confirm-label="Yes, turn back"
@@ -301,11 +301,11 @@
{{/* The way back in, first: it is the only offer here with a deadline on it,
and it is the answer to the verdict "Pull out" leaves behind, which until
now told a web player to go and type !resume in Matrix. */}}
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<div data-offer class="mt-5 pt-5 border-t border-ink/10">
<h3 class="font-display text-base font-bold">Go back in</h3>
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
<p class="text-sm text-muted mt-0.5 mb-2.5">
{{.Self.Resume.Display}}, day {{.Self.Resume.Day}} — waiting where you left it.
{{with untilUnix .Self.Resume.ExpiresAt}}<span class="text-[color:var(--warn)] font-semibold">{{.}}</span>.{{end}}
{{with untilUnix .Self.Resume.ExpiresAt}}<span class="text-warn font-semibold">{{.}}</span>.{{end}}
You re-stock before you go, so pick a pack.
</p>
<div class="flex flex-wrap gap-1.5">
@@ -328,15 +328,15 @@
{{/* Set out. The zone list is already level-gated and postgame-gated by
gogobee, and it arrives EMPTY while this adventurer is already out —
which is why there is no "you're on an expedition" branch here. */}}
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<div data-offer class="mt-5 pt-5 border-t border-ink/10">
<h3 class="font-display text-base font-bold">Set out</h3>
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
<p class="text-sm text-muted mt-0.5 mb-2.5">
Pick where, then pick how much to carry. The pack is what you pay for.
</p>
<label class="block">
<span class="sr-only">Zone</span>
<select id="adv-zone-pick"
class="w-full rounded-xl bg-[color:var(--ink)]/5 border border-[color:var(--ink)]/15 px-3 py-2 text-sm font-semibold">
class="w-full rounded-xl bg-ink/5 border border-ink/15 px-3 py-2 text-sm font-semibold">
{{range .Self.Zones}}
<option value="{{.ID}}">{{.Display}} · T{{.Tier}}{{if .Postgame}} · mythic{{end}}</option>
{{end}}
@@ -348,7 +348,7 @@
{{range .Self.Zones}}
{{$z := .}}
<div class="adv-zone-loadouts mt-2.5 hidden" data-zone="{{$z.ID}}">
{{with $z.Hook}}<p class="text-xs italic text-[color:var(--ink)]/50 mb-2">{{.}}</p>{{end}}
{{with $z.Hook}}<p class="text-xs italic text-muted mb-2">{{.}}</p>{{end}}
<div class="flex flex-wrap gap-1.5">
{{range $z.Loadouts}}
<button type="button"
@@ -368,20 +368,20 @@
{{end}}
{{if .Self.Babysit}}
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<div data-offer class="mt-5 pt-5 border-t border-ink/10">
<h3 class="font-display text-base font-bold">The sitter</h3>
{{if .Self.Babysit.Active}}
{{/* Engaged: say so, and offer only the way back out. Buying a second one
is refused by the game anyway, and a buy button under "already engaged"
reads as a page that has not noticed. */}}
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">
<p class="text-sm text-muted mt-0.5">
Somebody is looking after the camp{{with untilUnix .Self.Babysit.ExpiresAt}} — {{.}}{{end}}.
Your pet is being tended daily and standard camps rest like fortified ones.
</p>
{{if .CanCancelSitter}}
<div class="flex flex-wrap gap-1.5 mt-2.5">
<button type="button"
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
class="adv-action-btn rounded-full border border-ink/25 text-muted hover:bg-ink/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
data-action="babysit_cancel"
data-label="Send them home"
data-confirm-label="Yes, send them home"
@@ -389,7 +389,7 @@
</div>
{{end}}
{{else}}
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
<p class="text-sm text-muted mt-0.5 mb-2.5">
Pet tended daily, standard camps rest like fortified ones, rival duels declined for you.
</p>
<div class="flex flex-wrap gap-1.5">
@@ -425,29 +425,29 @@
same information the /adventure feed already printed, only as numbers
instead of as forty separate sentences. Not a gogobee snapshot: Pete
counted these itself out of the facts it kept. -->
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section class="mt-8 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">The record</h2>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="rounded-2xl bg-ink/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.BossKills}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">bosses down</div>
<div class="text-[10px] uppercase tracking-wider text-muted mt-1.5">bosses down</div>
</div>
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="rounded-2xl bg-ink/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.ZoneClears}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">zones cleared</div>
<div class="text-[10px] uppercase tracking-wider text-muted mt-1.5">zones cleared</div>
</div>
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="rounded-2xl bg-ink/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.TreasuresFound}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">treasure{{if ne .Trophies.TreasuresFound 1}}s{{end}}</div>
<div class="text-[10px] uppercase tracking-wider text-muted mt-1.5">treasure{{if ne .Trophies.TreasuresFound 1}}s{{end}}</div>
</div>
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="rounded-2xl bg-ink/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Deaths}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
<div class="text-[10px] uppercase tracking-wider text-muted mt-1.5">death{{if ne .Trophies.Deaths 1}}s{{end}}</div>
</div>
<div class="rounded-2xl bg-[color:var(--ink)]/5 px-2 py-3 text-center">
<div class="rounded-2xl bg-ink/5 px-2 py-3 text-center">
<div class="font-display text-2xl font-bold leading-none">{{.Trophies.Retreats}}</div>
<div class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/50 mt-1.5">walked out</div>
<div class="text-[10px] uppercase tracking-wider text-muted mt-1.5">walked out</div>
</div>
</div>
@@ -465,7 +465,7 @@
{{range .Trophies.Bosses}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Boss}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to clear it">★</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Kills}}</span>
<span class="text-xs text-muted shrink-0">×{{.Kills}}</span>
</li>
{{end}}
</ul>
@@ -478,8 +478,8 @@
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
{{range .Trophies.Zones}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">{{.Zone}}{{if .First}} <span class="text-theme-adventure" title="first clear in the realm">★</span>{{end}}{{if .Region}} <span class="text-[color:var(--ink)]/45">{{.Region}}</span>{{end}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">×{{.Clears}}</span>
<span class="flex-1">{{.Zone}}{{if .First}} <span class="text-theme-adventure" title="first clear in the realm">★</span>{{end}}{{if .Region}} <span class="text-muted">{{.Region}}</span>{{end}}</span>
<span class="text-xs text-muted shrink-0">×{{.Clears}}</span>
</li>
{{end}}
</ul>
@@ -488,13 +488,13 @@
</div>
{{if .Trophies.Treasures}}
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
<div class="mt-6 pt-4 border-t border-ink/10">
<h3 class="font-display text-lg font-bold mb-3">Treasures found</h3>
<ul class="space-y-1.5 text-sm max-h-56 overflow-y-auto pr-1">
{{range .Trophies.Treasures}}
<li class="flex items-baseline justify-between gap-3">
<span class="flex-1">💎 {{.Item}}{{if .First}} <span class="text-theme-adventure" title="first in the realm to pull this hoard">★</span>{{end}}</span>
{{if .Zone}}<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.Zone}}</span>{{end}}
{{if .Zone}}<span class="text-xs text-muted shrink-0">{{.Zone}}</span>{{end}}
</li>
{{end}}
</ul>
@@ -502,7 +502,7 @@
{{end}}
{{if .Trophies.Milestones}}
<div class="mt-6 pt-4 border-t border-[color:var(--ink)]/10">
<div class="mt-6 pt-4 border-t border-ink/10">
<h3 class="font-display text-lg font-bold mb-3">Milestones</h3>
<div class="flex flex-wrap gap-2">
{{range .Trophies.Milestones}}
@@ -515,24 +515,24 @@
<!-- The trail: every fact we have, newest first, each linking to the dispatch
that told it. -->
<section class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<section class="mt-8 rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">The trail</h2>
<ol class="relative border-l-2 border-[color:var(--ink)]/10 ml-3 space-y-4">
<ol class="relative border-l-2 border-ink/10 ml-3 space-y-4">
{{range .Timeline}}
<li class="relative pl-6">
<span class="absolute -left-[13px] top-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-[color:var(--card)] border-2 {{if .Notable}}border-theme-adventure{{else}}border-[color:var(--ink)]/10{{end}} text-xs" aria-hidden="true">{{.Emoji}}</span>
<span class="absolute -left-[13px] top-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-card border-2 {{if .Notable}}border-theme-adventure{{else}}border-ink/10{{end}} text-xs" aria-hidden="true">{{.Emoji}}</span>
<a href="{{.Permalink}}" class="group block">
<div class="flex items-baseline justify-between gap-3">
<span class="font-semibold group-hover:text-theme-adventure transition {{if .Notable}}text-theme-adventure{{end}}">{{.Label}}</span>
<span class="text-xs text-[color:var(--ink)]/45 shrink-0">{{.When}}</span>
<span class="text-xs text-muted shrink-0">{{.When}}</span>
</div>
{{if .Line}}<p class="text-sm text-[color:var(--ink)]/60 mt-0.5">{{.Line}}</p>{{end}}
{{if .Line}}<p class="text-sm text-muted mt-0.5">{{.Line}}</p>{{end}}
</a>
</li>
{{end}}
</ol>
{{if .MoreHistory}}
<p class="mt-5 pt-4 border-t border-[color:var(--ink)]/10 text-xs text-[color:var(--ink)]/45">
<p class="mt-5 pt-4 border-t border-ink/10 text-xs text-muted">
Showing the most recent {{len .Timeline}}. The counts above cover everything.
</p>
{{end}}
@@ -545,7 +545,7 @@
<section class="mt-8">
<div class="flex items-center gap-2 mb-3">
<span class="rounded-full bg-theme-adventure text-white text-xs font-semibold px-3 py-1">Your adventurer</span>
<span class="text-xs text-[color:var(--ink)]/45">only you can see the panels below</span>
<span class="text-xs text-muted">only you can see the panels below</span>
</div>
{{/* Equipment: the 5 standard slots, owner-only management. Take off round-trips
@@ -555,17 +555,17 @@
resolved server-side — the buttons only carry what was pushed. */}}
{{if .Self.Slots}}
<div id="equip-panel" data-token="{{.Mark.Token}}" data-balance="{{printf "%.2f" .Self.Balance}}"
class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete mb-6">
class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete mb-6">
<div class="flex items-baseline justify-between mb-4">
<h2 class="font-display text-xl font-bold">Equipment</h2>
<span class="text-xs text-[color:var(--ink)]/50">Balance <span class="font-semibold text-[color:var(--ink)]/70">€{{printf "%.2f" .Self.Balance}}</span></span>
<span class="text-xs text-muted">Balance <span class="font-semibold text-muted">€{{printf "%.2f" .Self.Balance}}</span></span>
</div>
<ul class="grid gap-3 sm:grid-cols-2">
{{range .Self.Slots}}
<li class="slot-card rounded-2xl bg-[color:var(--ink)]/5 p-4">
<li class="slot-card rounded-2xl bg-ink/5 p-4">
<div class="flex items-baseline justify-between gap-2">
<span class="text-[10px] uppercase tracking-wider text-[color:var(--ink)]/45">{{.Slot}}</span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
<span class="text-[10px] uppercase tracking-wider text-muted">{{.Slot}}</span>
<span class="text-xs text-muted shrink-0">T{{.Tier}}{{if .Condition}} · {{.Condition}}%{{end}}</span>
</div>
<div class="font-semibold mt-0.5 flex items-center flex-wrap gap-1.5">
<span>{{.Name}}</span>
@@ -578,7 +578,7 @@
data-action="unequip" data-slot="{{.Slot}}" data-item-name="{{.Name}}">Take off</button>{{end}}
{{if .NextTier}}<button type="button" class="equip-btn text-[11px] rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-2.5 py-0.5 font-semibold transition-colors"
data-action="upgrade" data-slot="{{.Slot}}" data-tier="{{.NextTier}}" data-cost="{{printf "%.2f" .NextPrice}}" data-item-name="{{.NextName}}">Upgrade to {{.NextName}} · €{{printf "%.0f" .NextPrice}}</button>{{end}}
{{if .RepairCost}}<button type="button" class="equip-btn text-[11px] rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-amber-400/10 px-2.5 py-0.5 font-semibold transition-colors"
{{if .RepairCost}}<button type="button" class="equip-btn text-[11px] rounded-full border border-warn/40 text-warn hover:bg-amber-400/10 px-2.5 py-0.5 font-semibold transition-colors"
data-action="repair" data-slot="{{.Slot}}" data-cost="{{.RepairCost}}" data-item-name="{{.Name}}">Repair · €{{.RepairCost}}</button>{{end}}
</div>
{{end}}
@@ -589,13 +589,13 @@
{{end}}
<div class="grid gap-6 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
<h2 class="font-display text-xl font-bold mb-4">Home</h2>
<div class="space-y-1.5 text-sm">
<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-[color:var(--ink)]/50">Autopay</span><span class="font-semibold">on</span></div>{{end}}
<div class="flex justify-between"><span class="text-muted">House tier</span><span class="font-semibold">{{if .Self.House.Tier}}{{.Self.House.Tier}}{{else}}no house{{end}}</span></div>
{{if .Self.House.LoanBalance}}<div class="flex justify-between"><span class="text-muted">Loan balance</span><span class="font-semibold">{{.Self.House.LoanBalance}}</span></div>{{end}}
{{if .Self.House.Rate}}<div class="flex justify-between"><span class="text-muted">Rate</span><span class="font-semibold">{{.Self.House.Rate}}</span></div>{{end}}
{{if .Self.House.Autopay}}<div class="flex justify-between"><span class="text-muted">Autopay</span><span class="font-semibold">on</span></div>{{end}}
</div>
{{/* Pets, with their levelling shown. They have been earning XP from every
@@ -610,27 +610,27 @@
{{range .PetRows}}
<li>
<div class="flex items-baseline justify-between gap-3">
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-[color:var(--ink)]/45 font-normal">{{.Type}}</span></span>
<span class="text-xs text-[color:var(--ink)]/50 shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
<span class="font-semibold flex-1">{{if .Name}}{{.Name}}{{else}}your {{.Type}}{{end}} <span class="text-muted font-normal">{{.Type}}</span></span>
<span class="text-xs text-muted shrink-0">lv {{.Level}}{{if .ArmorTier}} · barding T{{.ArmorTier}}{{end}}</span>
</div>
<div class="pet-xp-track mt-1.5">
<div class="pet-xp-fill{{if .Capped}} pet-xp-capped{{end}}" style="width: {{if .Capped}}100{{else}}{{.Percent}}{{end}}%"></div>
</div>
<p class="mt-1 text-xs text-[color:var(--ink)]/45">
<p class="mt-1 text-xs text-muted">
{{if .Capped}}fully grown{{else}}{{.Progress}} to level {{.NextLevel}}{{end}}
</p>
</li>
{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">No pets right now.</p>
<p class="text-sm text-muted">No pets right now.</p>
{{end}}
</div>
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
<div id="gear-panel" data-token="{{.Mark.Token}}" class="rounded-3xl bg-card border-2 border-ink/10 p-6 shadow-pete">
{{if .Worn}}
<h2 class="font-display text-xl font-bold mb-1">Worn</h2>
<p class="text-xs text-[color:var(--ink)]/45 mb-3">{{.BondsUsed}} of 3 bonds in use</p>
<p class="text-xs text-muted mb-3">{{.BondsUsed}} of 3 bonds in use</p>
<ul class="space-y-2.5 text-sm">
{{range .Worn}}{{template "itemrow" .}}{{end}}
</ul>
@@ -643,7 +643,7 @@
{{range .Backpack}}{{template "itemrow" .}}{{end}}
</ul>
{{else}}
<p class="text-sm text-[color:var(--ink)]/50">Backpack's empty.</p>
<p class="text-sm text-muted">Backpack's empty.</p>
{{end}}
{{if .VaultRows}}
@@ -842,8 +842,8 @@
left.textContent = verb + ' ' + (o.item_name || o.slot || 'item');
var right = document.createElement('span');
right.className = 'shrink-0 ' + (o.status === 'pending'
? 'text-[color:var(--ink)]/45'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
? 'text-muted'
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-warn'));
right.textContent = o.detail || STATUS[o.status] || o.status;
li.appendChild(left); li.appendChild(right);
list.appendChild(li);
@@ -913,9 +913,9 @@
var head = action === 'upgrade' ? ('Upgrade to ' + name) : ('Repair ' + name);
var boxEl = document.createElement('div');
boxEl.className = 'equip-confirm mt-2 rounded-xl bg-[color:var(--ink)]/5 p-2.5 text-xs';
boxEl.className = 'equip-confirm mt-2 rounded-xl bg-ink/5 p-2.5 text-xs';
var p = document.createElement('p');
p.className = 'text-[color:var(--ink)]/70';
p.className = 'text-muted';
p.textContent = head + ' for €' + euroFmt(cost) + '? Balance €' + euroFmt(balance) + ' → €' + euroFmt(balance - cost) + '.';
var row = document.createElement('div');
row.className = 'mt-2 flex gap-1.5';
@@ -926,7 +926,7 @@
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
var no = document.createElement('button');
no.type = 'button';
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-2.5 py-0.5';
no.className = 'rounded-full border border-ink/20 text-muted px-2.5 py-0.5';
no.textContent = 'Cancel';
no.addEventListener('click', function () { boxEl.remove(); });
row.appendChild(yes);
@@ -954,4 +954,4 @@
</script>
{{end}}
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
{{define "scripts"}}<script src="{{asset "/static/js/adventure-actions.js"}}" defer></script>{{end}}
+14 -3
View File
@@ -14,6 +14,7 @@ import (
"time"
"pete/internal/config"
"pete/internal/feedwatch"
"pete/internal/ingestion"
"pete/internal/matrix"
"pete/internal/poster"
@@ -243,6 +244,15 @@ func main() {
// Start pollers
poller := ingestion.NewPoller(cfg.Sources, processItem)
poller.Start(ctx)
// Tell the owner when a feed stops fetching, comes back, or goes quiet.
// Without an admin room there is nowhere to say it, and marking alerts as
// sent into the void would mean they never arrive once a room is set.
if mx.HasAdminRoom() {
go feedwatch.Run(ctx, cfg.Sources, mx.SendAdminNotice)
} else {
slog.Warn("feed alerts off: set matrix.admin_room to have Pete report broken or quiet feeds")
}
slog.Info("pollers started")
if postingEnabled && roundRobinMode {
@@ -305,8 +315,9 @@ func main() {
}
}
// Run maintenance on startup
storage.RunMaintenance()
// Prune now and then daily. Startup-only meant a service that ran for weeks
// between deploys never pruned at all.
storage.StartMaintenance(ctx)
// Wait for shutdown signal
sig := <-sigCh
@@ -423,7 +434,7 @@ func runLocal(cfg *config.Config) {
ws.StartAdventureAlerts(ctx)
slog.Info("local: web UI listening", "addr", cfg.Web.ListenAddr)
storage.RunMaintenance()
storage.StartMaintenance(ctx)
sig := <-sigCh
slog.Info("local: shutdown signal received", "signal", sig)
+16 -1
View File
@@ -1,11 +1,26 @@
/** @type {import('tailwindcss').Config} */
// Pete's palette colours change with the phase (and the casino room), so they
// live in CSS vars set on <html> by input.css. Each is exposed as bare rgb
// channels so Tailwind can put an opacity on it: text-ink/80, border-ink/10,
// bg-accent/20. Writing text-[color:var(--ink)]/80 instead compiles to nothing.
const phase = (name) => `rgb(var(--${name}-rgb) / <alpha-value>)`;
module.exports = {
content: [
"./internal/web/templates/**/*.html",
"./internal/web/static/**/*.{js,css}",
],
theme: {
extend: {},
extend: {
colors: {
ink: phase("ink"),
muted: phase("muted"),
accent: phase("accent"),
warn: phase("warn"),
card: phase("card"),
},
},
},
plugins: [],
};