Compare commits

...
3 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 caa46175bc install.sh: finish with next steps, like bamboo's
CI / test (push) Successful in 2m17s
The installer no longer opens an editor or starts the service on a fresh
install. It writes the files, then prints: sudoedit the env file, systemctl
start, journalctl -f. --start starts (or restarts) the service straight away,
and an already running service is restarted on upgrade.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:38:09 -06:00
VeillaxandClaude Sonnet 5.5 88487d9744 Restyle the dashboard to the Lightwell design language
CI / test (push) Successful in 2m15s
Release / release (push) Successful in 2m51s
Same tokens, sidebar shell, components and voice as Hammerhead: teal accent,
warm neutrals, status badges with text, dialogs with focus trapping that name
what they affect, empty states, skeleton loading, outline icons, 44px touch
targets and a slide-in drawer on phones. The strict CSP stays: no inline
styles or scripts, so icons and sizes use classes.

Adds docs/design.md and scripts/lint-design.mjs (run in CI), capitalizes
server error messages in one place, and rewrites interface copy to the voice
rules (sentence case, buttons that name the result, no dashes).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:30:30 -06:00
VeillaxandClaude Sonnet 5.5 81e3f73521 Add install.sh: install or upgrade Megalodon as a systemd service
CI / test (push) Successful in 2m15s
Downloads the release for this machine and verifies its checksum (or builds
from source), creates the service user and folders, writes and opens the
environment file, installs the hardened unit and prints the first-run admin
password. Re-running upgrades; --uninstall [--purge] removes it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:21:53 -06:00
9 changed files with 1100 additions and 502 deletions

No files matched your search

