Compare commits

..

17 Commits

Author SHA1 Message Date
ab3d2c201b feat(ingress): public distribution.${BASE_DOMAIN} static vhost
Serve a static site at distribution.${BASE_DOMAIN} whose web root lives in
another repo (DISTRIBUTION_WEB_ROOT, bind-mounted read-only into caddy):
  - caddy/conf.d/30-distribution.caddy  file_server vhost
  - docker-compose.distribution.yml     mount snippet + external web root
  - issue-letsencrypt.sh / .env.example add distribution to LE_SUBDOMAINS
  - ulicraft-caddy.conf.tmpl            TLS-front block proxying to caddy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:30:27 +02:00
3486546fb3 feat(ingress): configurable caddy port so host nginx can front it
Make the caddy host-published port configurable (CADDY_HTTP_PORT /
CADDY_HTTP_BIND, default 0.0.0.0:80 unchanged) so the machine's own nginx
can terminate TLS and reverse-proxy to caddy on a localhost-only port.

Add nginx/ulicraft-caddy.conf.tmpl: a TLS-terminator vhost set that proxies
apex/auth/pack to caddy by Host header, letting caddy stay the single ingress
for all routing. Alternative to ulicraft.conf.tmpl (nginx-as-static + drasl
proxy). minecraft still reaches caddy internally over http via mcnet aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:34:42 +02:00
3d52951355 feat(landing): pixel design ported to static Astro + en/es/eu i18n
Adopt the "Carved from stone" pixel design (landing/design/) as the apex
onboarding page, ported from its React/Babel-via-CDN prototype to static
Astro so it works on an offline LAN. Real modded-LAN content replaces the
prototype's fictional public-SMP copy.

- Vendor web fonts locally (tooling/fetch-fonts.sh -> public/fonts/) so the
  page renders without the Google Fonts CDN at party time.
- Port the design system (main.css: mood/hero/bevels) and split markup into
  Astro components (Creeper, PixelIcon, CopyChip, ServerListPanel).
- site.ts holds language-neutral config + theme knobs (mood/hero/headFont/
  dust) that replace the design's in-browser TweaksPanel; LITERALS holds
  never-translated product/in-game terms.
- i18n: English (/), Spanish (/es/), Euskera (/eu/) generated from one
  [...lang].astro via getStaticPaths; copy lives in src/i18n/ui.ts. Nav has
  an EN/ES/EU switcher; html lang + hreflang set per page.
- Status panel shows a static server-list row + honest stat tiles (no fake
  live count). Copy + scroll-reveal are the only client JS.

Build emits to ../www (gitignored). Euskera strings pending a native review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:18:36 +02:00
5cec2993d7 feat(nginx): serve launcher downloads from apex vhost
Port the /launcher/* static route from caddy's 10-static.caddy to the
nginx apex server (alias -> ./mirror/launcher, autoindex). /ca.crt is
omitted — LE certs are publicly trusted, no guest CA import needed.
The 20-mirror.caddy upstream spoofs stay caddy/air-gap-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:08:43 +02:00
843347ad00 feat(tls): Let's Encrypt via OVH DNS-01 + host nginx vhost
tooling/issue-letsencrypt.sh: manual script, issues one cert per name
(apex + LE_SUBDOMAINS) with acme.sh + OVH DNS-01 (no inbound ports),
installs to certs/<name>/ and reloads nginx. nginx/ulicraft.conf.tmpl:
TLS vhosts for apex/pack (static) + auth (proxy to drasl). OVH creds and
LE_EMAIL in .env.example; certs/ gitignored. plan/15-letsencrypt.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:07 +02:00
4c874e20ed refactor(ingress): extract caddy to its own compose file; add nginx path
Caddy is no longer in the base stack. Two ingress paths now:
- docker-compose.caddy.yml — LAN/air-gap; build-stack.sh adds it for all
  up modes + the prep pre-bake. Holds the mcnet auth./pack. aliases.
- docker-compose.nginx.yml — production behind the host's nginx: publishes
  drasl on 127.0.0.1:25585, points minecraft at the host (extra_hosts) over
  HTTPS for pack + authlib.

Base loses the caddy service and minecraft's caddy depends_on (re-added by
caddy.yml). Blessing Skin override now requires caddy.yml (it only swaps
caddy's core conf).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:07 +02:00
67718d8e05 docs(deploy): cochi redeploy guide + deploy skill
plan/14-deploy.md: git pull main + hard restart (compose down ->
build-stack.sh --up online), drasl/online, verify + rollback.
.claude/skills/deploy: skill to run it (pre-check, pull, hard restart,
verify) with prod guardrails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:22:37 +02:00
6e91749f59 refactor(pack): template CustomSkinLoader + unify pack rendering
Add tooling/render-pack.sh: renders all pack/**/*.tmpl with ${BASE_DOMAIN}
(no dnsmasq HOST_LAN_IP coupling) and rebuilds the packwiz index. Both
render-config.sh and add-custom-mod.sh now delegate to it.

