# 2048 rendering. Pure: takes a game state record, returns an html DSL
# record (or a `{__html}` envelope). The SSE pipeline and the /play and
# /games routes are the consumers.
use http-nu/html *
use http-nu/http *
# Used by `layout` below to resolve templates relative to this module.
const TEMPLATES_DIR = path self | path dirname | path join "templates"
# Pluck the wire-format view of a game state for the <game-board> WC.
# Keeps each tile's animation hints (spawned / merged) and the ghosts
# list (consumed-this-move + their merge destinations) so the WC can
# animate from snapshot annotations alone -- no client-side diff
# against a previous state, no prevState bookkeeping.
export def state-for-wc []: record -> record {
let s = $in
{
tiles: ($s.tiles | each {|t| {
id: $t.id
r: $t.r
c: $t.c
value: $t.value
spawned: ($t | get spawned? | default false)
merged: ($t | get merged? | default false)
} })
ghosts: ($s | get ghosts? | default [] | each {|g|
{id: $g.id r: $g.r c: $g.c value: $g.value}
})
gameOver: ($s | get game_over? | default false)
}
}
# Page-shell template (layout.html). Used once per request to wrap the
# body content in <html><head>...</head><body>. See `layout` below.
# Module-level `let` isn't allowed and `.mj compile` isn't const-eval'able,
# so templates are built once per `use` via export-env and stashed in $env.
export-env {
$env.LAYOUT_TPL = .mj compile ($TEMPLATES_DIR | path join "layout.html")
}
# Small targeted SSE fragments. Spans with stable ids morph in place on
# each state change.
export def render-score [score: int]: nothing -> record {
# Bound to the $score signal: Datastar's text plugin overwrites
# textContent on mount and on every signal patch, so post-init
# score updates flow as signals patches rather than element morphs.
# The undo tally sits on the same baseline, to the LEFT of the score:
# board-controls is right-anchored, so keeping the score the rightmost
# item means it doesn't shift when the tally first appears. data-show
# hides the tally until the game has actually used an undo.
(DIV {class: "score-block"}
(SPAN {id: "undos" class: "undo-tally"
"data-show": "$undos > 0"
"data-text": "$undos + ($undos === 1 ? ' undo' : ' undos')"} "")
(SPAN {id: "score" "data-text": "$score"} ($score | into string)))
}
# Breadcrumb header: a one-row nav element shared by / and /play. Left
# side holds the path (page title + optional crumbs); right side holds
# action shortcuts (kbd-btns). Callers pass each side as a list of HTML
# DSL records.
export def breadcrumb [
--left: list = []
--right: list = []
]: nothing -> record {
(NAV {class: "breadcrumb"}
(DIV {class: "left"} ...$left)
(DIV {class: "right"} ...$right))
}
# Bracketed key-cap button. The phrase is the button; the keyboard
# shortcut sits inside the phrase as `[k]`. Examples:
# kbd-btn "h" -> [h] (key is whole label)
# kbd-btn "esc" --suffix " home" -> [esc] home (key + descriptive tail)
# kbd-btn "n" --suffix "ew game" -> [n]ew game (key is first letter)
# kbd-btn "p" --prefix "(()) " --suffix "lay"
# -> (()) [p]lay (key inside phrase)
# kbd-btn "play now" --variant primary -> [ play now ] (CTA, no specific key)
#
# Renders <a class="kbd-btn"> when --href is set (so right-click-open-tab
# works) and <button class="kbd-btn"> otherwise. Behavior carriers:
# --intent "h"|"undo"|... fires move(intent) via script.js delegate
# --href "/new"|"/"|... the <a>'s real href
# neither caller wires a custom handler via --class
#
# --variant "primary" picks the orange CTA palette (splash play-now).
# Default variant is subdued; both flip to their accent on :hover and
# on [aria-pressed="true"] (so toggle state reuses the hover treatment).
export def kbd-btn [
label: string # the key (or whole label if no prefix/suffix)
--intent: string = ""
--href: string = ""
--class: string = ""
--prefix: string = "" # text before the [
--suffix: string = "" # text after the ]
--variant: string = "default" # "default" | "primary"
--aria-label: string = ""
--style: string = "" # inline per-instance tweak (margin, etc.)
]: nothing -> record {
let bracketed = [
(SPAN {class: "bracket"} "[")
(SPAN {class: "key"} $label)
(SPAN {class: "bracket"} "]")
]
mut inner = []
if ($prefix | is-not-empty) { $inner = ($inner | append (SPAN {class: "phrase"} $prefix)) }
$inner = ($inner | append $bracketed)
if ($suffix | is-not-empty) { $inner = ($inner | append (SPAN {class: "phrase"} $suffix)) }
let variant_class = if ($variant == "primary") { "primary" } else { "" }
let cls = ["kbd-btn" $variant_class $class] | where {|c| ($c | str trim | is-not-empty)} | str join " "
let elem = if ($href | is-not-empty) { "A" } else { "BUTTON" }
mut attrs = {class: $cls}
if $elem == "BUTTON" { $attrs = ($attrs | upsert "type" "button") }
if ($intent | is-not-empty) { $attrs = ($attrs | upsert "data-intent" $intent) }
if ($href | is-not-empty) { $attrs = ($attrs | upsert "href" $href) }
if ($aria_label | is-not-empty) { $attrs = ($attrs | upsert "aria-label" $aria_label) }
if ($style | is-not-empty) { $attrs = ($attrs | upsert "style" $style) }
if $elem == "A" { (A $attrs ...$inner) } else { (BUTTON $attrs ...$inner) }
}
# One key on the thumb-pad: a big arrow icon with the vim letter as a
# small hint below it. The arrow is an iconify-icon (http-nu's ICONIFY
# helper) -- it renders at 1em with currentColor, so .arrow's font-size /
# color drive it. Fires its move through the same [data-intent] click
# delegate in script.js that kbd-btn uses (it matches on
# `button[data-intent]`, so this stays a <button>). `dir` names the
# grid-area the key occupies in the cross layout.
def dpad-key [
icon: string # iconify icon name for the arrow (e.g. lucide:arrow-up)
key: string # vim letter, shown small as a keyboard hint
intent: string # move intent: h | j | k | l
dir: string # up | down | left | right (grid-area + aria-label)
]: nothing -> record {
(BUTTON {
type: "button"
class: $"dpad-key dpad-($dir)"
"data-intent": $intent
"aria-label": $dir
}
(SPAN {class: "arrow"} (ICONIFY $icon))
(SPAN {class: "hint"} $key))
}
# Thumb-ergonomic control pad for /play: a cross D-pad (up / left+right /
# down). One markup for both mobile and desktop -- the keys are a fixed
# comfortable size, centered, so they're big touch targets on a phone and
# stay proportional next to the board on a wide screen. Undo is NOT here:
# it's a meta action and lives with [n]ew game in the breadcrumb, away
# from the thumb-pad so it can't be hit mid-play.
export def control-pad []: nothing -> record {
(ASIDE {class: "help"}
(DIV {class: "dpad"}
(dpad-key "lucide:arrow-up" "k" "k" "up")
(dpad-key "lucide:arrow-left" "h" "h" "left")
(dpad-key "lucide:arrow-right" "l" "l" "right")
(dpad-key "lucide:arrow-down" "j" "j" "down")))
}
# Render a card from already-known state. Callers pass state straight
# out of a snapshot frame's meta, avoiding a redundant game-head lookup.
# Render a SCRU128 id's embedded timestamp as a short, human-readable
# string. Under a minute reads as "in play" (the game is still warm);
# beyond that it's "Xm ago" / "Xh ago" / "Xd ago" / "Xw ago".
# `.id unpack` is the http-nu builtin (no subprocess).
def last-active-from-id [id: string]: nothing -> string {
let ts = .id unpack $id | get timestamp
let diff = ((date now) - $ts | into int) / 1_000_000_000 | math floor
if $diff < 60 { "in play"
} else if $diff < 3600 { $"(($diff / 60) | into int)m ago"
} else if $diff < 86400 { $"(($diff / 3600) | into int)h ago"
} else if $diff < 604800 { $"(($diff / 86400) | into int)d ago"
} else { $"(($diff / 604800) | into int)w ago" }
}
# Each card answers "should I jump back into this one?". The thumbnail
# is the densest signal; the board itself mutes every tile except the
# highest value, so the headline ("how far this game got") emerges from
# the board without needing a separate max-tile badge. Two overlays sit
# on top, both owned by the WC's shadow DOM: a relative-time badge
# (top-right, when the snapshot signal carries `playedMs`) and a fun
# rotated win/over status badge.
export def render-card-from-state [
req: record
game_id: string
state: record
moves: int
last_move_id?: string
--href: string # destination URL (mount-resolved by caller); defaults to /play
]: nothing -> record {
let target = if ($href | is-empty) { ($req | href $"/play/($game_id)") } else { $href }
# One signal per game: $games[<id>] = {tiles, gameOver, playedMs, ...}
# The WC reads everything from there -- `show-played` opts the card
# in to rendering the playedMs as a relative-time overlay.
let g = "['" + $game_id + "']"
let board_expr = "JSON.stringify($games" + $g + ")"
(A {id: $"card-($game_id)" class: "game-card" href: $target}
(DIV {class: "board-wrap"}
(render-tag "game-board" {"data-attr:state": $board_expr dim: "" show-played: ""})))
}
# Render the whole .games-list from an in-memory {game_id: snapshot_meta}
# record. Sort by game_id (scru128, time-ordered) desc so newest is first.
export def render-games-list-from-data [req: record, data: record]: nothing -> record {
let entries = $data | transpose game_id meta | sort-by game_id --reverse
(DIV {class: "games-list"} ($entries | each {|e|
render-card-from-state $req $e.game_id $e.meta.state ($e.meta | get moves? | default 0) ($e.meta | get last_move_id? | default $e.game_id)
}))
}
# Page shell. Takes a list of body children (html DSL records) and wraps
# them in the shared <html><head>...</head><body> from layout.html.
#
# [(DIV ...) (FOOTER ...)] | layout $req $REV --title "..." --body-class "play"
#
# DATASTAR_JS_PATH is a const exported by http-nu/datastar; pass it in so
# this module doesn't depend on http-nu/datastar being in scope.
export def layout [
req: record
rev: string
datastar_src: string
--title: string = "nu2048"
--og-image: string = ""
--og-description: string = ""
--body-class: string = ""
--body-attrs: record = {}
--sse = false
--show-rtt = false
--head-extra: list = [] # extra HTML records spliced into <head> (after the
# core <link>/<script> tags). Used by sub-sites
# like /design to add per-section stylesheets or
# ES modules without forking the page shell.
]: list -> string {
let children = $in
let body_html = $children | each {|c| $c.__html } | str join
let head_extra_html = $head_extra | each {|c|
let d = $c | describe -d | get type
if $d == "record" and ('__html' in $c) { $c.__html } else { "" }
} | str join
# Short user slug for the header chip; empty string = no chip shown
# (template guards on `{% if player_id %}`). Reads the `session`
# cookie and looks up the bound user_id -- never the cookie token.
let token = $req | cookie parse | get session? | default ""
let pid = if ($token | is-empty) { "" } else {
let f = .last $"session.($token)"
if $f == null { "" } else { $f.meta | get user_id? | default "" }
}
let pid_short = if ($pid | is-empty) { "" } else { $pid | str substring 0..7 }
# script.js can't see the request, so resolved nav URLs ride along as
# body data-* attrs. Keyboard handlers there read these instead of
# hardcoded "/" / "/new", so Esc and n work under any mount prefix.
let nav_attrs = {
"data-home-href": ($req | href "/")
"data-new-href": ($req | href "/new")
"data-ping-url": ($req | href "/presence/ping")
# Mount prefix so client-side URL parsing (presence scope derivation,
# etc.) can strip it before reading the route path.
"data-mount-prefix": ($req | get mount_prefix? | default "")
}
{
title: $title
og_image: $og_image
og_description: $og_description
styles_href: ($req | href $"/styles.css?v=($rev)")
datastar_src: $datastar_src
script_src: ($req | href $"/script.js?v=($rev)")
game_board_src: ($req | href $"/game-board.js?v=($rev)")
scrub_knob_src: ($req | href $"/scrub-knob.js?v=($rev)")
ellie_href: ($req | href "/ellie.png")
splash_href: ($req | href "/")
my_games_href: ($req | href "/my/games")
leaderboard_href: ($req | href "/leaderboard")
design_href: ($req | href "/design/")
player_id: $pid_short
sse: $sse
show_rtt: $show_rtt
body_class: $body_class
body_attrs: ($nav_attrs | merge $body_attrs | transpose key value)
head_extra: $head_extra_html
body_html: $body_html
} | .mj render $env.LAYOUT_TPL
}