+3
View File
@@ -24,6 +24,9 @@ jobs:
exit 1
fi
- name: Design rules
run: node scripts/lint-design.mjs
- name: Vet
run: go vet ./...
+27 -1
View File
@@ -59,7 +59,29 @@ password you set there is the RCON password.
admin password. Keep it on your LAN or a VPN; only Megalodon needs to reach it. If Megalodon and
the server are on the same machine or Docker network, nothing needs to be opened at all.
## Running it
## Installing on Linux (systemd)
```sh
curl -fsSL https://git.veillax.com/Veillax/megalodon/raw/branch/main/deploy/install.sh | sudo sh
```
This downloads the latest release (checking its SHA-256), installs `/usr/local/bin/megalodon`,
creates a `megalodon` user, `/etc/megalodon/` (settings) and `/var/lib/megalodon/` (data), and writes
the systemd service and `/etc/megalodon/megalodon.env`. It then tells you what to do next:
```sh
sudoedit /etc/megalodon/megalodon.env
sudo systemctl start megalodon
journalctl -u megalodon -f # a first install prints the admin password here, once
```
Run it again to upgrade; your settings are kept and a running service is restarted.
Options: `--start` (start the service straight away), `--version v0.1.0`, `--listen 0.0.0.0:8480`,
`--source` (build from the repository with Go instead), and `--uninstall` (add `--purge` to delete
settings and data). Download the script and read it first if you prefer: it is [`deploy/install.sh`](deploy/install.sh).
## Running it by hand
```sh
# download a release, or: go build ./cmd/megalodon
@@ -153,6 +175,10 @@ Prometheus: `megalodon_server_up`, `megalodon_players`, `megalodon_players_max`,
report that ID.
- ARK:SE only (it is untested on ASA, which uses the same protocol but may differ in detail).
## Design
The dashboard follows the Lightwell design language, the same one Hammerhead uses: teal accent, warm neutrals, a sidebar, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules a machine can check.
## Development
```sh
+211
View File
@@ -0,0 +1,211 @@
#!/bin/sh
# Installs (or upgrades) Megalodon as a systemd service.
#
# curl -fsSL https://git.veillax.com/Veillax/megalodon/raw/branch/main/deploy/install.sh | sudo sh
# sudo sh install.sh [--start] [--version v0.1.0] [--listen 127.0.0.1:8480] [--source]
# sudo sh install.sh --uninstall [--purge]
#
# What it does:
# 1. downloads the release archive for this machine and checks its SHA-256 (or, with --source,
# clones the repository and builds it with Go),
# 2. installs the binary to /usr/local/bin/megalodon,
# 3. creates a "megalodon" user, /etc/megalodon (settings) and /var/lib/megalodon (data),
# 4. writes /etc/megalodon/megalodon.env and the systemd service,
# 5. tells you what to do next: edit the env file, start the service, watch the log.
#
# Running it again upgrades the binary and keeps your settings; a service that is already running
# is restarted. Add --start to start it straight away instead of editing the env file first.
set -eu
REPO_URL=${MEGALODON_REPO_URL:-https://git.veillax.com/Veillax/megalodon}
API_URL=${MEGALODON_API_URL:-https://git.veillax.com/api/v1/repos/Veillax/megalodon}
BIN=/usr/local/bin/megalodon
CONF_DIR=/etc/megalodon
DATA_DIR=/var/lib/megalodon
ENV_FILE=$CONF_DIR/megalodon.env
CONF_FILE=$CONF_DIR/megalodon.json
UNIT=/etc/systemd/system/megalodon.service
USER_NAME=megalodon
VERSION=""
LISTEN="127.0.0.1:8480"
SOURCE=0
START=0
UNINSTALL=0
PURGE=0
say() { printf '%s\n' "$*"; }
die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case $1 in
--version) VERSION=${2:?--version needs a value}; shift ;;
--listen) LISTEN=${2:?--listen needs a value}; shift ;;
--source) SOURCE=1 ;;
--start) START=1 ;;
--uninstall) UNINSTALL=1 ;;
--purge) PURGE=1 ;;
-h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown option $1" ;;
esac
shift
done
[ "$(id -u)" -eq 0 ] || die "run as root (sudo sh install.sh)"
[ "$(uname -s)" = Linux ] || die "this installer is for Linux; on Windows, download the .zip from the releases page"
command -v systemctl >/dev/null 2>&1 || HAVE_SYSTEMD=0
HAVE_SYSTEMD=${HAVE_SYSTEMD:-1}
if [ "$UNINSTALL" -eq 1 ]; then
if [ "$HAVE_SYSTEMD" -eq 1 ]; then
systemctl disable --now megalodon 2>/dev/null || true
rm -f "$UNIT"
systemctl daemon-reload
fi
rm -f "$BIN"
if [ "$PURGE" -eq 1 ]; then
rm -rf "$CONF_DIR" "$DATA_DIR"
userdel "$USER_NAME" 2>/dev/null || true
say "Megalodon removed, including its settings and data."
else
say "Megalodon removed. Settings ($CONF_DIR) and data ($DATA_DIR) were kept; add --purge to delete them."
fi
exit 0
fi
fetch() { # fetch URL DEST
if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1"
elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
else die "need curl or wget"; fi
}
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# ---- 1. get the binary ----
if [ "$SOURCE" -eq 1 ]; then
command -v git >/dev/null 2>&1 || die "--source needs git"
command -v go >/dev/null 2>&1 || die "--source needs Go (https://go.dev/dl/)"
say "Cloning $REPO_URL ..."
git clone --quiet ${VERSION:+--branch "$VERSION"} --depth 1 "$REPO_URL" "$TMP/src"
(cd "$TMP/src" && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION:-source}" -o "$TMP/megalodon" ./cmd/megalodon)
else
case $(uname -m) in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) die "no release build for $(uname -m); try --source" ;;
esac
if [ -z "$VERSION" ]; then
fetch "$API_URL/releases/latest" "$TMP/latest.json"
VERSION=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP/latest.json" | head -n 1)
[ -n "$VERSION" ] || die "could not find the latest release; use --version or --source"
fi
ARCHIVE="megalodon_${VERSION}_linux_${ARCH}.tar.gz"
say "Downloading Megalodon $VERSION ($ARCH) ..."
fetch "$REPO_URL/releases/download/$VERSION/$ARCHIVE" "$TMP/$ARCHIVE"
fetch "$REPO_URL/releases/download/$VERSION/SHA256SUMS" "$TMP/SHA256SUMS"
want=$(grep " $ARCHIVE\$" "$TMP/SHA256SUMS" | cut -d' ' -f1)
[ -n "$want" ] || die "$ARCHIVE is not listed in SHA256SUMS"
got=$(sha256sum "$TMP/$ARCHIVE" | cut -d' ' -f1)
[ "$want" = "$got" ] || die "checksum mismatch for $ARCHIVE; refusing to install"
tar -xzf "$TMP/$ARCHIVE" -C "$TMP"
cp "$TMP/megalodon_${VERSION}_linux_${ARCH}/megalodon" "$TMP/megalodon"
fi
# ---- 2. install it ----
UPGRADE=0
[ -x "$BIN" ] && UPGRADE=1
if [ "$UPGRADE" -eq 1 ] && [ "$HAVE_SYSTEMD" -eq 1 ]; then systemctl stop megalodon 2>/dev/null || true; fi
install -m 0755 "$TMP/megalodon" "$BIN"
say "Installed $("$BIN" version) to $BIN"
# ---- 3. user and folders ----
id "$USER_NAME" >/dev/null 2>&1 || useradd --system --home-dir "$DATA_DIR" --shell /usr/sbin/nologin "$USER_NAME" 2>/dev/null \
|| adduser --system --home "$DATA_DIR" --shell /usr/sbin/nologin "$USER_NAME" 2>/dev/null \
|| die "could not create the $USER_NAME user"
# The dashboard rewrites its settings file, so the service must own the folder that holds it.
mkdir -p "$CONF_DIR" "$DATA_DIR"
chown "$USER_NAME" "$CONF_DIR" "$DATA_DIR"
chmod 0750 "$CONF_DIR" "$DATA_DIR"
# ---- 4. settings and environment file ----
if [ ! -f "$CONF_FILE" ]; then
printf '{\n "listen": "%s",\n "data_dir": "%s"\n}\n' "$LISTEN" "$DATA_DIR" > "$CONF_FILE"
chown "$USER_NAME" "$CONF_FILE"
chmod 0600 "$CONF_FILE"
fi
if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
# Environment for the megalodon service. Restart it after editing: systemctl restart megalodon
# Where the settings file is. The dashboard edits this file, so leave it where the service can write.
MEGALODON_CONFIG=$CONF_FILE
# The time zone used by scheduled jobs ("04:30" means 04:30 in this zone). Examples: Europe/London,
# America/New_York, UTC. Leave it commented to use the server's own zone.
#TZ=Europe/London
EOF
chmod 0600 "$ENV_FILE" # systemd reads it as root, so the service user does not need access
say "Created $ENV_FILE. Edit it before starting."
fi
# ---- 5. service ----
if [ "$HAVE_SYSTEMD" -eq 0 ]; then
say "systemd was not found, so no service was installed. Run it yourself with:"
say " sudo -u $USER_NAME env \$(grep -v '^#' $ENV_FILE | xargs) $BIN serve -config $CONF_FILE"
exit 0
fi
cat > "$UNIT" <<EOF
[Unit]
Description=Megalodon, ARK server management over RCON
After=network-online.target
Wants=network-online.target
[Service]
User=$USER_NAME
EnvironmentFile=$ENV_FILE
WorkingDirectory=$DATA_DIR
ExecStart=$BIN serve
Restart=on-failure
RestartSec=5
# Megalodon only needs the network and its own folders.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=$CONF_DIR $DATA_DIR
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable megalodon >/dev/null 2>&1
if [ "$START" -eq 1 ] || systemctl is-active --quiet megalodon; then
systemctl restart megalodon
sleep 3
if systemctl is-active --quiet megalodon; then
say "Megalodon is running."
else
say "Megalodon did not start. Look at: journalctl -u megalodon -n 50"
exit 1
fi
fi
say "Installed $("$BIN" version)."
say "Next:"
say " 1. sudoedit $ENV_FILE (the time zone for schedules; the settings file is $CONF_FILE)"
say " 2. systemctl start megalodon (or run this again with --start)"
say " 3. journalctl -u megalodon -f to watch it; a first install prints the admin password here, once"
say ""
say "The dashboard is at http://$LISTEN"
case $LISTEN in
127.*|localhost*|[::1]*) say "It only listens on this machine. To reach it elsewhere, put an HTTPS reverse proxy (or Hammerhead) in front of it." ;;
esac
+63
View File
@@ -0,0 +1,63 @@
# Megalodon design language
The dashboard follows the Lightwell design language, the same one Hammerhead uses. The reference is `docs/design.md` in the Lightwell repository, and Hammerhead's own `docs/design.md` records how it is applied there. When this page and Lightwell disagree, Lightwell wins.
## What is shared with Lightwell and Hammerhead
* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values, copied from Hammerhead. The accent is Lightwell's teal, `#1f5c5c`, with white text on it. Everything derived from the accent uses `color-mix`.
* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA.
* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result ("Ban player", not "OK"), and errors that say what happened and what to do next. Errors that come from the server are capitalized in one place (`fail` in `internal/web/server.go`).
* **Status carries text.** A status is a badge with a label ("Online", "Offline", "Restarting soon"), never a colored dot on its own.
## Where it lives
| File | What it holds |
| --- | --- |
| `internal/web/static/style.css` | All styles. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. |
| `internal/web/static/app.js` | The dashboard: the toolkit at the top, then the pages. |
| `internal/web/static/index.html` | An empty shell that loads the two files above. |
Unlike Hammerhead's single page, Megalodon keeps its script and styles in separate files because its Content-Security-Policy forbids inline script and inline `style` attributes. Nothing in the UI sets a style attribute; widths and sizes are classes.
## Components
Built from the helpers at the top of `app.js`. Feature code uses these and does not style one-off.
| Helper | Use |
| --- | --- |
| `card`, `pageHeader` | Surfaces and the page title with its one primary action |
| `field`, `chk` | Label above the control, hint below |
| `badge(text, tone)` | Status pill. Tones: success, warning, danger, accent, or none for neutral |
| `emptyState(icon, title, text, action)` | Dashed outline, icon, a title that says "No X yet", one sentence |
| `modal`, `formDialog` | Dialogs. Focus moves in, Tab stays inside, Escape closes, focus returns to the opener |
| `confirmDialog` | Every destructive action. The title names the object, the body says what is lost and whether it can be undone |
| `toast` | Confirmations at the bottom right. Errors stay longer and are announced as alerts |
| `skeleton` | Placeholder rows while a page loads, not a spinner |
## Icons
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
Approved names: `fin`, `servers`, `players`, `bans`, `schedules`, `settings`, `activity`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
## Patterns
* **One primary action per view.** It sits in the page header: Add server on the overview, Broadcast on a server, Ban a player on the bans page, New job on schedules. Other buttons use the default (ghost) style.
* **List pages** show the page header, then the table in a card with no second title.
* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "At least 8 characters" are hints below them, not part of the label.
* **Dates** have the month written out ("Nov 2, 2026"), with `fmtTime`. Event rows show the time of day, with the full date in a tooltip.
* **Empty states** say why the list is empty: nothing yet, or nothing matching the filters.
## Layout
* Content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar, and grids collapse. Phones keep a 16px side gutter.
* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom.
* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar.
## Checking a change
```
node scripts/lint-design.mjs
```
It fails on em and en dashes, emoji, and raw colors outside the token block, in the dashboard files, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard.
+4
View File
@@ -285,7 +285,11 @@ func writeJSON(w http.ResponseWriter, v any) {
json.NewEncoder(w).Encode(v)
}
// fail answers with an error. Messages are capitalized here, once, so they read as sentences.
func fail(w http.ResponseWriter, code int, msg string) {
if r := []rune(msg); len(r) > 0 {
msg = strings.ToUpper(string(r[:1])) + string(r[1:])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
+535 -418
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -3,13 +3,13 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Megalodon</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M2 22c6-2 9-9 14-14 1 5 4 8 14 14-6 0-9 2-12 6-2-3-6-5-16-6z' fill='%232a7fb5'/%3E%3C/svg%3E">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231f5c5c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20c4-1 7-5 8-14 3 5 8 7 10 14z'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
<div id="toasts" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+208 -81
View File
@@ -1,93 +1,220 @@
:root {
--bg: #f4f6f8; --panel: #ffffff; --text: #17212b; --muted: #5d6b79; --line: #dde3e9;
--accent: #1f6fa8; --accent-text: #ffffff; --ok: #1c8a4b; --warn: #b36b00; --bad: #c4332b;
--ok-bg: #e2f4ea; --warn-bg: #fdf0d9; --bad-bg: #fbe5e3; --chip: #eaeff3;
}
@media (prefers-color-scheme: dark) {
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--bg: #0f151b; --panel: #172029; --text: #e4ebf1; --muted: #8fa0af; --line: #2a3744;
--accent: #4aa3de; --accent-text: #0b1218; --ok: #4cc07d; --warn: #e0a243; --bad: #ef6b62;
--ok-bg: #153325; --warn-bg: #382b12; --bad-bg: #3b1d1b; --chip: #212d38;
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
a { color: var(--accent); }
h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; } h3 { font-size: 1rem; }
.muted { color: var(--muted); } .small { font-size: .85rem; } .mono { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; }
.err { color: var(--bad); min-height: 1.2em; margin: .4rem 0 0; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
header.bar { display: flex; align-items: center; gap: 1rem; padding: .6rem 16px; background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; position: sticky; top: 0; z-index: 5; }
header.bar .brand { font-weight: 700; font-size: 1.1rem; margin-right: .5rem; }
header.bar nav { display: flex; gap: .25rem; flex-wrap: wrap; flex: 1; }
header.bar nav a { padding: .35rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; }
header.bar nav a.on { background: var(--chip); font-weight: 600; }
main { max-width: 1120px; margin: 0 auto; padding: 16px; }
*, *::before, *::after { box-sizing:border-box; }
html, body { height:100%; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
p { margin:0 0 var(--s3); }
a { color:var(--accent-text); text-underline-offset:2px; }
a:hover { text-decoration-thickness:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
[hidden] { display:none !important; }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
.inline-icon { display:inline-flex; }
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.card, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.card h2 { display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
.card canvas { width: 100%; height: 44px; display: block; margin: .6rem 0; }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.row.end { justify-content: flex-end; } .row.between { justify-content: space-between; }
.spacer { flex: 1; }
.stack > * + * { margin-top: 14px; }
/* shell */
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.sidebar button.sidebar-close { display:none; }
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
.brand .icon { color:var(--accent-text); }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
.nav { display:flex; flex-direction:column; gap:2px; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.navitem .label { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.navitem .state { font-size:var(--text-xs); font-weight:600; }
.navitem .state.up { color:var(--success); } .navitem .state.down { color:var(--danger); }
.grow { flex:1 1 auto; min-width:0; }
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { min-width:0; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
main:focus { outline:none; }
.topbar, .backdrop { display:none; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h h1 { display:flex; align-items:center; gap:var(--s3); flex-wrap:wrap; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
.page-h .btn { white-space:nowrap; }
.back { display:inline-block; margin-bottom:var(--s3); font-size:var(--text-sm); }
.pill { display: inline-block; padding: .1rem .55rem; border-radius: 99px; font-size: .78rem; font-weight: 600; background: var(--chip); color: var(--muted); }
.pill.ok { background: var(--ok-bg); color: var(--ok); } .pill.bad { background: var(--bad-bg); color: var(--bad); } .pill.warn { background: var(--warn-bg); color: var(--warn); }
.banner { padding: .55rem .8rem; border-radius: 8px; background: var(--warn-bg); color: var(--warn); margin: .6rem 0; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.banner.bad { background: var(--bad-bg); color: var(--bad); }
/* cards, layout */
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.card-h { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.card-h h2 { font-size:var(--text-lg); margin:0; }
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:var(--s4); margin-bottom:var(--s4); }
.cards > .card { margin:0; display:flex; flex-direction:column; gap:var(--s2); }
.cards h3 { font-size:var(--text-lg); }
.cards h3 a { color:var(--text); text-decoration:none; } .cards h3 a:hover { text-decoration:underline; }
.meta { color:var(--text-muted); font-size:var(--text-sm); }
.stack { display:flex; flex-direction:column; gap:var(--s4); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
td.actions-cell { text-align:right; white-space:nowrap; }
td.actions-cell button + button { margin-left:var(--s2); }
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.toolbar input[type=search], .toolbar input[type=text], .toolbar select { max-width:320px; }
.toolbar .push { margin-left:auto; }
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
.spark { width:100%; height:48px; display:block; }
.sendrow { display:flex; gap:var(--s2); margin-top:var(--s3); flex-wrap:wrap; }
.sendrow input { flex:1 1 220px; width:auto; }
button, .btn { font: inherit; padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; }
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; }
button.danger { color: var(--bad); } button.danger:hover { border-color: var(--bad); }
button.small { padding: .15rem .5rem; font-size: .82rem; }
button:disabled { opacity: .5; cursor: default; }
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: .4rem .55rem; max-width: 100%; }
input:focus, select:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
input[type=checkbox] { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); }
textarea { width: 100%; min-height: 5rem; font-family: ui-monospace, Consolas, monospace; font-size: .88rem; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; font-family:var(--font-mono); font-size:var(--text-sm); }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
input[aria-invalid="true"] { border-color:var(--danger); }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: 1rem 0; flex-wrap: wrap; }
.tabs a { padding: .45rem .9rem; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; }
.tabs a.on { color: var(--text); border-color: var(--accent); font-weight: 600; }
/* buttons: btn is the primary action, ghost the default, danger and quiet the others */
button.btn, button.ghost { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.quiet { background:transparent; border-color:transparent; }
button.danger { color:var(--danger); border-color:var(--danger); }
button.danger:hover { background:var(--danger-bg); }
button.btn-icon { width:var(--control-h); padding:0; }
td button.ghost, .actions button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--line); vertical-align: middle; }
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
td.actions { white-space: nowrap; text-align: right; }
.tablewrap { overflow-x: auto; }
.empty { padding: 1.2rem; text-align: center; color: var(--muted); }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
td.wrap { overflow-wrap:anywhere; min-width:150px; max-width:320px; }
.log { max-height: 55vh; overflow-y: auto; font-size: .9rem; }
.ev { display: flex; gap: .6rem; padding: .25rem 0; border-bottom: 1px solid var(--line); align-items: baseline; }
.ev time { color: var(--muted); font-size: .8rem; white-space: nowrap; min-width: 4.8rem; }
.ev .tag { font-size: .7rem; text-transform: uppercase; padding: 0 .4rem; border-radius: 4px; background: var(--chip); color: var(--muted); white-space: nowrap; }
.ev .tag.join, .ev .tag.online, .ev .tag.start { background: var(--ok-bg); color: var(--ok); }
.ev .tag.leave { background: var(--chip); }
.ev .tag.offline, .ev .tag.ban, .ev .tag.kick, .ev .tag.error { background: var(--bad-bg); color: var(--bad); }
.ev .tag.restart, .ev .tag.filter, .ev .tag.watch, .ev .tag.schedule { background: var(--warn-bg); color: var(--warn); }
.ev .txt { overflow-wrap: anywhere; }
.chat .who { font-weight: 600; } .chat .srv { color: var(--accent); }
pre.out { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: .7rem; min-height: 12rem; max-height: 50vh; overflow: auto; margin: 0 0 .6rem; white-space: pre-wrap; overflow-wrap: anywhere; font-size: .85rem; }
pre.out .cmd { color: var(--accent); } pre.out .bad { color: var(--bad); }
/* badges: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
.logbox { margin:0; background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-sm)/1.6 var(--font-mono); height:320px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
.logbox .cmd { color:var(--accent-text); } .logbox .bad { color:var(--danger); }
dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--text); padding: 18px 20px; width: min(520px, calc(100vw - 32px)); }
dialog::backdrop { background: rgba(0,0,0,.45); }
dialog label { display: block; margin: .6rem 0 .15rem; font-size: .85rem; color: var(--muted); }
dialog label.check { display: flex; gap: .5rem; align-items: center; color: var(--text); font-size: .95rem; }
dialog input[type=text], dialog input[type=number], dialog input[type=password], dialog select { width: 100%; }
dialog .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; }
dialog .multi label { display: flex; gap: .35rem; align-items: center; margin: 0; color: var(--text); font-size: .9rem; }
dialog .buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
/* the event and chat feed */
.feed { max-height:60vh; overflow-y:auto; }
.ev { display:grid; grid-template-columns:5.5rem 6rem minmax(0,1fr); gap:var(--s3); align-items:baseline; padding:var(--s2) 0; border-bottom:var(--border-w) solid var(--border); font-size:var(--text-sm); }
.ev:last-child { border-bottom:0; }
.ev time { color:var(--text-muted); font-variant-numeric:tabular-nums; white-space:nowrap; }
.ev .txt { overflow-wrap:anywhere; }
.ev .txt strong { font-weight:600; }
.ev.chat { grid-template-columns:5.5rem minmax(0,1fr); }
.ev .srv { color:var(--text-muted); }
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: .5rem; z-index: 50; max-width: min(420px, calc(100vw - 32px)); }
.toast { background: var(--text); color: var(--bg); padding: .6rem .9rem; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.25); overflow-wrap: anywhere; }
.toast.bad { background: var(--bad); color: #fff; }
/* empty state, notice, loading */
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
.empty p { margin:0 auto var(--s3); max-width:56ch; }
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
.card > .empty { margin:0; }
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); display:flex; gap:var(--s3); align-items:center; flex-wrap:wrap; }
.notice-warning { background:var(--warning-bg); border-color:var(--warning); color:var(--warning); }
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); height:16px; margin-bottom:14px; }
.w60 { width:60%; } .w75 { width:75%; } .w90 { width:90%; } .w100 { width:100%; }
@keyframes shimmer { to { background-position:-200% 0; } }
.login { max-width: 340px; margin: 14vh auto 0; }
.login form > * { display: block; width: 100%; margin-top: .6rem; }
@media (max-width: 640px) { main { padding: 10px; } td.actions { white-space: normal; } }
.grow { flex: 1; min-width: 0; } .mt { margin-top: .7rem; }
/* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; grid-template-columns:minmax(0,1fr); place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(640px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 a { background:var(--surface); color:var(--text-muted); padding:0 var(--s4); min-height:var(--control-h); display:inline-flex; align-items:center; text-decoration:none; font-weight:500; }
.tabs2 a + a { border-left:var(--border-w) solid var(--border-strong); }
.tabs2 a:hover { background:var(--surface-2); }
.tabs2 a.on { background:var(--accent-soft); color:var(--accent-text); }
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); overflow-wrap:anywhere; }
.toast-error { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
@media (pointer: coarse) {
button.btn, button.ghost, .navitem, input, select, .tabs2 a { min-height:var(--touch); }
td button.ghost, .actions button.ghost { min-height:36px; }
input, select, textarea { font-size:16px; }
}
@media (max-width:860px) {
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.sidebar { display:none; }
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
.sidebar button.sidebar-close { display:inline-flex; }
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
main { padding:var(--s5) var(--s4) var(--s7); }
.ev { grid-template-columns:4.5rem minmax(0,1fr); } .ev .badge { display:none; }
}
@media (max-width:640px) {
.modal { padding:var(--s4); }
.shade { padding:var(--s4) var(--s3); }
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
.page-h > .btn { width:100%; }
}
.icon-16 { width:16px; height:16px; } .icon-22 { width:22px; height:22px; }
+47
View File
@@ -0,0 +1,47 @@
// Style gate for the dashboard, following the Lightwell design language (see docs/design.md).
// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the
// token block of the dashboard stylesheet. Run with: node scripts/lint-design.mjs
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, relative, sep } from "node:path";
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
const skipDirs = new Set(["node_modules", ".git", "dist", "bin", "data"]);
const exts = new Set([".html", ".css", ".js", ".md", ".go", ".sh", ".mjs", ".json", ".yml"]);
const dash = /[–—−]/;
const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u;
const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
function* walk(p) {
const st = statSync(p, { throwIfNoEntry: false });
if (!st) return;
if (st.isFile()) return yield p;
for (const name of readdirSync(p)) if (!skipDirs.has(name)) yield* walk(join(p, name));
}
const problems = [];
for (const file of walk(root)) {
if (!exts.has(extname(file))) continue;
const rel = relative(root, file).split(sep).join("/");
if (rel === "scripts/lint-design.mjs" || rel === "megalodon.json") continue;
const isPage = rel.startsWith("internal/web/static/");
let inTokens = false;
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`);
if (line.includes("tokens:begin")) inTokens = true;
if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to.");
if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon.");
if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) {
at("raw color outside the token block. Use a token.");
}
if (isPage && /(?:toast|textContent\s*=|class:\s*'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy.");
if (line.includes("tokens:end")) inTokens = false;
});
}
if (problems.length) {
console.error(problems.join("\n"));
console.error(`\n${problems.length} style problem(s).`);
process.exit(1);
}
console.log("Style checks passed.");