CustomSkinLoader.json's skin API roots are no longer frozen — it becomes
CustomSkinLoader.json.tmpl, rendered like the mod metadata. .packwizignore
broadened to *.tmpl; rendered .json gitignored as build output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:42:32 +02:00
853405afe7 chore(skins): point CustomSkinLoader at auth.ulicraft.net
Update apiRoot/sessionRoot from the old .lan domain to .net.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:38:30 +02:00
f7858d4ec4 refactor(pack): template custom mod metadata so BASE_DOMAIN isn't frozen
The download URL is the only domain-dependent field in the external-file
metadata. Make mods/*.pw.toml build output rendered from committed
mods/*.pw.toml.tmpl (url uses ${BASE_DOMAIN}); render-config.sh and
add-custom-mod.sh render them + packwiz refresh at deploy/add time.

- pack/.packwizignore: keep *.pw.toml.tmpl out of the index
- gitignore rendered pack/mods/*.pw.toml + pack/index.toml (build output)
- seed a minimal index.toml before refresh (packwiz won't bootstrap one)
- add-custom-mod renders inline (BASE_DOMAIN only) — no coupling to
  dnsmasq's HOST_LAN_IP

Domain changes now just need a re-render, not a metadata rewrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:07:35 +02:00
39be74d028 fix(pack): regenerate custom mod URLs for current BASE_DOMAIN
The mods/*.pw.toml download URLs were frozen at ulicraft.lan; the stack
now uses ulicraft.net. Regenerate all nine with the current domain.

add-custom-mod.sh: skip the copy when source == hosted dest (`-ef`),
so `add-custom-mod.sh custom --force` works as an in-place regen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:52:00 +02:00
c40cbe7594 refactor(pack): host custom jars from ./custom outside the pack root
Move pack/custom/ -> ./custom/ and bind-mount it into caddy at
/srv/pack/custom (served URL pack.${BASE_DOMAIN}/custom/ unchanged).
Keeping jars outside the packwiz pack root stops `packwiz refresh`
from indexing them as direct files on top of the mods/*.pw.toml
external refs (drops 9 bogus custom/*.jar entries from index.toml).

add-custom-mod.sh now writes jars to ./custom/. Trim the blessingskin
caddy override to just the conf swap (volumes merge by target).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:22:14 +02:00
58f0312018 feat(pack): add QoL/JEI mod set as packwiz external files
Nine NeoForge 1.21.1 jars hosted under pack/custom/ with hand-written
mods/*.pw.toml (JEI + JER + JEProfessions, Jade + JadeAddons, Balm,
InventoryEssentials, MouseTweaks, CustomSkinLoader). Adds
CustomSkinLoader.json config; refreshes index.toml/pack.toml.

Jars are committed because external files have no upstream URL — the
pack/custom/ copy served by caddy is the source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:28:51 +02:00
8c3d85f691 refactor(ingress): rename packwiz.* subdomain to pack.*
Rename the pack-serving vhost/alias from packwiz.${BASE_DOMAIN} to
pack.${BASE_DOMAIN} (caddy conf, caddy network alias, PACKWIZ_URL).
Inline the static-site caddy mounts (10-static.caddy, www, launcher,
CA cert) into the base compose; set MOTD to "Uli LAN party".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:28:07 +02:00
2405eee57d feat(tooling): add-custom-mod.sh folder mode
Accept a directory: process every *.jar inside and run packwiz refresh
once at the end. --name rejected with a folder (names derived per-jar);
halts on an empty folder. Single-jar path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:51:56 +02:00
e1ccdda961 feat(tooling): add-custom-mod.sh — host local jars as packwiz external files
Copies a local jar under pack/custom/ (served by caddy), sha256s it,
writes pack/mods/<slug>.pw.toml per packwiz "Other external files",
then packwiz refresh. Cautious: halts on bad side, whitespace in
filename, or existing entry without --force.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:48:55 +02:00
5172316919 feat(auth): Blessing Skin variant as Drasl alternative
Override stack that swaps Drasl for Blessing Skin Server + MariaDB,
talking to Minecraft via the yggdrasil-api plugin (authlib endpoint
/api/yggdrasil, ONLINE_MODE=TRUE). Caddy auth.* repointed to
blessing-skin:80; Drasl left idle (compose merges depends_on).

Run: docker compose -f docker-compose.yml -f docker-compose.blessingskin.yml up -d

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:48:54 +02:00
103 changed files with 4553 additions and 266 deletions

View File

@@ -0,0 +1,66 @@
---
name: deploy
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
---
# Deploy Ulicraft to `cochi`
Redeploy the running stack on the production host. Full reference:
`plan/14-deploy.md`.
## Fixed parameters
- Host: `cochi` (SSH alias)
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
- Branch: `main`
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml`
- Auth: Drasl (base stack)
- Restart: **hard** (down → up) — players are disconnected for a few minutes
## Procedure
This is an outward-facing, downtime-causing action on production. The user
invoking this skill is the authorization to proceed, but:
1. **Pre-check (read-only).** See what would change before tearing anything down:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git fetch && git log --oneline HEAD..@{u}'
```
Report the incoming commits. If the list is empty, tell the user the host is
already up to date and ask whether to restart anyway (they may want a plain
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
2. **Pull + hard restart** (single SSH session, halts on any error):
```bash
ssh cochi 'set -e
cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
tooling/build-stack.sh --up online'
```
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
report (local changes on the host need manual resolution).
- `build-stack.sh --up online` renders configs (needs `.env` complete:
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`.
3. **Verify.**
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose -f docker-compose.yml -f docker-compose.static.yml ps'
```
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft
log briefly and confirm it reaches `Done` (server ready):
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done"
```
## Guardrails
- Never `git reset --hard` or discard changes on `cochi` without telling the user
first — there may be host-local edits.
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do
NOT pass `-v`/`--volumes` to `down`.
- If a step fails, STOP and surface the exact error + the failing command. Do not
improvise recovery on production.
- Blessing Skin variant or `airgap` mode changes the compose file set — if the
user asks for those, follow `plan/14-deploy.md` instead of the fixed commands
above.

View File

@@ -15,5 +15,43 @@ HOST_LAN_IP=192.168.1.10
ENABLE_MDNS=false
RCON_PASSWORD=change-me-to-something-random
# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind
# it to localhost so only nginx reaches it:
# CADDY_HTTP_PORT=8880
# CADDY_HTTP_BIND=127.0.0.1
# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT.
CADDY_HTTP_PORT=80
CADDY_HTTP_BIND=0.0.0.0
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml).
# Absolute host path to the built static site — it lives in ANOTHER repo.
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
# Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth pack distribution
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu
OVH_AK=
OVH_AS=
OVH_CK=
# LE_STAGING=1 # use the untrusted staging CA for dry runs
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
# Only needed when running the Blessing Skin override instead of Drasl.
BS_DB_NAME=blessingskin
BS_DB_USER=blessingskin
BS_DB_PASSWORD=change-me-bs-db
BS_DB_ROOT_PASSWORD=change-me-bs-root
# Generate once and paste here so it survives container recreates:
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
BS_APP_KEY=base64:replace-with-generated-key

8
.gitignore vendored
View File

@@ -6,6 +6,11 @@ runtime/authlib-injector.jar
# rendered configs (from tooling/render-config.sh)
drasl/config/config.toml
dnsmasq/dnsmasq.conf
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
pack/mods/*.pw.toml
pack/index.toml
pack/CustomSkinLoader/CustomSkinLoader.json
# vendored / mirrored binaries
mirror/
@@ -19,3 +24,6 @@ landing/dist/
# exported Caddy CA (per-deploy artifact)
caddy/caddy-root-ca.crt
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
certs/

View File

@@ -0,0 +1,14 @@
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
# docker-compose.blessingskin.yml override is in play (mounted at the same
# target path). auth.* now points at Blessing Skin instead of Drasl.
#
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
# on the same vhost, so a single reverse_proxy covers everything.
http://auth.{$BASE_DOMAIN} {
reverse_proxy blessing-skin:80
}
http://pack.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}

View File

@@ -3,7 +3,7 @@ http://auth.{$BASE_DOMAIN} {
reverse_proxy drasl:25585
}
http://packwiz.{$BASE_DOMAIN} {
http://pack.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}

View File

@@ -0,0 +1,8 @@
# Distribution toggle — static site served from a web root in ANOTHER repo.
# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
# /srv/distribution by docker-compose.caddy.yml. Mounted only when that var is
# set (see the distribution override).
http://distribution.{$BASE_DOMAIN} {
root * /srv/distribution
file_server browse
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,105 @@
# ──────────────────────────────────────────────────────────────────
# Blessing Skin auth variant — use INSTEAD of Drasl.
# ──────────────────────────────────────────────────────────────────
# Layer this over the base file AND the caddy ingress (caddy lives in its own
# file now; this override only swaps caddy's core conf, so caddy.yml is required):
#
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.blessingskin.yml up -d
#
# What it does vs the base stack:
# - drasl → left running but UNUSED + unreachable (Caddy no longer
# routes to it; it has no host port). Compose merges
# depends_on, so it can't be removed from an override —
# idling it is the clean option. `docker compose ... stop
# drasl` after up if you want it down.
# - caddy → auth.* now reverse-proxies blessing-skin:80
# - mariadb → new; Blessing Skin's datastore (no SQLite support)
# - blessing-skin → new; PHP skin server + yggdrasil-api plugin
# - minecraft → authlib-injector now points at /api/yggdrasil,
# ONLINE_MODE=TRUE (real session validation against BSS)
#
# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api
# plugin must be installed + enabled from the BSS admin panel before the
# /api/yggdrasil endpoint (and thus Minecraft login) works.
#
# DB creds + APP_KEY come from .env (see .env.example: BS_* vars).
# ──────────────────────────────────────────────────────────────────
services:
caddy:
# Swap the core ingress conf for the Blessing Skin one. Compose MERGES
# volumes by container path, so mounting the BSS conf at the SAME target
# (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all
# other base mounts (pack, custom, static, ca) are kept automatically.
volumes:
- ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro
depends_on:
- blessing-skin
mariadb:
image: mariadb:11
container_name: mariadb
restart: unless-stopped
environment:
MARIADB_DATABASE: ${BS_DB_NAME}
MARIADB_USER: ${BS_DB_USER}
MARIADB_PASSWORD: ${BS_DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD}
TZ: "Europe/Madrid"
volumes:
- bs_db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
networks:
- mcnet
blessing-skin:
image: azusamikan/blessing-skin-server-docker:latest
container_name: blessing-skin
restart: unless-stopped
# Internal-only — reached via Caddy at auth.${BASE_DOMAIN}.
environment:
APP_URL: "http://auth.${BASE_DOMAIN}"
# Persist APP_KEY across recreates (else sessions/encryption break).
# Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
APP_KEY: ${BS_APP_KEY}
DB_DRIVER: "mysql"
DB_HOST: "mariadb"
DB_PORT: "3306"
DB_DATABASE: ${BS_DB_NAME}
DB_USERNAME: ${BS_DB_USER}
DB_PASSWORD: ${BS_DB_PASSWORD}
TZ: "Europe/Madrid"
volumes:
- bs_storage:/app/storage # uploaded skins/capes + textures
- bs_plugins:/app/plugins # yggdrasil-api plugin lives here
depends_on:
mariadb:
condition: service_healthy
networks:
- mcnet
minecraft:
environment:
# authlib-injector API root for Blessing Skin's yggdrasil-api plugin
# is <site>/api/yggdrasil (NOT /authlib-injector like Drasl).
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil"
# authlib-injector needs real auth: online-mode TRUE so the server
# validates sessions against Blessing Skin. (Drasl variant used FALSE.)
ONLINE_MODE: "TRUE"
# Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign
# profile keys, so you MAY flip this to TRUE if signed chat is wanted.
ENFORCE_SECURE_PROFILE: "FALSE"
# Merged with the base depends_on (drasl, caddy); just adds blessing-skin so
# Minecraft starts after the skin server is up.
depends_on:
- blessing-skin
volumes:
bs_db:
bs_storage:
bs_plugins:

50
docker-compose.caddy.yml Normal file
View File

@@ -0,0 +1,50 @@
# ──────────────────────────────────────────────────────────────────
# Caddy ingress — LAN / air-gap path. NOT started with the base stack.
# ──────────────────────────────────────────────────────────────────
# Caddy was moved out of docker-compose.yml so the base stack can run behind
# the host's nginx (production) without it. build-stack.sh adds this file for
# its online/airgap/core modes; static/mirror overrides mount extra vhosts in.
#
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
#
# Holds the mcnet aliases (auth./pack.${BASE_DOMAIN}) so containers resolve the
# stack's own names internally — this is why minecraft can reach the pack here
# without extra_hosts (the nginx path uses extra_hosts instead).
services:
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
# Host-published port. Default 80 for the standalone LAN/air-gap path; set
# CADDY_HTTP_PORT to a high localhost-only port when the machine's nginx
# fronts caddy (nginx terminates TLS, reverse-proxies here by Host header —
# see nginx/ulicraft-caddy.conf.tmpl).
ports:
- "${CADDY_HTTP_BIND:-0.0.0.0}:${CADDY_HTTP_PORT:-80}:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./www:/srv/www:ro
- ./mirror/launcher:/srv/launcher:ro
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
- ./pack:/srv/pack:ro
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
- ./custom:/srv/pack/custom:ro
networks:
mcnet:
# The upstream spoof-host aliases live in docker-compose.mirror.yml —
# adding them here would hijack maven.neoforged.net etc. for ALL mcnet
# containers, breaking the ONLINE NeoForge install during pre-bake.
aliases:
- "auth.${BASE_DOMAIN}"
- "pack.${BASE_DOMAIN}"
# Restore the ordering the base file used to have (base drops it so it can run
# caddy-less behind nginx).
minecraft:
depends_on:
- caddy

View File

@@ -0,0 +1,15 @@
# ──────────────────────────────────────────────────────────────────
# Distribution vhost — distribution.${BASE_DOMAIN}, static files from a web
# root that lives in ANOTHER repo. Layered on top of the caddy ingress.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.distribution.yml up -d
#
# DISTRIBUTION_WEB_ROOT (.env) = absolute host path to the built static site in
# the other repo. Bind-mounted read-only; caddy serves it via the
# conf.d/30-distribution.caddy snippet.
services:
caddy:
volumes:
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro

30
docker-compose.nginx.yml Normal file
View File

@@ -0,0 +1,30 @@
# ──────────────────────────────────────────────────────────────────
# Production ingress = the HOST's nginx + Let's Encrypt. No caddy.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# nginx (on the host, already installed) terminates TLS with the LE certs from
# tooling/issue-letsencrypt.sh and:
# - serves apex + pack.${BASE_DOMAIN} as static files (./www, ./pack, ./custom)
# - reverse-proxies auth.${BASE_DOMAIN} -> 127.0.0.1:25585 (drasl)
# See nginx/ulicraft.conf.tmpl.
#
# This override:
# - publishes drasl on localhost so the host nginx can reach it
# - points the minecraft container at the host (extra_hosts) over HTTPS, so it
# fetches the pack and validates sessions through the same public names the
# LE certs cover (JVM trusts Let's Encrypt out of the box)
services:
drasl:
ports:
- "127.0.0.1:25585:25585"
minecraft:
# Make the public names resolve to the host running nginx.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
- "pack.${BASE_DOMAIN}:host-gateway"
environment:
# Use HTTPS now that nginx terminates TLS with a publicly-trusted cert.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
PACKWIZ_URL: "https://pack.${BASE_DOMAIN}/pack.toml"

View File

@@ -5,38 +5,21 @@
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
# records to add (online vs airgap). A turnkey dnsmasq is available as an
# optional layer: docker-compose.dnsmasq.yml.
# - caddy is the only HTTP ingress; holds mcnet aliases so the stack
# resolves auth.${BASE_DOMAIN} / packwiz.${BASE_DOMAIN} internally too
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml,
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml
# + Let's Encrypt, production)
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
# - packwiz pack is served by caddy at packwiz.${BASE_DOMAIN}
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN}
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
# ──────────────────────────────────────────────────────────────────
services:
# Core ingress. Static site + air-gap mirror are layered in via override
# files (docker-compose.static.yml / .mirror.yml) — see build-stack.sh --up.
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./pack:/srv/pack:ro
networks:
mcnet:
# Only the stack's own subdomains in the base. The upstream spoof-host
# aliases live in docker-compose.mirror.yml — adding them here would
# hijack maven.neoforged.net / libraries.minecraft.net for ALL mcnet
# containers, breaking the ONLINE NeoForge install during pre-bake.
aliases:
- "auth.${BASE_DOMAIN}"
- "packwiz.${BASE_DOMAIN}"
# NOTE: the HTTP(S) ingress is NOT in this base file.
# - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh
# - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml
# publishes drasl to localhost; nginx/ulicraft.conf.tmpl)
# Base alone (drasl + minecraft + mc-backup) is not a complete deployment —
# always layer one ingress file on top.
drasl:
image: unmojang/drasl:latest
container_name: drasl
@@ -81,15 +64,17 @@ services:
# ── Server config ───────────────────────────────────────────
DIFFICULTY: "normal"
MODE: "survival"
MOTD: "LAN party — kitchen sink"
MOTD: "Uli LAN party"
MAX_PLAYERS: "10"
VIEW_DISTANCE: "10"
SIMULATION_DISTANCE: "8"
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
ALLOW_FLIGHT: "TRUE" # many tech mods need this
# ── Mod source: packwiz pack served by caddy ────────────────
PACKWIZ_URL: "http://packwiz.${BASE_DOMAIN}/pack.toml"
# ── Mod source: packwiz pack served by the ingress ──────────
# http + caddy alias by default; the nginx override switches this to
# https + host-gateway (extra_hosts) for the production path.
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
# ── RCON for remote admin ───────────────────────────────────
ENABLE_RCON: "TRUE"
@@ -101,8 +86,7 @@ services:
- mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here
depends_on:
- drasl
- caddy # packwiz pack is served by caddy
- drasl # ingress (caddy/nginx) is layered separately
networks:
- mcnet

BIN
landing/design/.thumbnail Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" data-mood="grass" data-hero="centered" data-head="pixelify">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ulicraft — Java Survival SMP</title>
<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=Pixelify+Sans:wght@400;500;600;700&family=Press+Start+2P&family=Silkscreen:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&family=VT323&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

434
landing/design/app.jsx Normal file
View File

@@ -0,0 +1,434 @@
// Ulicraft landing — pixel components, live status, copy-IP, scroll reveals, tweaks
const { useState, useEffect, useRef, useCallback } = React;
/* ---------- pixel art helpers ---------- */
// 8x8 creeper face
const CREEPER = [
"00000000",
"01100110",
"01100110",
"00011000",
"00111100",
"00111100",
"00100100",
"00000000",
];
function Creeper() {
const cells = CREEPER.join("").split("");
return (
<div className="creeper" aria-hidden="true">
{cells.map((c, i) => <i key={i} className={c === "1" ? "f" : ""} />)}
</div>
);
}
// 7x7 feature glyphs
const GLYPHS = {
shield: ["0111110","1111111","1111111","1111111","0111110","0011100","0001000"],
diamond:["0001000","0011100","0111110","1111111","0111110","0011100","0001000"],
orb: ["0011100","0111110","1111111","1111111","1111111","0111110","0011100"],
heart: ["0110110","1111111","1111111","1111111","0111110","0011100","0001000"],
};
function PixelIcon({ glyph, gold }) {
const cells = GLYPHS[glyph].join("").split("");
return (
<div className="picon" aria-hidden="true">
{cells.map((c, i) => (
<i key={i} className={c === "1" ? (gold ? "go" : "on") : ""} />
))}
</div>
);
}
/* ---------- copy to clipboard ---------- */
function copyText(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
return;
}
} catch (e) {}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
}
function IpChip({ ip }) {
const [copied, setCopied] = useState(false);
const onCopy = () => {
copyText(ip);
setCopied(true);
clearTimeout(onCopy._t);
onCopy._t = setTimeout(() => setCopied(false), 1600);
};
return (
<div className="ip-chip">
<span className="ip-val"><span className="pin"></span>{ip}</span>
<button className={copied ? "copied" : ""} onClick={onCopy}>
{copied ? "Copied!" : "Copy IP"}
</button>
</div>
);
}
/* ---------- live player count ---------- */
function useLivePlayers(base = 37, max = 100) {
const [n, setN] = useState(base);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = setInterval(() => {
setN((p) => {
const step = Math.floor(Math.random() * 5) - 2;
return Math.max(base - 6, Math.min(max - 4, p + step));
});
}, 2600);
return () => clearInterval(id);
}, [base, max]);
return n;
}
/* ---------- floating pixel dust ---------- */
function Dust({ on }) {
if (!on) return null;
const bits = Array.from({ length: 16 }, (_, i) => i);
return (
<div className="dust" aria-hidden="true">
{bits.map((i) => {
const size = 3 + (i % 3) * 2;
const st = {
position: "absolute",
left: ((i * 61) % 100) + "%",
bottom: "-20px",
width: size + "px",
height: size + "px",
background: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
opacity: 0.18 + (i % 3) * 0.06,
imageRendering: "pixelated",
animation: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
};
return <i key={i} style={st} />;
})}
</div>
);
}
/* ---------- server-list status panel ---------- */
function ServerListPanel({ players, max, version }) {
return (
<div className="serverlist">
<div className="icon"><Creeper /></div>
<div className="meta">
<div className="row1">
<span className="title">Ulicraft</span>
<span className="ver">Java {version}</span>
</div>
<p className="motd">
<span className="a"> Season 3</span> · Survival SMP · <span className="g">whitelist open</span>
</p>
</div>
<div className="stat">
<div className="players">
<span className="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
<span><b>{players}</b>/{max}</span>
</div>
</div>
</div>
);
}
/* ---------- scroll reveal (fail-safe: visible at rest, .anim added when in view) ---------- */
function useReveal() {
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let els = [...document.querySelectorAll(".reveal")];
let stop = false;
const tick = () => {
if (stop) return;
const vh = window.innerHeight;
for (let i = els.length - 1; i >= 0; i--) {
const r = els[i].getBoundingClientRect();
if (r.top < vh * 0.9 && r.bottom > 0) {
els[i].classList.add("anim");
els.splice(i, 1);
}
}
if (els.length) requestAnimationFrame(tick);
else stop = true;
};
requestAnimationFrame(tick);
return () => { stop = true; };
}, []);
}
/* ============================================================ */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"heroLayout": "centered",
"mood": "grass",
"headFont": "pixelify",
"serverIp": "play.ulicraft.net",
"tagline": "Hardcore survival, built to last.",
"particles": true
}/*EDITMODE-END*/;
const HEAD_FONTS = {
pixelify: "'Pixelify Sans', system-ui, sans-serif",
"8bit": "'Press Start 2P', monospace",
silkscreen: "'Silkscreen', system-ui, sans-serif",
};
const FEATURES = [
{ glyph: "shield", gold: false, h: "Grief-proof claims",
p: "Lock down your base with golden-shovel land claims. Your builds stay yours — even while you're offline." },
{ glyph: "diamond", gold: true, h: "Zero pay-to-win",
p: "The store sells cosmetics and nothing else. Every diamond is earned in-game, never bought." },
{ glyph: "orb", gold: false, h: "A living world",
p: "Seasonal map resets, custom bosses, and community events keep the overworld worth logging into." },
{ glyph: "heart", gold: true, h: "Real community",
p: "A whitelisted, moderated server with an active Discord. Toxicity meets the ban hammer, fast." },
];
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const players = useLivePlayers(37, 100);
const peak = 84;
const version = "1.21.4";
// guarantee pixel head fonts are downloaded before first paint settles
useEffect(() => {
if (!document.fonts || !document.fonts.load) return;
["600 32px 'Pixelify Sans'", "700 32px 'Pixelify Sans'",
"400 32px 'Press Start 2P'", "400 32px 'Silkscreen'", "700 32px 'Silkscreen'"]
.forEach((f) => document.fonts.load(f).catch(() => {}));
}, []);
// apply mood / hero / font to <html>
useEffect(() => {
const r = document.documentElement;
r.setAttribute("data-mood", t.mood);
r.setAttribute("data-hero", t.heroLayout);
r.setAttribute("data-head", t.headFont);
r.style.setProperty("--font-head", HEAD_FONTS[t.headFont] || HEAD_FONTS.pixelify);
}, [t.mood, t.heroLayout, t.headFont]);
useReveal();
return (
<>
{/* NAV */}
<header className="nav">
<div className="wrap nav-in">
<a className="brand" href="#top">
<Creeper />
<span className="name">ULICRAFT</span>
</a>
<nav className="nav-links">
<a href="#status">Status</a>
<a href="#features">Features</a>
<a href="#join">How to Join</a>
</nav>
<span className="nav-spacer" />
<a className="mc-btn primary sm" href="#join">Play Now</a>
</div>
</header>
{/* HERO */}
<main id="top">
<section className="hero" data-screen-label="Hero">
<Dust on={t.particles} />
<div className="wrap hero-grid">
<div className="hero-logo">
<img src="assets/ulicraft-logo.png" alt="Ulicraft" width="707" height="148" />
</div>
<div className="hero-copy">
<span className="eyebrow">Java Edition · Survival SMP · Season 3</span>
<h1 className="hero-tagline">{t.tagline}</h1>
<p className="hero-sub">
A whitelisted Java SMP with land claims, seasonal events, and zero pay-to-win.
Grab the IP, fire up your launcher, and stake your claim.
</p>
<div className="hero-ip-row">
<IpChip ip={t.serverIp} />
<a className="mc-btn" href="#join">How to Join</a>
</div>
<div className="hero-meta">
<span className="live-pill">
<span className="live-dot" />
<span><b>{players}</b> playing now</span>
</span>
<span>Java {version}</span>
<span className="dot" />
<span>No mods required</span>
</div>
</div>
<div className="hero-status">
<ServerListPanel players={players} max={100} version={version} />
</div>
</div>
</section>
{/* STATUS */}
<section id="status" className="pad" data-screen-label="Status">
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">Server Status</span>
<h2 className="section-title">Live, and waiting for you.</h2>
<p className="lead">This is exactly what you'll see in your multiplayer list.</p>
</div>
<div className="reveal">
<ServerListPanel players={players} max={100} version={version} />
</div>
<div className="stat-grid">
{[
{ k: <>{players}<span className="u">/100</span></>, l: "Players Online" },
{ k: <>{peak}</>, l: "Peak Today" },
{ k: <>99.9<span className="u">%</span></>, l: "Uptime" },
{ k: <>20.0<span className="u"> tps</span></>, l: "Performance" },
].map((s, i) => (
<div className="tile reveal" key={i} style={{ transitionDelay: i * 60 + "ms" }}>
<div className="k">{s.k}</div>
<div className="l">{s.l}</div>
</div>
))}
</div>
</div>
</section>
{/* FEATURES */}
<section id="features" className="pad" data-screen-label="Features" style={{ background: "var(--bg-2)" }}>
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">What makes it Ulicraft</span>
<h2 className="section-title">Built for players who stay.</h2>
<p className="lead">No reset roulette, no whales buying god gear. Just a server that respects your time.</p>
</div>
<div className="feat-grid">
{FEATURES.map((f, i) => (
<div className="feat reveal" key={i} style={{ transitionDelay: (i % 2) * 80 + "ms" }}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{f.h}</h3>
<p>{f.p}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* HOW TO JOIN */}
<section id="join" className="pad" data-screen-label="How to Join">
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">How to Join · 3 Steps</span>
<h2 className="section-title">From zero to spawning in.</h2>
<p className="lead">Ulicraft runs on Minecraft: Java Edition. Here's the whole path, start to finish.</p>
</div>
<div className="steps">
{/* STEP 1 */}
<div className="step reveal">
<div className="num">1</div>
<div className="body">
<span className="kicker">Account</span>
<h3>Get Minecraft: Java Edition</h3>
<p>
Create a free Microsoft account, then buy and download Minecraft: Java &amp; Bedrock
Edition from minecraft.net. Already own Java Edition? Skip straight to step&nbsp;2.
</p>
<div className="actions">
<a className="mc-btn gold sm" href="https://www.minecraft.net/get-minecraft" target="_blank" rel="noopener">Get Java Edition </a>
<span className="hint">Microsoft account required</span>
</div>
</div>
</div>
{/* STEP 2 */}
<div className="step reveal">
<div className="num">2</div>
<div className="body">
<span className="kicker">Launcher</span>
<h3>Download &amp; set up the launcher</h3>
<p>
Install the official Minecraft Launcher, sign in with your Microsoft account, and select
the <strong style={{ color: "var(--text)" }}>Latest Release ({version})</strong> profile.
</p>
<ul>
<li>Download the launcher for Windows, macOS, or Linux.</li>
<li>Sign in pick <strong style={{ color: "var(--text)" }}>Latest Release</strong> press <strong style={{ color: "var(--text)" }}>Play</strong> once to finish installing.</li>
<li>Optional: allocate 46&nbsp;GB RAM under Installations More Options for smoother play.</li>
</ul>
<div className="actions">
<a className="mc-btn sm" href="https://www.minecraft.net/download" target="_blank" rel="noopener">Download launcher </a>
<span className="hint">No mods or modpack needed</span>
</div>
</div>
</div>
{/* STEP 3 */}
<div className="step reveal">
<div className="num">3</div>
<div className="body">
<span className="kicker">Connect</span>
<h3>Join the Ulicraft server</h3>
<p>
In Minecraft, open <kbd>Multiplayer</kbd> <kbd>Add Server</kbd>. Name it Ulicraft,
paste the address below, hit <kbd>Done</kbd>, then double-click the server to join.
</p>
<div className="actions" style={{ marginBottom: "14px" }}>
<IpChip ip={t.serverIp} />
</div>
<span className="hint">Whitelisted server apply in our Discord first to get added.</span>
</div>
</div>
</div>
</div>
</section>
</main>
{/* FOOTER */}
<footer className="footer">
<div className="wrap footer-in">
<a className="brand" href="#top">
<Creeper />
<span className="name">ULICRAFT</span>
</a>
<div className="footer-links">
<a className="mc-btn primary sm" href="#join">Copy IP &amp; Play</a>
<a className="mc-btn sm" href="#" onClick={(e) => e.preventDefault()}>Discord</a>
</div>
<p className="disc">
Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.
© 2026 Ulicraft.
</p>
</div>
</footer>
{/* TWEAKS */}
<TweaksPanel title="Tweaks">
<TweakSection label="Layout" />
<TweakRadio label="Hero" value={t.heroLayout}
options={[{ value: "centered", label: "Centered" }, { value: "split", label: "Split" }, { value: "spotlight", label: "Spotlight" }]}
onChange={(v) => setTweak("heroLayout", v)} />
<TweakSection label="Mood" />
<TweakRadio label="Palette" value={t.mood}
options={[{ value: "grass", label: "Grass" }, { value: "nether", label: "Nether" }, { value: "end", label: "End" }]}
onChange={(v) => setTweak("mood", v)} />
<TweakSection label="Type" />
<TweakSelect label="Headline font" value={t.headFont}
options={[{ value: "pixelify", label: "Pixelify (readable)" }, { value: "8bit", label: "Press Start (8-bit)" }, { value: "silkscreen", label: "Silkscreen" }]}
onChange={(v) => setTweak("headFont", v)} />
<TweakSection label="Content" />
<TweakText label="Tagline" value={t.tagline} onChange={(v) => setTweak("tagline", v)} />
<TweakText label="Server IP" value={t.serverIp} onChange={(v) => setTweak("serverIp", v)} />
<TweakToggle label="Floating dust" value={t.particles} onChange={(v) => setTweak("particles", v)} />
</TweaksPanel>
</>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

579
landing/design/styles.css Normal file
View File

@@ -0,0 +1,579 @@
/* ============================================================
ULICRAFT — "Carved from stone"
Design system: dark overworld, MC-GUI bevels, pixel type
============================================================ */
/* ---- Mood palettes (data-mood on <html>) ---- */
:root,
:root[data-mood="grass"] {
--bg: oklch(0.165 0.014 150);
--bg-2: oklch(0.205 0.016 150);
--surface: oklch(0.235 0.017 150);
--surface-2: oklch(0.285 0.018 150);
--slot: oklch(0.185 0.014 150);
--bevel-hi: oklch(0.40 0.018 150);
--bevel-lo: oklch(0.115 0.012 150);
--line: oklch(0.33 0.016 150);
--accent: oklch(0.72 0.16 145); /* grass */
--accent-hi: oklch(0.80 0.15 145);
--accent-lo: oklch(0.55 0.15 145);
--accent-ink: oklch(0.17 0.05 150); /* text on accent */
--gold: oklch(0.82 0.135 85);
--glow: oklch(0.72 0.16 145 / 0.30);
--text: oklch(0.95 0.008 110);
--muted: oklch(0.72 0.012 145);
--dim: oklch(0.55 0.012 145);
}
:root[data-mood="nether"] {
--bg: oklch(0.165 0.018 35);
--bg-2: oklch(0.205 0.022 32);
--surface: oklch(0.235 0.026 32);
--surface-2: oklch(0.285 0.030 32);
--slot: oklch(0.185 0.020 32);
--bevel-hi: oklch(0.42 0.040 35);
--bevel-lo: oklch(0.115 0.016 32);
--line: oklch(0.34 0.030 32);
--accent: oklch(0.64 0.19 32); /* nether red */
--accent-hi: oklch(0.72 0.18 38);
--accent-lo: oklch(0.50 0.17 30);
--accent-ink: oklch(0.16 0.04 32);
--gold: oklch(0.83 0.14 75);
--glow: oklch(0.64 0.19 32 / 0.34);
--text: oklch(0.95 0.010 60);
--muted: oklch(0.74 0.020 45);
--dim: oklch(0.56 0.020 40);
}
:root[data-mood="end"] {
--bg: oklch(0.155 0.018 305);
--bg-2: oklch(0.195 0.022 305);
--surface: oklch(0.225 0.026 305);
--surface-2: oklch(0.275 0.030 305);
--slot: oklch(0.175 0.020 305);
--bevel-hi: oklch(0.42 0.040 305);
--bevel-lo: oklch(0.110 0.016 305);
--line: oklch(0.34 0.030 305);
--accent: oklch(0.74 0.135 175); /* end teal */
--accent-hi: oklch(0.82 0.13 175);
--accent-lo: oklch(0.58 0.13 178);
--accent-ink: oklch(0.16 0.04 200);
--gold: oklch(0.84 0.13 95);
--glow: oklch(0.70 0.16 300 / 0.34);
--text: oklch(0.96 0.010 300);
--muted: oklch(0.76 0.020 300);
--dim: oklch(0.58 0.020 300);
}
/* ---- Fonts (switchable head face via --font-head) ---- */
:root {
--font-head: 'Pixelify Sans', system-ui, sans-serif;
--font-body: 'Space Grotesk', system-ui, sans-serif;
--font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
--font-mono: 'VT323', monospace; /* IP / terminal */
--maxw: 1160px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* faint pixel-grid stone texture over everything */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
background-size: 4px 4px, 4px 4px;
mix-blend-mode: overlay;
opacity: 0.5;
}
img { display: block; max-width: 100%; }
::selection { background: var(--accent); color: var(--accent-ink); }
/* ---- Typography helpers ---- */
.eyebrow {
font-family: var(--font-pixel);
font-size: clamp(9px, 1.1vw, 11px);
letter-spacing: 0.12em;
color: var(--accent);
text-transform: uppercase;
line-height: 1.8;
}
h1, h2, h3 {
font-family: var(--font-head);
font-weight: 600;
line-height: 1.02;
letter-spacing: 0.01em;
margin: 0;
}
.section-title {
font-size: clamp(34px, 5vw, 58px);
}
.lead { color: var(--muted); }
.mono { font-family: var(--font-mono); }
/* ---- Layout ---- */
.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
section { position: relative; z-index: 1; }
.pad { padding-block: clamp(64px, 9vw, 130px); }
.sec-head { max-width: 640px; margin-bottom: 48px; }
.sec-head .eyebrow { display: block; margin-bottom: 14px; }
.sec-head p { margin: 16px 0 0; font-size: 18px; }
/* ============================================================
MINECRAFT-STYLE BUTTON
============================================================ */
.mc-btn {
--b: var(--surface-2);
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-head);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text);
background: var(--b);
border: 0;
padding: 15px 24px 17px;
cursor: pointer;
text-decoration: none;
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 4px 0 oklch(0 0 0 / 0.5),
0 8px 18px oklch(0 0 0 / 0.4);
transition: transform .08s ease, filter .12s ease;
image-rendering: pixelated;
text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
white-space: nowrap;
}
.mc-btn:hover { filter: brightness(1.12); }
.mc-btn:active {
transform: translateY(4px);
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 0 0 oklch(0 0 0 / 0.5),
0 2px 8px oklch(0 0 0 / 0.4);
}
.mc-btn.primary {
--b: var(--accent);
--bevel-hi: var(--accent-hi);
--bevel-lo: var(--accent-lo);
color: var(--accent-ink);
text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
}
.mc-btn.gold {
--b: var(--gold);
--bevel-hi: oklch(0.90 0.10 90);
--bevel-lo: oklch(0.66 0.13 75);
color: oklch(0.20 0.05 80);
text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
}
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
/* ---- Copy-IP chip ---- */
.ip-chip {
display: inline-flex;
align-items: stretch;
background: var(--slot);
box-shadow:
inset 2px 2px 0 var(--bevel-lo),
inset -2px -2px 0 var(--bevel-hi);
overflow: hidden;
}
.ip-chip .ip-val {
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
font-family: var(--font-mono);
font-size: 28px;
line-height: 1;
color: var(--gold);
letter-spacing: 0.02em;
}
.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
.ip-chip button {
border: 0;
cursor: pointer;
padding: 14px 18px;
font-family: var(--font-head);
font-weight: 600;
font-size: 15px;
background: var(--accent);
color: var(--accent-ink);
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
transition: filter .12s ease;
white-space: nowrap;
}
.ip-chip button:hover { filter: brightness(1.1); }
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
/* ============================================================
NAV
============================================================ */
.nav {
position: sticky;
top: 0;
z-index: 50;
background: oklch(0.165 0.014 150 / 0.72);
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--line);
}
.nav-in {
display: flex;
align-items: center;
gap: 24px;
height: 68px;
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
.brand .name {
font-family: var(--font-head);
font-weight: 600;
font-size: 22px;
letter-spacing: 0.02em;
}
.nav-links { display: flex; gap: 6px; margin-left: 12px; }
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 15px;
font-weight: 500;
padding: 8px 12px;
transition: color .12s ease, background .12s ease;
}
.nav-links a:hover { color: var(--text); background: var(--surface); }
.nav-spacer { flex: 1; }
/* ---- Creeper pixel mark ---- */
.creeper {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
width: 34px;
height: 34px;
background: var(--accent);
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
image-rendering: pixelated;
}
.creeper i { background: transparent; }
.creeper i.f { background: oklch(0.16 0.03 150); }
/* ============================================================
HERO
============================================================ */
.hero {
position: relative;
padding-top: clamp(48px, 7vw, 90px);
padding-bottom: clamp(56px, 8vw, 110px);
overflow: hidden;
}
.hero::before { /* spotlight glow behind logo */
content: "";
position: absolute;
top: -10%;
left: 50%;
width: 1100px;
height: 760px;
transform: translateX(-50%);
background: radial-gradient(closest-side, var(--glow), transparent 72%);
pointer-events: none;
filter: blur(8px);
}
.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
/* layout: centered (default) */
:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
:root[data-hero="centered"] .hero-cta { justify-content: center; }
:root[data-hero="centered"] .hero-meta { justify-content: center; }
:root[data-hero="centered"] .hero-copy { max-width: 720px; }
:root[data-hero="centered"] .hero-status { display: none; }
/* layout: split (copy left, live panel right) */
:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
:root[data-hero="split"] .hero-logo { max-width: 560px; }
:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
/* layout: spotlight (giant logo, minimal text, centered) */
:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
:root[data-hero="spotlight"] .hero-status { display: none; }
:root[data-hero="spotlight"] .hero-features-note { display: none; }
.hero-logo { width: 100%; max-width: 760px; }
.hero-logo img {
width: 100%;
image-rendering: pixelated;
filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
margin-inline: auto;
}
.hero-copy { display: flex; flex-direction: column; gap: 22px; }
.hero-tagline {
font-family: var(--font-head);
font-weight: 600;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.08;
letter-spacing: 0.01em;
text-wrap: balance;
}
.hero-tagline .hl { color: var(--accent); }
.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
/* status pill */
.live-pill {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 14px 7px 11px;
background: var(--slot);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
font-size: 14px;
color: var(--muted);
font-weight: 500;
}
.live-dot {
width: 9px; height: 9px; background: var(--accent);
box-shadow: 0 0 0 3px var(--glow);
animation: pulse 2.2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
/* ============================================================
SERVER-LIST STATUS PANEL
============================================================ */
.serverlist {
display: flex;
gap: 18px;
align-items: center;
background: var(--slot);
padding: 16px;
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 8px 24px oklch(0 0 0 / 0.4);
}
.serverlist .icon {
width: 72px; height: 72px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--bg-2);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
}
.serverlist .icon .creeper { width: 48px; height: 48px; }
.serverlist .meta { flex: 1; min-width: 0; }
.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
.serverlist .motd .g { color: var(--gold); }
.serverlist .motd .a { color: var(--accent); }
.serverlist .stat { text-align: right; flex-shrink: 0; }
.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
.serverlist .players b { color: var(--text); }
/* signal bars */
.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
.bars i:nth-child(1){height:25%}
.bars i:nth-child(2){height:45%}
.bars i:nth-child(3){height:65%}
.bars i:nth-child(4){height:85%}
.bars i:nth-child(5){height:100%}
/* stat tiles */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 28px;
}
.tile {
background: var(--surface);
padding: 22px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
.tile .k .u { color: var(--accent); font-size: 22px; }
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
/* ============================================================
FEATURES
============================================================ */
.feat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
}
.feat {
display: flex;
gap: 18px;
background: var(--surface);
padding: 26px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
transition: transform .14s ease, filter .14s ease;
}
.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
.feat h3 { font-size: 21px; margin-bottom: 8px; }
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
/* pixel icon */
.picon {
width: 52px; height: 52px; flex-shrink: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(7, 1fr);
background: var(--slot);
padding: 4px;
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
image-rendering: pixelated;
}
.picon i { background: transparent; }
.picon i.on { background: var(--accent); }
.picon i.go { background: var(--gold); }
/* ============================================================
HOW TO JOIN — steps
============================================================ */
.steps { display: flex; flex-direction: column; gap: 20px; }
.step {
display: grid;
grid-template-columns: 92px 1fr;
gap: 28px;
background: var(--surface);
padding: 30px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.step .num {
width: 92px; height: 92px;
display: grid; place-items: center;
background: var(--slot);
font-family: var(--font-head);
font-weight: 600;
font-size: 46px;
color: var(--accent);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
}
.step .body { padding-top: 2px; }
.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
.step h3 { font-size: 26px; margin: 10px 0 12px; }
.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
.step .hint { color: var(--dim); font-size: 14px; }
.step kbd {
font-family: var(--font-mono); font-size: 18px; line-height: 1;
background: var(--bg-2); color: var(--text);
padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
/* ============================================================
FOOTER
============================================================ */
.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
.footer .brand .name { font-size: 20px; }
.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
/* ---- floating dust ---- */
.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
@keyframes rise {
0% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
.hero-grid { z-index: 1; }
/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
:root[data-head="8bit"] .brand .name { font-size: 15px; }
:root[data-head="8bit"] .mc-btn { font-size: 12px; }
:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
:root[data-head="8bit"] .step .num { font-size: 30px; }
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
:root[data-head="8bit"] .tile .k { font-size: 26px; }
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
:root[data-head="8bit"] .live-pill { font-size: 12px; }
/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
/* ============================================================
SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
============================================================ */
.reveal { will-change: opacity, transform; }
@keyframes reveal-in {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: no-preference) {
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 880px) {
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
:root[data-hero="split"] .hero-status { display: block; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.feat-grid { grid-template-columns: 1fr; }
.nav-links { display: none; }
.serverlist { flex-wrap: wrap; }
.serverlist .stat { text-align: left; }
}
@media (max-width: 560px) {
.step { grid-template-columns: 1fr; gap: 18px; }
.step .num { width: 64px; height: 64px; font-size: 32px; }
.ip-chip .ip-val { font-size: 22px; }
.stat-grid { grid-template-columns: 1fr 1fr; }
}

View File

@@ -0,0 +1,541 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 25 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,270 @@
/* cyrillic */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,17 @@
---
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global
// [data-copy] script in [...lang].astro; data-label / data-copied drive the
// (localized) button text and transient feedback.
interface Props {
value: string;
variant?: "ip" | "url";
label?: string;
copiedLabel?: string;
}
const { value, variant = "ip", label = "Copy", copiedLabel = "Copied!" } = Astro.props;
---
<div class:list={["ip-chip", variant === "url" && "url"]}>
<span class="ip-val"><span class="pin">▸</span>{value}</span>
<button type="button" data-copy={value} data-label={label} data-copied={copiedLabel}>{label}</button>
</div>

View File

@@ -0,0 +1,17 @@
---
// 8x8 creeper face — pure markup, styled by .creeper in main.css.
const CREEPER = [
"00000000",
"01100110",
"01100110",
"00011000",
"00111100",
"00111100",
"00100100",
"00000000",
];
const cells = CREEPER.join("").split("");
---
<div class="creeper" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? "f" : undefined} />)}
</div>

View File

@@ -0,0 +1,20 @@
---
// 7x7 feature glyphs — styled by .picon in main.css.
interface Props {
glyph: "shield" | "diamond" | "orb" | "heart";
gold?: boolean;
}
const { glyph, gold = false } = Astro.props;
const GLYPHS: Record<Props["glyph"], string[]> = {
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
diamond: ["0001000", "0011100", "0111110", "1111111", "0111110", "0011100", "0001000"],
orb: ["0011100", "0111110", "1111111", "1111111", "1111111", "0111110", "0011100"],
heart: ["0110110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
};
const cells = GLYPHS[glyph].join("").split("");
const onClass = gold ? "go" : "on";
---
<div class="picon" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
</div>

View File

@@ -0,0 +1,31 @@
---
// Static server-list panel — mirrors the multiplayer-list row. No live count
// (plan 09-landing.md option B); shows version, motd and slot cap.
import Creeper from "./Creeper.astro";
import { site } from "../data/site";
interface Props {
modded: string;
motd: string;
upTo: string;
}
const { modded, motd, upTo } = Astro.props;
const { status } = site;
---
<div class="serverlist">
<div class="icon"><Creeper /></div>
<div class="meta">
<div class="row1">
<span class="title">{site.name}</span>
<span class="ver">Java {site.mcVersion}</span>
</div>
<p class="motd">
<span class="a">⛏ {modded}</span> · {motd}
</p>
</div>
<div class="stat">
<div class="players">
<span class="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
<span>{upTo} <b>{status.slots}</b></span>
</div>
</div>
</div>

View File

@@ -1,11 +1,28 @@
// Single source of truth for the landing page content.
// Single source of truth for the landing page's *non-translatable* config:
// domains, URLs, theme, launcher files, and the structural shape of the
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
// Minecraft / pack facts, surfaced in a few places.
const MC_VERSION = "1.21.1";
export const site = {
name: "Ulicraft",
tagline: "LAN-party Minecraft server",
baseDomain: BASE_DOMAIN,
mcVersion: MC_VERSION,
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
// mood: "grass" | "nether" | "end"
// hero: "centered" | "split" | "spotlight"
// headFont: "pixelify" | "8bit" | "silkscreen"
// dust: floating pixel particles in the hero
theme: {
mood: "grass",
hero: "centered",
headFont: "pixelify",
dust: true,
},
// Connect target shown to guests. No port needed — a dnsmasq SRV record
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
@@ -21,10 +38,35 @@ export const site = {
// Caddy local CA root — guests import this to trust the offline asset mirror.
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
// Static server-list panel (Status section). No fake live count — see plan
// 09-landing.md option B.
status: {
slots: 10,
},
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
// come from ui.ts (same order).
stats: [
{ key: "50+" },
{ key: MC_VERSION },
{ key: "10" },
{ key: "6h" },
],
// Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
// Text (h/p) comes from ui.ts (same order).
features: [
{ glyph: "diamond", gold: true },
{ glyph: "shield", gold: false },
{ glyph: "orb", gold: false },
{ glyph: "heart", gold: true },
],
// Launcher downloads. Stable filenames are symlinks created by
// tooling/fetch-launcher.sh — keep these in sync with that script.
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
// and hints are technical, kept language-neutral here.
launcher: {
name: "FjordLauncherUnlocked",
name: "Fjord Launcher",
base: "/launcher/latest",
downloads: [
{ os: "Windows", file: "fjord-windows-setup.exe", hint: "x64 installer" },
@@ -33,3 +75,18 @@ export const site = {
],
},
} as const;
// Product / in-game UI literals — never translated (they match what guests see
// on screen or type verbatim).
export const LITERALS = {
authlib: "authlib-injector",
multiplayer: "Multiplayer",
addServer: "Add Server",
} as const;
// Head-font CSS stacks, keyed by theme.headFont.
export const HEAD_FONTS: Record<string, string> = {
pixelify: "'Pixelify Sans', system-ui, sans-serif",
"8bit": "'Press Start 2P', monospace",
silkscreen: "'Silkscreen', system-ui, sans-serif",
};

340
landing/src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,340 @@
// Translatable UI copy for the landing page. Structure is identical across
// locales; the page (src/pages/[...lang].astro) renders against it.
//
// Conventions:
// - "{name}" in step1 is replaced with the launcher name at render time.
// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
// post/link) so the markup stays in the template, not in the strings.
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
// live in site.ts LITERALS and are never translated.
export type Lang = "en" | "es" | "eu";
export const LANGS: Lang[] = ["en", "es", "eu"];
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
// URL path per locale (en is default, served at root).
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
export interface UI {
htmlLang: string;
copied: string; // transient copy-button feedback
nav: { status: string; features: string; join: string; play: string };
hero: {
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
tagline: string;
sub: string;
copyIp: string;
howToJoin: string;
metaModpack: string;
metaLan: string;
};
status: {
eyebrow: string;
title: string;
lead: string;
modded: string;
motd: string;
upTo: string;
};
statLabels: [string, string, string, string];
features: {
eyebrow: string;
title: string;
lead: string;
items: { h: string; p: string }[];
};
join: {
eyebrow: string;
title: string;
lead: string;
s1: { kicker: string; h: string; p: string };
s2: {
kicker: string;
h: string;
pa: string; // before the authlib-injector literal
pb: string; // after it
copy: string;
registerPre: string;
caPre: string;
caLink: string;
caPost: string;
};
s3: { kicker: string; h: string; p: string };
s4: {
kicker: string;
h: string;
pa: string; // before the Multiplayer/Add Server kbds
pb: string; // after them
copy: string;
};
};
footer: { join: string; disc: string };
}
export const ui: Record<Lang, UI> = {
en: {
htmlLang: "en",
copied: "Copied!",
nav: { status: "Status", features: "Features", join: "How to Join", play: "Play Now" },
hero: {
eyebrow: "Modded LAN · NeoForge",
tagline: "Kitchen-sink survival, built to outlast the weekend.",
sub:
"A self-hosted modded server for the crew — a curated tech + magic + " +
"exploration pack, your own skins, one-click setup. Grab the address, " +
"import the pack, dig in.",
copyIp: "Copy IP",
howToJoin: "How to Join",
metaModpack: "NeoForge modpack",
metaLan: "LAN-only",
},
status: {
eyebrow: "Server",
title: "This is what you'll see.",
lead: "Exactly the entry that shows up in your multiplayer list.",
modded: "Modded",
motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
upTo: "up to",
},
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
features: {
eyebrow: "What makes it Ulicraft",
title: "Built for the crew.",
lead:
"Self-hosted, curated, and persistent — a modded server that respects " +
"your time and outlives the LAN.",
items: [
{
h: "Kitchen-sink pack",
p: "~50100 hand-picked mods — tech, magic, exploration and QoL. NeoForge 1.21.1, curated, no bloat.",
},
{
h: "Your identity, self-hosted",
p: "Drasl runs accounts and skins on the LAN. No Mojang login needed — your skin and cape just work.",
},
{
h: "One-click setup",
p: "packwiz installs and updates the whole modpack inside your launcher, so everyone stays in sync.",
},
{
h: "Built to last",
p: "Not a weekend throwaway — it runs on the homelab with 6-hour world backups. Your base survives.",
},
],
},
join: {
eyebrow: "How to Join · 4 Steps",
title: "From zero to spawning in.",
lead: "One-time setup. After this, the launcher keeps you in sync.",
s1: {
kicker: "Launcher",
h: "Download {name}",
p: "Grab {name} for your system — a Prism-based launcher with authlib-injector support built in.",
},
s2: {
kicker: "Account",
h: "Add your account",
pa: "In the launcher, add an",
pb: "account using this URL, then log in with your Ulicraft credentials.",
copy: "Copy",
registerPre: "Need credentials? Register at",
caPre: "Offline party? Download and trust the",
caLink: "local CA certificate",
caPost: "so the game-file mirror works over HTTPS.",
},
s3: {
kicker: "Modpack",
h: "Import the modpack",
p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.",
},
s4: {
kicker: "Connect",
h: "Join the server",
pa: "Launch the instance, open",
pb: ", paste the address, and join.",
copy: "Copy IP",
},
},
footer: {
join: "How to Join",
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
},
},
es: {
htmlLang: "es",
copied: "¡Copiado!",
nav: { status: "Estado", features: "Características", join: "Cómo entrar", play: "Jugar ahora" },
hero: {
eyebrow: "LAN con mods · NeoForge",
tagline: "Supervivencia todo incluido, para durar más que el finde.",
sub:
"Un servidor con mods autoalojado para la cuadrilla: un pack curado de " +
"tecnología, magia y exploración, tus propias skins y configuración en un " +
"clic. Copia la dirección, importa el pack y a cavar.",
copyIp: "Copiar IP",
howToJoin: "Cómo entrar",
metaModpack: "Pack NeoForge",
metaLan: "Solo LAN",
},
status: {
eyebrow: "Servidor",
title: "Esto es lo que verás.",
lead: "La misma entrada que aparece en tu lista de multijugador.",
modded: "Con mods",
motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
upTo: "hasta",
},
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
features: {
eyebrow: "Qué hace especial a Ulicraft",
title: "Hecho para la cuadrilla.",
lead:
"Autoalojado, curado y persistente: un servidor con mods que respeta tu " +
"tiempo y sobrevive a la LAN.",
items: [
{
h: "Pack todo incluido",
p: "~50100 mods elegidos a mano: tecnología, magia, exploración y QoL. NeoForge 1.21.1, curado y sin relleno.",
},
{
h: "Tu identidad, autoalojada",
p: "Drasl gestiona cuentas y skins en la LAN. Sin login de Mojang: tu skin y tu capa funcionan sin más.",
},
{
h: "Configuración en un clic",
p: "packwiz instala y actualiza todo el modpack dentro de tu launcher, así todos vais sincronizados.",
},
{
h: "Hecho para durar",
p: "No es de usar y tirar: corre en el homelab con copias del mundo cada 6 horas. Tu base sobrevive.",
},
],
},
join: {
eyebrow: "Cómo entrar · 4 pasos",
title: "De cero a aparecer en el mundo.",
lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
s1: {
kicker: "Launcher",
h: "Descarga {name}",
p: "Coge {name} para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
},
s2: {
kicker: "Cuenta",
h: "Añade tu cuenta",
pa: "En el launcher, añade una cuenta",
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
copy: "Copiar",
registerPre: "¿Sin credenciales? Regístrate en",
caPre: "¿Fiesta sin internet? Descarga y confía en el",
caLink: "certificado CA local",
caPost: "para que el mirror de archivos funcione por HTTPS.",
},
s3: {
kicker: "Modpack",
h: "Importa el modpack",
p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.",
},
s4: {
kicker: "Conectar",
h: "Entra al servidor",
pa: "Lanza la instancia, abre",
pb: ", pega la dirección y entra.",
copy: "Copiar IP",
},
},
footer: {
join: "Cómo entrar",
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
},
},
eu: {
htmlLang: "eu",
copied: "Kopiatuta!",
nav: { status: "Egoera", features: "Ezaugarriak", join: "Nola sartu", play: "Jokatu orain" },
hero: {
eyebrow: "Mod-dun LANa · NeoForge",
tagline: "Mod ugariko biziraupena, irauteko egina.",
sub:
"Koadrilarentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
"esplorazio pack zaindua, zure azalak eta konfigurazioa klik bakarrean. " +
"Hartu helbidea, inportatu packa eta hasi zulatzen.",
copyIp: "Kopiatu IPa",
howToJoin: "Nola sartu",
metaModpack: "NeoForge packa",
metaLan: "LAN soilik",
},
status: {
eyebrow: "Zerbitzaria",
title: "Hau ikusiko duzu.",
lead: "Zure multijokalari-zerrendan agertzen den sarrera bera.",
modded: "Mod-duna",
motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
upTo: "gehienez",
},
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
features: {
eyebrow: "Zerk egiten du Ulicraft berezi",
title: "Koadrilarentzat eginda.",
lead:
"Auto-ostatatua, zaindua eta iraunkorra: zure denbora errespetatzen duen " +
"eta LANa gainditzen duen mod-dun zerbitzaria.",
items: [
{
h: "Pack osoa",
p: "Eskuz aukeratutako ~50100 mod: teknologia, magia, esplorazioa eta QoL. NeoForge 1.21.1, zaindua eta betegarririk gabe.",
},
{
h: "Zure nortasuna, auto-ostatatua",
p: "Draslek kontuak eta azalak LANean kudeatzen ditu. Mojang loginik gabe: zure azalak eta kapak besterik gabe dabiltza.",
},
{
h: "Konfigurazioa klik batean",
p: "packwiz-ek modpack osoa instalatu eta eguneratzen du zure launcherrean, denok sinkronizatuta egoteko.",
},
{
h: "Irauteko eginda",
p: "Ez da behin erabili eta botatzekoa: homelab-ean dabil, munduaren babeskopiak 6 orduro. Zure basea bizirik.",
},
],
},
join: {
eyebrow: "Nola sartu · 4 urrats",
title: "Zerotik mundura agertzera.",
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
s1: {
kicker: "Launcherra",
h: "Deskargatu {name}",
p: "Hartu {name} zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
},
s2: {
kicker: "Kontua",
h: "Gehitu zure kontua",
pa: "Launcherrean, gehitu",
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
copy: "Kopiatu",
registerPre: "Kredentzialik ez? Erregistratu hemen:",
caPre: "Internetik gabeko festa? Deskargatu eta fidatu",
caLink: "tokiko CA ziurtagiriaz",
caPost: "fitxategi-mirrorrak HTTPS bidez ibil dadin.",
},
s3: {
kicker: "Modpacka",
h: "Inportatu modpacka",
p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.",
},
s4: {
kicker: "Konektatu",
h: "Sartu zerbitzarira",
pa: "Abiarazi instantzia, ireki",
pb: ", itsatsi helbidea eta sartu.",
copy: "Kopiatu IPa",
},
},
footer: {
join: "Nola sartu",
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
},
},
};

View File

@@ -0,0 +1,317 @@
---
import { site, LITERALS, HEAD_FONTS } from "../data/site";
import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
import Creeper from "../components/Creeper.astro";
import PixelIcon from "../components/PixelIcon.astro";
import CopyChip from "../components/CopyChip.astro";
import ServerListPanel from "../components/ServerListPanel.astro";
import "../styles/main.css";
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
export function getStaticPaths() {
return [
{ params: { lang: undefined }, props: { locale: "en" as Lang } },
{ params: { lang: "es" }, props: { locale: "es" as Lang } },
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
];
}
const { locale } = Astro.props;
const t = ui[locale];
const { theme, launcher } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
const launcherName = launcher.name;
// Build-time floating dust bits (index-derived, no runtime RNG).
const dustBits = Array.from({ length: 16 }, (_, i) => {
const size = 3 + (i % 3) * 2;
return {
left: `${(i * 61) % 100}%`,
size: `${size}px`,
bg: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
opacity: 0.18 + (i % 3) * 0.06,
anim: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
};
});
---
<!doctype html>
<html
lang={t.htmlLang}
data-mood={theme.mood}
data-hero={theme.hero}
data-head={theme.headFont}
style={`--font-head: ${headFont}`}
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {t.hero.howToJoin}</title>
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
</head>
<body>
<!-- NAV -->
<header class="nav">
<div class="wrap nav-in">
<a class="brand" href="#top">
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<nav class="nav-links">
<a href="#status">{t.nav.status}</a>
<a href="#features">{t.nav.features}</a>
<a href="#join">{t.nav.join}</a>
</nav>
<span class="nav-spacer"></span>
<div class="lang-switch" aria-label="Language">
{LANGS.map((l) => (
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
))}
</div>
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
</div>
</header>
<main id="top">
<!-- HERO -->
<section class="hero">
{theme.dust && (
<div class="dust" aria-hidden="true">
{dustBits.map((b) => (
<i
style={`position:absolute;left:${b.left};bottom:-20px;width:${b.size};height:${b.size};background:${b.bg};opacity:${b.opacity};image-rendering:pixelated;animation:${b.anim}`}
/>
))}
</div>
)}
<div class="wrap hero-grid">
<div class="hero-logo">
<img src="/logo.png" alt={site.name} width="707" height="148" />
</div>
<div class="hero-copy">
<span class="eyebrow">{t.hero.eyebrow} {site.mcVersion}</span>
<h1 class="hero-tagline">{t.hero.tagline}</h1>
<p class="hero-sub">{t.hero.sub}</p>
<div class="hero-ip-row">
<CopyChip value={site.serverAddress} variant="ip" label={t.hero.copyIp} copiedLabel={t.copied} />
<a class="mc-btn" href="#join">{t.hero.howToJoin}</a>
</div>
<div class="hero-meta">
<span>Java {site.mcVersion}</span>
<span class="dot"></span>
<span>{t.hero.metaModpack}</span>
<span class="dot"></span>
<span>{t.hero.metaLan}</span>
</div>
</div>
<div class="hero-status">
<ServerListPanel
modded={t.status.modded}
motd={t.status.motd}
upTo={t.status.upTo}
/>
</div>
</div>
</section>
<!-- STATUS -->
<section id="status" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.status.eyebrow}</span>
<h2 class="section-title">{t.status.title}</h2>
<p class="lead">{t.status.lead}</p>
</div>
<div class="reveal">
<ServerListPanel
modded={t.status.modded}
motd={t.status.motd}
upTo={t.status.upTo}
/>
</div>
<div class="stat-grid">
{site.stats.map((s, i) => (
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
<div class="k">{s.key}</div>
<div class="l">{t.statLabels[i]}</div>
</div>
))}
</div>
</div>
</section>
<!-- FEATURES -->
<section id="features" class="pad" style="background: var(--bg-2)">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.features.eyebrow}</span>
<h2 class="section-title">{t.features.title}</h2>
<p class="lead">{t.features.lead}</p>
</div>
<div class="feat-grid">
{site.features.map((f, i) => (
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{t.features.items[i].h}</h3>
<p>{t.features.items[i].p}</p>
</div>
</div>
))}
</div>
</div>
</section>
<!-- HOW TO JOIN -->
<section id="join" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.join.eyebrow}</span>
<h2 class="section-title">{t.join.title}</h2>
<p class="lead">{t.join.lead}</p>
</div>
<div class="steps">
<!-- STEP 1 -->
<div class="step reveal">
<div class="num">1</div>
<div class="body">
<span class="kicker">{t.join.s1.kicker}</span>
<h3>{t.join.s1.h.replace("{name}", launcherName)}</h3>
<p>{t.join.s1.p.replace(/\{name\}/g, launcherName)}</p>
<div class="actions">
{launcher.downloads.map((d) => (
<a class="mc-btn sm" href={`${launcher.base}/${d.file}`}>
{d.os} <span class="hint">· {d.hint}</span>
</a>
))}
</div>
</div>
</div>
<!-- STEP 2 -->
<div class="step reveal">
<div class="num">2</div>
<div class="body">
<span class="kicker">{t.join.s2.kicker}</span>
<h3>{t.join.s2.h}</h3>
<p>
{t.join.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {t.join.s2.pb}
</p>
<div class="actions" style="margin-bottom:14px">
<CopyChip value={site.authlibUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
</div>
<p>
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>.
</p>
<span class="hint">
{t.join.s2.caPre} <a href={site.caCertUrl}>{t.join.s2.caLink}</a> {t.join.s2.caPost}
</span>
</div>
</div>
<!-- STEP 3 -->
<div class="step reveal">
<div class="num">3</div>
<div class="body">
<span class="kicker">{t.join.s3.kicker}</span>
<h3>{t.join.s3.h}</h3>
<p>{t.join.s3.p}</p>
<div class="actions">
<CopyChip value={site.packwizUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
<!-- STEP 4 -->
<div class="step reveal">
<div class="num">4</div>
<div class="body">
<span class="kicker">{t.join.s4.kicker}</span>
<h3>{t.join.s4.h}</h3>
<p>
{t.join.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s4.pb}
</p>
<div class="actions">
<CopyChip value={site.serverAddress} variant="ip" label={t.join.s4.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="footer">
<div class="wrap footer-in">
<a class="brand" href="#top">
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<div class="footer-links">
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
</div>
<p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain}
</p>
</div>
</footer>
<script>
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
function copyText(text: string) {
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
return;
}
} catch {}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch {}
document.body.removeChild(ta);
}
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
let timer: number | undefined;
btn.addEventListener("click", () => {
copyText(btn.dataset.copy ?? "");
const label = btn.dataset.label ?? "Copy";
btn.textContent = btn.dataset.copied ?? "Copied!";
btn.classList.add("copied");
clearTimeout(timer);
timer = window.setTimeout(() => {
btn.textContent = label;
btn.classList.remove("copied");
}, 1600);
});
});
// Scroll reveal — visible at rest; .anim added when scrolled into view.
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("anim");
io.unobserve(e.target);
}
}
},
{ rootMargin: "0px 0px -10% 0px" }
);
els.forEach((el) => io.observe(el));
}
</script>
</body>
</html>

View File

@@ -1,199 +0,0 @@
---
import { site } from "../data/site";
const { launcher } = site;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — join the server</title>
<meta name="description" content={`${site.name}: ${site.tagline}`} />
<link rel="icon" type="image/png" href="/logo.png" />
</head>
<body>
<main>
<header class="hero">
<img class="logo" src="/logo.png" alt={`${site.name} logo`} width="128" height="128" />
<h1>{site.name}</h1>
<p class="tagline">{site.tagline}</p>
<p class="addr">
<span class="addr-label">Server</span>
<code id="server-addr">{site.serverAddress}</code>
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
</p>
</header>
<ol class="steps">
<li>
<h2><span class="num">1</span> Download the launcher</h2>
<p>Grab <strong>{launcher.name}</strong> for your system.</p>
<div class="downloads">
{launcher.downloads.map((d) => (
<a class="dl" href={`${launcher.base}/${d.file}`}>
<span class="dl-os">{d.os}</span>
<span class="dl-hint">{d.hint}</span>
</a>
))}
</div>
</li>
<li>
<h2><span class="num">2</span> Add your account</h2>
<p>In the launcher, add an <strong>authlib-injector</strong> account using this URL:</p>
<p>
<code>{site.authlibUrl}</code>
<button class="copy" data-copy={site.authlibUrl} type="button">Copy</button>
</p>
<p>
Need credentials? Register at <a href={site.authUrl}>{site.authUrl}</a>, then log in
with that username and password.
</p>
<p class="note">
Offline party? Download and trust the
<a href={site.caCertUrl}>local CA certificate</a> so the game-file
mirror works over HTTPS.
</p>
</li>
<li>
<h2><span class="num">3</span> Import the modpack</h2>
<p>Add a new instance from this packwiz URL:</p>
<p>
<code>{site.packwizUrl}</code>
<button class="copy" data-copy={site.packwizUrl} type="button">Copy</button>
</p>
</li>
<li>
<h2><span class="num">4</span> Connect</h2>
<p>Launch the instance, add a server, and join:</p>
<p>
<code>{site.serverAddress}</code>
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
</p>
</li>
</ol>
<footer>
<p>{site.name} · {site.baseDomain}</p>
</footer>
</main>
<script>
// Copy-to-clipboard for every [data-copy] button.
document.querySelectorAll<HTMLButtonElement>("button.copy").forEach((btn) => {
btn.addEventListener("click", async () => {
const text = btn.dataset.copy ?? "";
try {
await navigator.clipboard.writeText(text);
const prev = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => (btn.textContent = prev), 1200);
} catch {
btn.textContent = "Copy failed";
}
});
});
</script>
<style>
:root {
--stone: #7a7a7a;
--bg: #f4f4f2;
--card: #ffffff;
--ink: #1c1c1c;
--muted: #6b6b6b;
--line: #e2e2df;
--accent: #4f7942;
}
* { box-sizing: border-box; }
html { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
line-height: 1.55;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.25rem 4rem;
}
.hero { text-align: center; margin-bottom: 2.5rem; }
.logo {
image-rendering: pixelated;
border-radius: 12px;
margin-bottom: 0.5rem;
}
h1 { font-size: 2.4rem; margin: 0.25rem 0 0; letter-spacing: -0.5px; }
.tagline { color: var(--muted); margin: 0.25rem 0 1.25rem; }
.addr {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.addr-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
}
code {
background: #ededea;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.2rem 0.5rem;
font-size: 0.95rem;
word-break: break-all;
}
.copy {
border: 1px solid var(--line);
background: var(--card);
border-radius: 6px;
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
}
.copy:hover { border-color: var(--stone); }
.steps { list-style: none; padding: 0; margin: 0; display: grid; gap: 1rem; }
.steps > li {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1.25rem 1.5rem;
}
.steps h2 { font-size: 1.2rem; margin: 0 0 0.5rem; display: flex; align-items: center; gap: 0.6rem; }
.num {
display: inline-grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-size: 0.95rem;
}
.steps p { margin: 0.4rem 0; }
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-top: 0.75rem; }
.dl {
display: flex;
flex-direction: column;
text-decoration: none;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.8rem 1rem;
}
.dl:hover { border-color: var(--accent); }
.dl-os { font-weight: 600; }
.dl-hint { font-size: 0.8rem; color: var(--muted); }
footer { text-align: center; color: var(--muted); font-size: 0.85rem; margin-top: 2.5rem; }
a { color: var(--accent); }
</style>
</body>
</html>

608
landing/src/styles/main.css Normal file
View File

@@ -0,0 +1,608 @@
/* ============================================================
ULICRAFT — "Carved from stone"
Design system: dark overworld, MC-GUI bevels, pixel type
============================================================ */
/* ---- Mood palettes (data-mood on <html>) ---- */
:root,
:root[data-mood="grass"] {
--bg: oklch(0.165 0.014 150);
--bg-2: oklch(0.205 0.016 150);
--surface: oklch(0.235 0.017 150);
--surface-2: oklch(0.285 0.018 150);
--slot: oklch(0.185 0.014 150);
--bevel-hi: oklch(0.40 0.018 150);
--bevel-lo: oklch(0.115 0.012 150);
--line: oklch(0.33 0.016 150);
--accent: oklch(0.72 0.16 145); /* grass */
--accent-hi: oklch(0.80 0.15 145);
--accent-lo: oklch(0.55 0.15 145);
--accent-ink: oklch(0.17 0.05 150); /* text on accent */
--gold: oklch(0.82 0.135 85);
--glow: oklch(0.72 0.16 145 / 0.30);
--text: oklch(0.95 0.008 110);
--muted: oklch(0.72 0.012 145);
--dim: oklch(0.55 0.012 145);
}
:root[data-mood="nether"] {
--bg: oklch(0.165 0.018 35);
--bg-2: oklch(0.205 0.022 32);
--surface: oklch(0.235 0.026 32);
--surface-2: oklch(0.285 0.030 32);
--slot: oklch(0.185 0.020 32);
--bevel-hi: oklch(0.42 0.040 35);
--bevel-lo: oklch(0.115 0.016 32);
--line: oklch(0.34 0.030 32);
--accent: oklch(0.64 0.19 32); /* nether red */
--accent-hi: oklch(0.72 0.18 38);
--accent-lo: oklch(0.50 0.17 30);
--accent-ink: oklch(0.16 0.04 32);
--gold: oklch(0.83 0.14 75);
--glow: oklch(0.64 0.19 32 / 0.34);
--text: oklch(0.95 0.010 60);
--muted: oklch(0.74 0.020 45);
--dim: oklch(0.56 0.020 40);
}
:root[data-mood="end"] {
--bg: oklch(0.155 0.018 305);
--bg-2: oklch(0.195 0.022 305);
--surface: oklch(0.225 0.026 305);
--surface-2: oklch(0.275 0.030 305);
--slot: oklch(0.175 0.020 305);
--bevel-hi: oklch(0.42 0.040 305);
--bevel-lo: oklch(0.110 0.016 305);
--line: oklch(0.34 0.030 305);
--accent: oklch(0.74 0.135 175); /* end teal */
--accent-hi: oklch(0.82 0.13 175);
--accent-lo: oklch(0.58 0.13 178);
--accent-ink: oklch(0.16 0.04 200);
--gold: oklch(0.84 0.13 95);
--glow: oklch(0.70 0.16 300 / 0.34);
--text: oklch(0.96 0.010 300);
--muted: oklch(0.76 0.020 300);
--dim: oklch(0.58 0.020 300);
}
/* ---- Fonts (switchable head face via --font-head) ---- */
:root {
--font-head: 'Pixelify Sans', system-ui, sans-serif;
--font-body: 'Space Grotesk', system-ui, sans-serif;
--font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
--font-mono: 'VT323', monospace; /* IP / terminal */
--maxw: 1160px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* faint pixel-grid stone texture over everything */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
background-size: 4px 4px, 4px 4px;
mix-blend-mode: overlay;
opacity: 0.5;
}
img { display: block; max-width: 100%; }
::selection { background: var(--accent); color: var(--accent-ink); }
/* ---- Typography helpers ---- */
.eyebrow {
font-family: var(--font-pixel);
font-size: clamp(9px, 1.1vw, 11px);
letter-spacing: 0.12em;
color: var(--accent);
text-transform: uppercase;
line-height: 1.8;
}
h1, h2, h3 {
font-family: var(--font-head);
font-weight: 600;
line-height: 1.02;
letter-spacing: 0.01em;
margin: 0;
}
.section-title {
font-size: clamp(34px, 5vw, 58px);
}
.lead { color: var(--muted); }
.mono { font-family: var(--font-mono); }
/* in-copy links (steps, hints) — accent, not browser blue */
.step a, .hint a, .hero-sub a { color: var(--accent); text-underline-offset: 2px; }
/* ---- Layout ---- */
.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
section { position: relative; z-index: 1; }
.pad { padding-block: clamp(64px, 9vw, 130px); }
.sec-head { max-width: 640px; margin-bottom: 48px; }
.sec-head .eyebrow { display: block; margin-bottom: 14px; }
.sec-head p { margin: 16px 0 0; font-size: 18px; }
/* ============================================================
MINECRAFT-STYLE BUTTON
============================================================ */
.mc-btn {
--b: var(--surface-2);
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-head);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text);
background: var(--b);
border: 0;
padding: 15px 24px 17px;
cursor: pointer;
text-decoration: none;
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 4px 0 oklch(0 0 0 / 0.5),
0 8px 18px oklch(0 0 0 / 0.4);
transition: transform .08s ease, filter .12s ease;
image-rendering: pixelated;
text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
white-space: nowrap;
}
.mc-btn:hover { filter: brightness(1.12); }
.mc-btn:active {
transform: translateY(4px);
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 0 0 oklch(0 0 0 / 0.5),
0 2px 8px oklch(0 0 0 / 0.4);
}
.mc-btn.primary {
--b: var(--accent);
--bevel-hi: var(--accent-hi);
--bevel-lo: var(--accent-lo);
color: var(--accent-ink);
text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
}
.mc-btn.gold {
--b: var(--gold);
--bevel-hi: oklch(0.90 0.10 90);
--bevel-lo: oklch(0.66 0.13 75);
color: oklch(0.20 0.05 80);
text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
}
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
/* ---- Copy-IP chip ---- */
.ip-chip {
display: inline-flex;
align-items: stretch;
background: var(--slot);
box-shadow:
inset 2px 2px 0 var(--bevel-lo),
inset -2px -2px 0 var(--bevel-hi);
overflow: hidden;
}
.ip-chip .ip-val {
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
font-family: var(--font-mono);
font-size: 28px;
line-height: 1;
color: var(--gold);
letter-spacing: 0.02em;
}
.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
.ip-chip button {
border: 0;
cursor: pointer;
padding: 14px 18px;
font-family: var(--font-head);
font-weight: 600;
font-size: 15px;
background: var(--accent);
color: var(--accent-ink);
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
transition: filter .12s ease;
white-space: nowrap;
}
.ip-chip button:hover { filter: brightness(1.1); }
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
/* url variant: long auth/packwiz URLs — smaller, wraps instead of overflowing */
.ip-chip.url { max-width: 100%; }
.ip-chip.url .ip-val {
font-family: var(--font-mono);
font-size: 18px;
padding: 8px 14px;
word-break: break-all;
color: var(--text);
}
.ip-chip.url button { font-size: 14px; padding: 10px 16px; }
/* ============================================================
NAV
============================================================ */
.nav {
position: sticky;
top: 0;
z-index: 50;
background: oklch(0.165 0.014 150 / 0.72);
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--line);
}
.nav-in {
display: flex;
align-items: center;
gap: 24px;
height: 68px;
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
.brand .name {
font-family: var(--font-head);
font-weight: 600;
font-size: 22px;
letter-spacing: 0.02em;
}
.nav-links { display: flex; gap: 6px; margin-left: 12px; }
/* language switcher */
.lang-switch { display: flex; gap: 2px; }
.lang-switch a {
font-family: var(--font-pixel);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--dim);
text-decoration: none;
padding: 7px 8px;
line-height: 1;
transition: color .12s ease, background .12s ease;
}
.lang-switch a:hover { color: var(--text); background: var(--surface); }
.lang-switch a.on { color: var(--accent-ink); background: var(--accent); }
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 15px;
font-weight: 500;
padding: 8px 12px;
transition: color .12s ease, background .12s ease;
}
.nav-links a:hover { color: var(--text); background: var(--surface); }
.nav-spacer { flex: 1; }
/* ---- Creeper pixel mark ---- */
.creeper {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
width: 34px;
height: 34px;
background: var(--accent);
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
image-rendering: pixelated;
}
.creeper i { background: transparent; }
.creeper i.f { background: oklch(0.16 0.03 150); }
/* ============================================================
HERO
============================================================ */
.hero {
position: relative;
padding-top: clamp(48px, 7vw, 90px);
padding-bottom: clamp(56px, 8vw, 110px);
overflow: hidden;
}
.hero::before { /* spotlight glow behind logo */
content: "";
position: absolute;
top: -10%;
left: 50%;
width: 1100px;
height: 760px;
transform: translateX(-50%);
background: radial-gradient(closest-side, var(--glow), transparent 72%);
pointer-events: none;
filter: blur(8px);
}
.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
/* layout: centered (default) */
:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
:root[data-hero="centered"] .hero-cta { justify-content: center; }
:root[data-hero="centered"] .hero-meta { justify-content: center; }
:root[data-hero="centered"] .hero-copy { max-width: 720px; }
:root[data-hero="centered"] .hero-status { display: none; }
/* layout: split (copy left, live panel right) */
:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
:root[data-hero="split"] .hero-logo { max-width: 560px; }
:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
/* layout: spotlight (giant logo, minimal text, centered) */
:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
:root[data-hero="spotlight"] .hero-status { display: none; }
:root[data-hero="spotlight"] .hero-features-note { display: none; }
.hero-logo { width: 100%; max-width: 760px; }
.hero-logo img {
width: 100%;
image-rendering: pixelated;
filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
margin-inline: auto;
}
.hero-copy { display: flex; flex-direction: column; gap: 22px; }
.hero-tagline {
font-family: var(--font-head);
font-weight: 600;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.08;
letter-spacing: 0.01em;
text-wrap: balance;
}
.hero-tagline .hl { color: var(--accent); }
.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
/* status pill */
.live-pill {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 14px 7px 11px;
background: var(--slot);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
font-size: 14px;
color: var(--muted);
font-weight: 500;
}
.live-dot {
width: 9px; height: 9px; background: var(--accent);
box-shadow: 0 0 0 3px var(--glow);
animation: pulse 2.2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
/* ============================================================
SERVER-LIST STATUS PANEL
============================================================ */
.serverlist {
display: flex;
gap: 18px;
align-items: center;
background: var(--slot);
padding: 16px;
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 8px 24px oklch(0 0 0 / 0.4);
}
.serverlist .icon {
width: 72px; height: 72px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--bg-2);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
}
.serverlist .icon .creeper { width: 48px; height: 48px; }
.serverlist .meta { flex: 1; min-width: 0; }
.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
.serverlist .motd .g { color: var(--gold); }
.serverlist .motd .a { color: var(--accent); }
.serverlist .stat { text-align: right; flex-shrink: 0; }
.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
.serverlist .players b { color: var(--text); }
/* signal bars */
.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
.bars i:nth-child(1){height:25%}
.bars i:nth-child(2){height:45%}
.bars i:nth-child(3){height:65%}
.bars i:nth-child(4){height:85%}
.bars i:nth-child(5){height:100%}
/* stat tiles */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 28px;
}
.tile {
background: var(--surface);
padding: 22px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
.tile .k .u { color: var(--accent); font-size: 22px; }
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
/* ============================================================
FEATURES
============================================================ */
.feat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
}
.feat {
display: flex;
gap: 18px;
background: var(--surface);
padding: 26px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
transition: transform .14s ease, filter .14s ease;
}
.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
.feat h3 { font-size: 21px; margin-bottom: 8px; }
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
/* pixel icon */
.picon {
width: 52px; height: 52px; flex-shrink: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(7, 1fr);
background: var(--slot);
padding: 4px;
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
image-rendering: pixelated;
}
.picon i { background: transparent; }
.picon i.on { background: var(--accent); }
.picon i.go { background: var(--gold); }
/* ============================================================
HOW TO JOIN — steps
============================================================ */
.steps { display: flex; flex-direction: column; gap: 20px; }
.step {
display: grid;
grid-template-columns: 92px 1fr;
gap: 28px;
background: var(--surface);
padding: 30px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.step .num {
width: 92px; height: 92px;
display: grid; place-items: center;
background: var(--slot);
font-family: var(--font-head);
font-weight: 600;
font-size: 46px;
color: var(--accent);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
}
.step .body { padding-top: 2px; }
.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
.step h3 { font-size: 26px; margin: 10px 0 12px; }
.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
.step .hint { color: var(--dim); font-size: 14px; }
.step kbd {
font-family: var(--font-mono); font-size: 18px; line-height: 1;
background: var(--bg-2); color: var(--text);
padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
/* ============================================================
FOOTER
============================================================ */
.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
.footer .brand .name { font-size: 20px; }
.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
/* ---- floating dust ---- */
.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
@keyframes rise {
0% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
.hero-grid { z-index: 1; }
/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
:root[data-head="8bit"] .brand .name { font-size: 15px; }
:root[data-head="8bit"] .mc-btn { font-size: 12px; }
:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
:root[data-head="8bit"] .step .num { font-size: 30px; }
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
:root[data-head="8bit"] .tile .k { font-size: 26px; }
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
:root[data-head="8bit"] .live-pill { font-size: 12px; }
/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
/* ============================================================
SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
============================================================ */
.reveal { will-change: opacity, transform; }
@keyframes reveal-in {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: no-preference) {
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 880px) {
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
:root[data-hero="split"] .hero-status { display: block; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.feat-grid { grid-template-columns: 1fr; }
.nav-links { display: none; }
.serverlist { flex-wrap: wrap; }
.serverlist .stat { text-align: left; }
}
@media (max-width: 560px) {
.step { grid-template-columns: 1fr; gap: 18px; }
.step .num { width: 64px; height: 64px; font-size: 32px; }
.ip-chip .ip-val { font-size: 22px; }
.stat-grid { grid-template-columns: 1fr 1fr; }
}

View File

@@ -0,0 +1,116 @@
# ──────────────────────────────────────────────────────────────────
# Ulicraft host nginx — TLS terminator in FRONT of caddy.
# ──────────────────────────────────────────────────────────────────
# Alternative to ulicraft.conf.tmpl. Instead of nginx serving the static
# files + proxying only drasl, here caddy (the docker container) is the single
# ingress for ALL vhosts and nginx just terminates TLS and reverse-proxies
# everything to caddy by Host header.
#
# Caddy must be published on a localhost port — set in .env:
# CADDY_HTTP_PORT=8880
# CADDY_HTTP_BIND=127.0.0.1
# and the stack brought up with the caddy override (NOT the nginx override):
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.static.yml up -d
#
# Render with BOTH vars then install:
# CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# Prereqs:
# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs/<name>/{cert,key}.pem
# ──────────────────────────────────────────────────────────────────
upstream ulicraft_caddy {
server 127.0.0.1:${CADDY_HTTP_PORT};
}
# HTTP -> HTTPS for every name.
server {
listen 80;
listen [::]:80;
server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN} distribution.${BASE_DOMAIN};
return 301 https://$host$request_uri;
}
# One TLS server block per name (each has its own LE cert). All proxy to caddy;
# caddy routes by the forwarded Host header (apex -> www, auth -> drasl,
# pack -> packwiz files).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
client_max_body_size 4m; # skin uploads (auth) reuse the same proxy block
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name auth.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
client_max_body_size 4m; # skin uploads
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name pack.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name distribution.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/key.pem;
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

93
nginx/ulicraft.conf.tmpl Normal file
View File

@@ -0,0 +1,93 @@
# ──────────────────────────────────────────────────────────────────
# Ulicraft host nginx — TLS ingress with Let's Encrypt certs.
# ──────────────────────────────────────────────────────────────────
# Template. Render with BOTH vars then install:
#
# APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
# envsubst '$APP_DIR $BASE_DOMAIN' \
# < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# Prereqs:
# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs/<name>/{cert,key}.pem
# - the docker stack up with the nginx override (publishes drasl on 127.0.0.1:25585):
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
# - nginx's user (www-data) can READ $APP_DIR/{www,pack,custom,certs}
# ──────────────────────────────────────────────────────────────────
# HTTP -> HTTPS for every name.
server {
listen 80;
listen [::]:80;
server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN};
return 301 https://$host$request_uri;
}
# Apex — static landing page (./www) + launcher downloads (./mirror/launcher).
# /ca.crt from the caddy path is intentionally omitted: LE certs are publicly
# trusted, so guests need no CA import.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
# Launcher downloads (populated by tooling/fetch-launcher.sh -> ./mirror/launcher).
location /launcher/ {
alias ${APP_DIR}/mirror/launcher/;
autoindex on;
}
location / {
root ${APP_DIR}/www;
index index.html;
try_files $uri $uri/ =404;
}
}
# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name pack.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
# Locally-hosted custom mod jars live outside ./pack; expose them at /custom/.
location /custom/ {
alias ${APP_DIR}/custom/;
autoindex on;
}
location / {
root ${APP_DIR}/pack;
autoindex on;
}
}
# Auth — reverse proxy to drasl (published on localhost by the nginx override).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name auth.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
client_max_body_size 4m; # skin uploads
location / {
proxy_pass http://127.0.0.1:25585;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

3
pack/.packwizignore Normal file
View File

@@ -0,0 +1,3 @@
# Template sources for pack metadata/config — rendered to their real files by
# tooling/render-pack.sh. packwiz must NOT index the templates themselves.
*.tmpl

View File

@@ -0,0 +1,11 @@
{
"enable": true,
"loadlist": [
{
"name": "Ulicraft Skins",
"type": "MojangAPI",
"apiRoot": "https://auth.${BASE_DOMAIN}/account/",
"sessionRoot": "https://auth.${BASE_DOMAIN}/session/"
}
]
}

View File

@@ -1,2 +0,0 @@
hash-format = "sha256"
files = []

View File

@@ -0,0 +1,8 @@
name = "balm-neoforge-1.21.1-21.0.58"
filename = "balm-neoforge-1.21.1-21.0.58.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/balm-neoforge-1.21.1-21.0.58.jar"
hash-format = "sha256"
hash = "aa2463747bad4c2351869226ea7bce7839510dfd2ac387f8d1588b1ce9309c67"

View File

@@ -0,0 +1,8 @@
name = "CustomSkinLoader_Universal-14.28"
filename = "CustomSkinLoader_Universal-14.28.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/CustomSkinLoader_Universal-14.28.jar"
hash-format = "sha256"
hash = "0008fb0a91dd1e84d52162cfe96addb5f6aed3b76d426ea7305d7624e31f1894"

View File

@@ -0,0 +1,8 @@
name = "inventoryessentials-neoforge-1.21.1-21.1.15"
filename = "inventoryessentials-neoforge-1.21.1-21.1.15.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/inventoryessentials-neoforge-1.21.1-21.1.15.jar"
hash-format = "sha256"
hash = "b7a5c5526a45256551543419ba2b5d54e56c585d76a645a8cea8cb8ea843ae2c"

View File

@@ -0,0 +1,8 @@
name = "Jade-1.21.1-NeoForge-15.10.5"
filename = "Jade-1.21.1-NeoForge-15.10.5.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/Jade-1.21.1-NeoForge-15.10.5.jar"
hash-format = "sha256"
hash = "067bb4b007e1d6f6b79f0afe99c91252aa825472b99a76d33a60d24442f9e92d"

View File

@@ -0,0 +1,8 @@
name = "JadeAddons-1.21.1-NeoForge-6.1.0"
filename = "JadeAddons-1.21.1-NeoForge-6.1.0.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/JadeAddons-1.21.1-NeoForge-6.1.0.jar"
hash-format = "sha256"
hash = "5f242668ad710092964d4d9afda8a94fa2401665ea13eea67d55d07e4dff8a64"

View File

@@ -0,0 +1,8 @@
name = "jei-1.21.1-neoforge-19.27.0.340"
filename = "jei-1.21.1-neoforge-19.27.0.340.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/jei-1.21.1-neoforge-19.27.0.340.jar"
hash-format = "sha256"
hash = "8aaf547432f1b4958239b036356b910692fe40f858c3073d996f56bbf7c99826"

View File

@@ -0,0 +1,8 @@
name = "JustEnoughProfessions-neoforge-1.21.1-4.0.5"
filename = "JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
hash-format = "sha256"
hash = "b320b47f1791f56df9e1a05138f0e312e6e54541945ff15f2f1fd398cd1d4d9d"

View File

@@ -0,0 +1,8 @@
name = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17"
filename = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
hash-format = "sha256"
hash = "7a47d69b5530704690d5a5f0726cd74a2d6c57df17dd4a8589c4ab5212f91460"

View File

@@ -0,0 +1,8 @@
name = "MouseTweaks-neoforge-mc1.21-2.26.1"
filename = "MouseTweaks-neoforge-mc1.21-2.26.1.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/MouseTweaks-neoforge-mc1.21-2.26.1.jar"
hash-format = "sha256"
hash = "68e6f4201c5de97b77929a7215c9552495696ca6a3bf3ae4eacc34e135f6cc8b"

View File

@@ -6,7 +6,7 @@ pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "7b81c9e9d9e6e21a9865a2dded0dfd77e7683d679b98fe5b0114eaed9a31f17e"
hash = "9bca0b1a4434050835cab46116d7c14aafec971ff5a95a1ee0c4232c8bcbc355"
[versions]
minecraft = "1.21.1"

80
plan/03b-blessing-skin.md Normal file
View File

@@ -0,0 +1,80 @@
# Blessing Skin — auth + skin server (Drasl alternative)
## Summary
Drop-in alternative to Drasl (`plan/03-drasl.md`). Blessing Skin Server (BSS) is
a PHP skin station; the **yggdrasil-api plugin** gives it a Yggdrasil API that
authlib-injector talks to — same end result as Drasl, different internals.
Run it as an override **instead of** Drasl:
```
docker compose -f docker-compose.yml -f docker-compose.blessingskin.yml up -d
```
The override (`docker-compose.blessingskin.yml`) repoints Caddy's `auth.*` vhost
at `blessing-skin:80`, adds `mariadb` + `blessing-skin`, and switches the
Minecraft authlib endpoint to `/api/yggdrasil`. Drasl stays defined but idle and
unreachable (compose merges `depends_on`, so an override can't remove it);
`docker compose … stop drasl` after up if you want it down.
## Drasl vs Blessing Skin — what changes
| | Drasl | Blessing Skin |
|---|---|---|
| Backend | single Go binary | PHP app + **MariaDB** (no SQLite) |
| Config | rendered `config.toml` | **web install wizard** + DB |
| Yggdrasil API | built in | **yggdrasil-api plugin** (install + enable) |
| authlib endpoint | `…/authlib-injector` | `…/api/yggdrasil` |
| OIDC/Keycloak | supported (future) | not native (OAuth plugins exist) |
| Server online-mode | repo used FALSE | **TRUE** (real session validation) |
## Setup (first run)
1. `cp .env.example .env`, fill the `BS_*` vars. Generate `BS_APP_KEY`:
```
docker run --rm azusamikan/blessing-skin-server-docker:latest \
php artisan key:generate --show
```
2. Bring the stack up with the override (command above).
3. Open `http://auth.${BASE_DOMAIN}` → BSS install wizard. DB host = `mariadb`,
port `3306`, name/user/pass = the `BS_*` values. Create the admin account.
4. Admin panel → **Plugins → Plugin Market** → install **yggdrasil-api** →
enable it. (Needs internet; for air-gap, pre-place the plugin jar/zip into
the `bs_plugins` volume.) After enabling, the API root is live at
`http://auth.${BASE_DOMAIN}/api/yggdrasil`.
5. Each player: register on the BSS site, add a character whose name == their
in-game player name, upload a skin.
6. Restart minecraft if it came up before the plugin was enabled.
## Client (Prism / authlib-injector)
- authlib-injector URL: `http://auth.${BASE_DOMAIN}/api/yggdrasil`
- Login = BSS **email + password** (BSS uses email as the account id).
- Must match the server's `-javaagent` arg exactly (same host + path).
## Gotchas
- **`/api/yggdrasil`, not `/authlib-injector`.** Wrong path → silent "Invalid
session". Server `JVM_OPTS` and every client must agree.
- **ONLINE_MODE=TRUE.** authlib-injector only authenticates when online-mode is
on; the server then validates the join against BSS. (The Drasl variant in this
repo set FALSE — do not copy that here.)
- **Secure profile (1.21+).** Override ships `ENFORCE_SECURE_PROFILE=FALSE` for
safety. yggdrasil-api *does* sign profile keys, so TRUE may work — flip and
test if signed chat matters; revert on "Invalid signature".
- **APP_KEY must persist.** Set `BS_APP_KEY` in `.env`. Losing it invalidates
all sessions and breaks encrypted data.
- **DB is the source of truth.** `bs_db` volume holds accounts; `bs_storage`
holds skins; `bs_plugins` holds the yggdrasil-api plugin. Back these up.
- **Air-gap:** the plugin market needs internet. Install + enable yggdrasil-api
while online (it persists in `bs_plugins`), or stage the plugin manually.
## Image note
`azusamikan/blessing-skin-server-docker:latest` is a community image and the
env-var names (`DB_*`, `APP_KEY`) may differ slightly by tag — if auto-config
doesn't take, the web wizard is the reliable path. Official source:
https://github.com/bs-community/blessing-skin-server ,
plugin: https://github.com/bs-community/yggdrasil-api , authlib-injector setup:
https://github.com/bs-community/blessing-skin-manual/blob/master/man/yggdrasil-api/authlib-injector.md

View File

@@ -2,32 +2,92 @@
## Summary
Apex `ulicraft.local` serves a clean static page guiding guests through joining:
Apex `ulicraft.local` serves a static page guiding guests through joining:
download launcher → add account → import modpack → connect. Built with **Astro +
TypeScript** in its own `./landing/` project; `npm run build` emits straight into
`./www/`, which Caddy already serves at the apex domain. No runtime JS framework —
output is plain HTML/CSS plus one tiny copy-to-clipboard script.
TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`,
which Caddy/nginx serve at the apex. No runtime framework — output is plain
HTML/CSS plus one tiny vanilla script (copy-to-clipboard + scroll reveal).
The visual is the **"Carved from stone"** pixel design ("Claude Design",
`landing/design/`): dark overworld palette, MC-GUI bevel buttons, pixel type,
creeper mark, server-list panel. That design was a React/Babel-via-CDN prototype
for a *fictional public SMP*; it was **ported to static Astro** with the real
modded-LAN content and offline-safe assets.
## Stack & wiring
- `landing/` — Astro project (own `package.json`, `tsconfig`).
- `landing/` — Astro project (own `package.json`, `tsconfig`). pnpm only.
- `astro.config.mjs``outDir: "../www"`. Build overwrites `www/`; `www/` is
gitignored (generated artifact).
- `BASE_DOMAIN` env read at build time in `src/data/site.ts`
(`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy:
`BASE_DOMAIN=ulicraft.local pnpm run build`.
- Logo: drop PNG at `landing/public/logo.png` → copied to `www/logo.png`,
referenced as `/logo.png` (favicon + hero). `image-rendering: pixelated` keeps
the stone 3D logo crisp.
- Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon).
`image-rendering: pixelated` keeps it crisp.
## Content (src/data/site.ts is the single source)
### Source layout
```
landing/
├── design/ # "Claude Design" prototype (reference, not built)
├── public/
│ ├── logo.png # hero wordmark (from design/assets/ulicraft-logo.png)
│ └── fonts/ # vendored woff2 + fonts.css (offline-safe)
└── src/
├── data/site.ts # single source: content + theme knobs
├── styles/main.css # ported design system (mood/hero/bevels)
├── components/ # Creeper, PixelIcon, CopyChip, ServerListPanel
└── pages/index.astro # composition + copy/reveal script + dust
```
- Hero: logo, name, server address `mc.${BASE_DOMAIN}:25565` (copy button).
- Step 1: launcher downloads — 3 OS buttons → `/launcher/latest/<stable>`.
- Step 2: add authlib-injector account, URL `http://auth.${BASE_DOMAIN}/authlib-injector`,
register link to `http://auth.${BASE_DOMAIN}`.
- Step 3: import packwiz `http://packwiz.${BASE_DOMAIN}/pack.toml`.
- Step 4: connect to server address.
## Fonts — vendored for offline LAN
The design pulled fonts from Google Fonts CDN; that breaks on offline LAN day.
`tooling/fetch-fonts.sh` downloads the woff2 + a URL-rewritten `fonts.css` into
`landing/public/fonts/`. `index.astro` links `/fonts/fonts.css`. Re-run the
script only if the font set changes (keep families in sync with `main.css`
`--font-*`): Pixelify Sans, Space Grotesk, Press Start 2P, Silkscreen, VT323.
## Theme config (replaces the design's in-browser TweaksPanel)
`site.ts` `theme` is baked at build — edit + rebuild to change:
- `mood`: `grass` (default) | `nether` | `end`
- `hero`: `centered` (default) | `split` | `spotlight`
- `headFont`: `pixelify` (default) | `8bit` | `silkscreen`
- `dust`: floating hero particles (CSS animation; respects reduced-motion)
`index.astro` sets `<html data-mood data-hero data-head>` + `--font-head` from these.
## Languages (i18n)
Three locales, static, build-time, offline-safe — **English** (default, `/`),
**Spanish** (`/es/`), **Euskera** (`/eu/`).
- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three
routes (`/`, `/es/`, `/eu/`). No runtime/JS routing.
- Translatable copy lives in `src/i18n/ui.ts` (`ui[locale]`), keyed identically
across locales. `site.ts` holds only language-neutral config (URLs, theme,
launcher files, stat/feature *structure*).
- Product / in-game literals never translated — `site.ts` `LITERALS`
(`authlib-injector`, `Multiplayer`, `Add Server`). OS names + version too.
- Sentences with links/kbd are split into parts (pre/post/link, pa/pb) so markup
stays in the template, not the strings.
- Nav has a pixel `EN · ES · EU` switcher (`.lang-switch`); active locale
highlighted. `<html lang>` + `<link rel="alternate" hreflang>` set per page.
- Adding a locale: extend the `Lang` union + `ui`/`LANG_*` maps in `ui.ts`, add a
`getStaticPaths` entry. No template change.
## Content (real modded-LAN flow, all in site.ts)
- Hero: wordmark, eyebrow `Modded LAN · NeoForge 1.21.1`, tagline, sub, server
address `mc.${BASE_DOMAIN}` (copy chip), "How to Join".
- Status: static server-list panel (no fake live count — **option B**) + 4 honest
stat tiles (Mods / NeoForge ver / Player slots / Backups).
- Features ×4: kitchen-sink pack · self-hosted Drasl identity+skins · one-click
packwiz · built-to-last (homelab + 6h backups).
- How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account
(`auth.${BASE_DOMAIN}/authlib-injector`) + register link + CA-cert note →
**3** packwiz import (`packwiz.${BASE_DOMAIN}/pack.toml`) → **4** connect.
- Footer: Mojang trademark disclaimer.
## Launcher download links (Option A — stable symlinks)
@@ -38,16 +98,18 @@ Page links to fixed names so they survive launcher version bumps:
`tooling/fetch-launcher.sh` must create these as symlinks under
`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names
in sync** between `site.ts` and the script. Caddy mount
`./mirror/launcher:/srv/www/launcher:ro` makes them resolve at `/launcher/latest/…`.
in sync** between `site.ts` and the script. Mounted so they resolve at
`/launcher/latest/…` (see nginx/Caddy ingress).
## Tasks
- [ ] User: place logo at `landing/public/logo.png`
- [ ] `cd landing && pnpm install`
- [ ] `BASE_DOMAIN=ulicraft.local pnpm run build` → verify `www/index.html` + `www/logo.png`
- [ ] Extend `fetch-launcher.sh`: after download, symlink stable names → versioned files
(resolve which asset = win setup / mac dmg / linux AppImage)
- [ ] Confirm Caddy serves apex from `./www` and `/launcher/latest/*` from mirror
- [ ] Optional: pin launcher tag instead of `latest` for party day
- [ ] Optional: add a "what's in the pack" section once mod list firms up
- [x] Vendor fonts (`tooling/fetch-fonts.sh`) → `public/fonts/`
- [x] Port design → static Astro (components + main.css + index.astro)
- [x] Real content in `site.ts`; theme knobs replace TweaksPanel
- [x] i18n: en/es/eu via `[...lang].astro` + `i18n/ui.ts`; nav language switcher
- [x] `BASE_DOMAIN=ulicraft.local pnpm run build` → verified `www/` output + screenshot
- [ ] Extend `fetch-launcher.sh`: symlink stable names → versioned files
- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from mirror
- [ ] Optional: try `mood: nether` / `hero: split` for party day
- [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted
```

86
plan/14-deploy.md Normal file
View File

@@ -0,0 +1,86 @@
# Deployment — redeploy to `cochi`
Production host for the Ulicraft stack.
| | |
|---|---|
| Host | `cochi` (SSH alias) |
| Path | `/home/ubuntu/mc/ulicraft-server-v1` |
| Branch | `main` |
| Run mode | `online` (compose files: `docker-compose.yml` + `docker-compose.static.yml`) |
| Auth | Drasl (base stack — no Blessing Skin override) |
| Restart | **hard** (`compose down``build-stack.sh --up`) — brief full downtime |
## Routine redeploy
```bash
ssh cochi
cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only
# hard restart: tear the stack down, then bring it back up (renders configs first)
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
tooling/build-stack.sh --up online
```
Or as a one-shot from your workstation:
```bash
ssh cochi 'set -e
cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
tooling/build-stack.sh --up online'
```
`build-stack.sh --up online` runs preflight (checks `.env`), `render-config.sh`
(which renders configs + `render-pack.sh` for the packwiz pack), then
`docker compose ... up -d` with the online file set.
## What survives a hard restart
Named volumes persist — world and auth data are safe:
- `mc_data` — world + installed NeoForge/mods
- `drasl_state` — Drasl accounts/skins
- `backups/` — mc-backup snapshots
`down` removes **containers**, not volumes. Players are disconnected during the
restart (a few minutes); they reconnect once `minecraft` is healthy again.
## Prerequisites (one-time, on `cochi`)
- SSH access as the `cochi` alias.
- `.env` present and complete: `BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`
(`render-config.sh` halts on any unset var, even in online mode).
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
- **First deploy only** — heavy online prep (fetch launcher, pre-bake the server
volume, etc.):
```bash
tooling/build-stack.sh --prep
```
Not needed for routine redeploys; `--up` alone re-syncs the pack and configs.
## Verify
```bash
docker compose -f docker-compose.yml -f docker-compose.static.yml ps
docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft # watch for "Done"
curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
```
## Rollback
```bash
git log --oneline -5 # find the last-good commit
git checkout <sha> # or: git reset --hard <sha>
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
tooling/build-stack.sh --up online
```
## Notes
- **Online mode needs internet** on `cochi` at restart (no air-gap mirror): the
server installs NeoForge + mods over the network. For an internet-less deploy,
switch to `airgap` (needs `--prep` first) — see `plan/13-dns-and-run-modes.md`.
- Switching to the **Blessing Skin** auth variant changes the file set to
`-f docker-compose.yml -f docker-compose.blessingskin.yml` (see
`plan/03b-blessing-skin.md`); update the `down`/`up` commands accordingly.

76
plan/15-letsencrypt.md Normal file
View File

@@ -0,0 +1,76 @@
# TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress
Production serves real, publicly-trusted certs via the **host's nginx**. Caddy is
no longer the production ingress — it moved to its own compose file for the
LAN/air-gap path.
## Two ingress paths
| Path | Ingress | Compose | TLS |
|------|---------|---------|-----|
| Production | host **nginx** | `docker-compose.yml` + `docker-compose.nginx.yml` | Let's Encrypt (this doc) |
| LAN / air-gap | **caddy** container | `docker-compose.yml` + `docker-compose.caddy.yml` (+ static/mirror) | Caddy internal CA |
`build-stack.sh` drives the caddy path (adds `docker-compose.caddy.yml`
automatically). The nginx path is a manual deploy (below). This supersedes the
ingress part of `plan/14-deploy.md` for production.
## Certs: one per name, via OVH DNS-01
`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each
subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`) using acme.sh + the OVH DNS-01
challenge — no inbound ports needed, so it works even on a LAN-only host.
### One-time setup
1. Install acme.sh:
```bash
curl https://get.acme.sh | sh -s email=you@example.com
```
2. OVH API credentials → `.env` (see `.env.example` `OVH_*`, `LE_EMAIL`,
`LE_SUBDOMAINS`). Create a token at <https://eu.api.ovh.com/createToken/> with
`GET/POST/PUT/DELETE` on `/domain/zone/*`, or leave `OVH_CK` blank and run the
script once — acme.sh prints an authorization URL; visit it, then paste the
consumer key into `OVH_CK` and re-run.
### Issue
```bash
tooling/issue-letsencrypt.sh # real certs
LE_STAGING=1 tooling/issue-letsencrypt.sh # dry run against the LE staging CA
```
Certs install to `certs/<name>/{cert.pem,key.pem}` (gitignored). acme.sh adds a
cron for renewal; `--reloadcmd` reloads nginx automatically on renew. Override
the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
## nginx
1. Stack up with the nginx override (publishes drasl on `127.0.0.1:25585`):
```bash
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
```
2. Render + install the vhost (`nginx/ulicraft.conf.tmpl`):
```bash
APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
envsubst '$APP_DIR $BASE_DOMAIN' \
< nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
nginx then: apex + `pack.*` as static files (`www`, `pack`, `custom`),
`auth.*` → drasl. Ensure `www-data` can read `$APP_DIR/{www,pack,custom,certs}`.
## DNS
Point the public/party DNS for `${BASE_DOMAIN}`, `auth.${BASE_DOMAIN}`,
`pack.${BASE_DOMAIN}` at the host. The Minecraft container reaches them via
`extra_hosts: host-gateway` (set in `docker-compose.nginx.yml`) and uses HTTPS —
the JVM trusts Let's Encrypt with no CA import (the big win over the internal CA).
## Why DNS-01 (not HTTP-01)
`HOST_LAN_IP` shows a LAN host; HTTP-01/TLS-ALPN-01 need public inbound on 80/443.
DNS-01 only needs the OVH API, so it issues regardless of public reachability —
and `ulicraft.net` is a real OVH-managed domain, which LE requires (it never
issues for `.lan`/`.local`).

132
tooling/add-custom-mod.sh Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# add-custom-mod.sh — add LOCAL jar(s) to the packwiz pack as "external files".
#
# Implements the "Other external files" flow from
# https://packwiz.infra.link/tutorials/creating/adding-mods/ : a <slug>.pw.toml
# that points at a stable download URL + sha256, then refresh. Here the metadata
# is written as a TEMPLATE (<slug>.pw.toml.tmpl) with a ${BASE_DOMAIN}
# placeholder and rendered by render-config.sh, so the domain is never frozen.
#
# Since the jars are local (no public URL), we HOST them on the caddy pack
# server: each jar is copied to the top-level ./custom/ dir (OUTSIDE ./pack, so
# packwiz refresh won't index it as a direct file). Caddy bind-mounts ./custom
# at /srv/pack/custom, so it is served at
# http://pack.${BASE_DOMAIN}/custom/<filename>
# which is exactly what minecraft fetches via PACKWIZ_URL on install.
#
# Usage:
# tooling/add-custom-mod.sh <mod.jar> [--name "Nice Name"] [--side S] [--force]
# tooling/add-custom-mod.sh <dir/> [--side S] [--force] # all *.jar in dir
# # (--name not allowed)
# S = both|client|server (default both)
#
# Single-jar name defaults to the filename without .jar; folder mode derives
# each name from its filename. packwiz refresh runs ONCE at the end.
# Cautious: halts on missing tool/var, bad side, whitespace in a filename, an
# empty folder, or an existing metadata/custom jar (unless --force).
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
# ---- helpers ---------------------------------------------------------------
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
slugify() { # lowercase, non-alnum -> hyphen, squeeze + trim hyphens
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//'
}
# ---- args ------------------------------------------------------------------
TARGET=""; NAME=""; SIDE="both"; FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--name) NAME="${2:?--name needs a value}"; shift 2 ;;
--side) SIDE="${2:?--side needs a value}"; shift 2 ;;
--force) FORCE=1; shift ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
-*) die "unknown flag: $1" ;;
*) [ -z "$TARGET" ] || die "only one jar or dir at a time (got extra: $1)"; TARGET="$1"; shift ;;
esac
done
[ -n "$TARGET" ] || die "no jar/dir given. usage: tooling/add-custom-mod.sh <mod.jar|dir/> [--side both|client|server]"
[ -e "$TARGET" ] || die "path not found: $TARGET"
case "$SIDE" in both|client|server) ;; *) die "invalid --side '$SIDE' (both|client|server)" ;; esac
# ---- preflight -------------------------------------------------------------
command -v packwiz >/dev/null 2>&1 || die "missing required tool: packwiz (https://packwiz.infra.link)"
command -v sha256sum >/dev/null 2>&1 || die "missing required tool: sha256sum"
command -v envsubst >/dev/null 2>&1 || die "missing required tool: envsubst (gettext)"
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
[ -f pack/pack.toml ] || die "pack/pack.toml not found — run from a packwiz pack (see plan/04-packwiz.md)"
# ---- collect jars ----------------------------------------------------------
declare -a JARS=()
if [ -d "$TARGET" ]; then
[ -z "$NAME" ] || die "--name not allowed with a folder (names are derived per-jar)"
shopt -s nullglob
for f in "$TARGET"/*.jar; do JARS+=("$f"); done
shopt -u nullglob
[ "${#JARS[@]}" -gt 0 ] || die "no .jar files in folder: $TARGET"
else
JARS=("$TARGET")
fi
mkdir -p custom pack/mods
# ---- per-jar processing (no refresh; refresh once at the end) --------------
add_one() { # $1 = jar path. Uses NAME (single-jar only), SIDE, FORCE, BASE_DOMAIN.
local jar="$1" filename name slug meta dest url hash
[ -f "$jar" ] || die "not a file: $jar"
filename="$(basename -- "$jar")"
case "$filename" in *.jar) ;; *) die "not a .jar: $filename" ;; esac
case "$filename" in *[[:space:]]*) die "filename has whitespace: '$filename' — rename it (URL-unsafe)" ;; esac
name="$NAME"; [ -n "$name" ] || name="${filename%.jar}"
slug="$(slugify "$name")"
[ -n "$slug" ] || die "could not derive a slug from name '$name'"
# Metadata is a TEMPLATE: the download URL keeps a literal ${BASE_DOMAIN}
# placeholder, rendered to the real .pw.toml by render-config.sh at deploy so
# the domain is never frozen into a committed file.
meta="pack/mods/${slug}.pw.toml.tmpl"
dest="custom/${filename}"
url_tmpl='http://pack.${BASE_DOMAIN}/custom/'"${filename}"
if [ "$FORCE" -ne 1 ]; then
[ -e "$meta" ] && die "metadata exists: $meta (use --force to overwrite)"
[ -e "$dest" ] && die "hosted jar exists: $dest (use --force to overwrite)"
fi
hash="$(sha256sum -- "$jar" | cut -d' ' -f1)"
# Skip the copy when the source already IS the hosted file (regen-in-place,
# e.g. `add-custom-mod.sh custom --force` to rewrite metadata).
[ "$dest" -ef "$jar" ] || cp -- "$jar" "$dest"
cat > "$meta" <<EOF
name = "${name}"
filename = "${filename}"
side = "${SIDE}"
[download]
url = "${url_tmpl}"
hash-format = "sha256"
hash = "${hash}"
EOF
echo " + ${filename} -> http://pack.${BASE_DOMAIN}/custom/${filename} (${SIDE}, sha256 ${hash:0:12}…)"
}
log "adding ${#JARS[@]} jar(s), side=${SIDE}"
for j in "${JARS[@]}"; do add_one "$j"; done
# ---- render + refresh once -------------------------------------------------
# render-pack.sh renders all pack templates (BASE_DOMAIN only) + rebuilds the
# packwiz index. Single source of pack-render logic, shared with render-config.
log "rendering metadata + refreshing index"
tooling/render-pack.sh
log "done — ${#JARS[@]} mod(s) added to the pack"
echo " commit: custom/*.jar + pack/mods/*.pw.toml.tmpl (rendered .pw.toml/index are gitignored)"

View File

@@ -91,7 +91,8 @@ prep() {
log "pre-bake server volume (TYPE=NEOFORGE install, one-shot, online)"
# Bring minecraft up once so itzg installs NeoForge + mods into the volume,
# then stop it. The party then runs offline against the baked volume.
"${COMPOSE[@]}" up -d minecraft
# caddy.yml so the pack (pack.${BASE_DOMAIN}) resolves + is served during bake.
"${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.caddy.yml up -d minecraft
warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe"
}
@@ -102,7 +103,10 @@ prep() {
# core base only (no landing, no mirror) — debugging
up() {
local mode="${1:-airgap}"
local -a files=(-f docker-compose.yml)
# build-stack is the caddy (LAN/air-gap) deployer; caddy lives in its own file
# now (the base stack can also run behind the host's nginx — see
# docker-compose.nginx.yml). Always include caddy here.
local -a files=(-f docker-compose.yml -f docker-compose.caddy.yml)
case "$mode" in
core) ;;
online) files+=(-f docker-compose.static.yml) ;;

44
tooling/fetch-fonts.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Vendor the landing-page web fonts locally so the apex site works on an
# offline LAN (no Google Fonts CDN at party time).
#
# Pulls woff2 + a rewritten @font-face stylesheet from Google Fonts into
# landing/public/fonts/. Re-run only when the font set changes.
#
# Halts on any error (cautious-by-default).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
out="$here/landing/public/fonts"
mkdir -p "$out"
# Modern Chrome UA → Google returns woff2 (not ttf).
UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
# Families + weights used by the landing (keep in sync with main.css --font-*).
CSS_URL="https://fonts.googleapis.com/css2"
CSS_URL+="?family=Pixelify+Sans:wght@600;700"
CSS_URL+="&family=Space+Grotesk:wght@400;500;600;700"
CSS_URL+="&family=Press+Start+2P"
CSS_URL+="&family=Silkscreen:wght@400;700"
CSS_URL+="&family=VT323"
CSS_URL+="&display=swap"
echo "→ fetching @font-face CSS"
raw="$(curl -fsSL -A "$UA" "$CSS_URL")"
echo "→ downloading woff2 files"
# Pull every gstatic woff2 URL, download to out/, dedupe by basename.
echo "$raw" | grep -oE 'https://fonts\.gstatic\.com/[^)]+\.woff2' | sort -u | while read -r url; do
fn="$(basename "$url")"
if [ ! -f "$out/$fn" ]; then
curl -fsSL -o "$out/$fn" "$url"
echo " $fn"
fi
done
echo "→ writing fonts.css (URLs rewritten to /fonts/…)"
# Rewrite absolute gstatic URLs to local /fonts/<basename>.
echo "$raw" | sed -E 's#https://fonts\.gstatic\.com/[^)]+/([^)/]+\.woff2)#/fonts/\1#g' > "$out/fonts.css"
echo "✓ fonts vendored into $out"

Some files were not shown because too many files have changed in this diff Show More