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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-31 00:30:30 -06:00
1 parent 81e3f73521
commit 88487d9744
8 files changed
+852 -487

No files matched your search

+3
View File
@@ -24,6 +24,9 @@ jobs:
exit 1 exit 1
fi fi
- name: Design rules
run: node scripts/lint-design.mjs
- name: Vet - name: Vet
run: go vet ./... run: go vet ./...
+4
View File
@@ -168,6 +168,10 @@ Prometheus: `megalodon_server_up`, `megalodon_players`, `megalodon_players_max`,
report that ID. report that ID.
- ARK:SE only (it is untested on ASA, which uses the same protocol but may differ in detail). - 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 ## Development
```sh ```sh
+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) 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) { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(code) w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg}) json.NewEncoder(w).Encode(map[string]string{"error": msg})
+521 -404
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -3,13 +3,13 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Megalodon</title> <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"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<div id="toasts" aria-live="polite"></div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+208 -81
View File
@@ -1,93 +1,220 @@
:root { /* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
--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) {
:root { :root {
--bg: #0f151b; --panel: #172029; --text: #e4ebf1; --muted: #8fa0af; --line: #2a3744; --accent:#1f5c5c; --accent-contrast:#ffffff;
--accent: #4aa3de; --accent-text: #0b1218; --ok: #4cc07d; --warn: #e0a243; --bad: #ef6b62; --bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--ok-bg: #153325; --warn-bg: #382b12; --bad-bg: #3b1d1b; --chip: #212d38; --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;
} }
} @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
* { box-sizing: border-box; } --bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } --text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
a { color: var(--accent); } --accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; } --accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; } h3 { font-size: 1rem; } --accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
.muted { color: var(--muted); } .small { font-size: .85rem; } .mono { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; } --success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
.err { color: var(--bad); min-height: 1.2em; margin: .4rem 0 0; } --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; } *, *::before, *::after { box-sizing:border-box; }
header.bar .brand { font-weight: 700; font-size: 1.1rem; margin-right: .5rem; } html, body { height:100%; }
header.bar nav { display: flex; gap: .25rem; flex-wrap: wrap; flex: 1; } html { -webkit-text-size-adjust:100%; }
header.bar nav a { padding: .35rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; } body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
header.bar nav a.on { background: var(--chip); font-weight: 600; } h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
main { max-width: 1120px; margin: 0 auto; padding: 16px; } 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)); } /* shell */
.card, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; } .shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.card h2 { display: flex; justify-content: space-between; align-items: center; gap: .5rem; } .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; }
.card canvas { width: 100%; height: 44px; display: block; margin: .6rem 0; } .sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } .sidebar button.sidebar-close { display:none; }
.row.end { justify-content: flex-end; } .row.between { justify-content: space-between; } .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; }
.spacer { flex: 1; } .brand .icon { color:var(--accent-text); }
.stack > * + * { margin-top: 14px; } .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); } /* cards, layout */
.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); } .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; }
.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; } .card.flush { padding:0; overflow:hidden; }
.banner.bad { background: var(--bad-bg); color: var(--bad); } .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; } /* forms */
button:hover { border-color: var(--accent); } label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; } label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
button.danger { color: var(--bad); } button.danger:hover { border-color: var(--bad); } 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%; }
button.small { padding: .15rem .5rem; font-size: .82rem; } textarea { min-height:96px; resize:vertical; font-family:var(--font-mono); font-size:var(--text-sm); }
button:disabled { opacity: .5; cursor: default; } input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
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 { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:focus, select:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } input:disabled, select:disabled { opacity:.6; }
input[type=checkbox] { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); } input[aria-invalid="true"] { border-color:var(--danger); }
textarea { width: 100%; min-height: 5rem; font-family: ui-monospace, Consolas, monospace; font-size: .88rem; } .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; } /* buttons: btn is the primary action, ghost the default, danger and quiet the others */
.tabs a { padding: .45rem .9rem; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; } 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); }
.tabs a.on { color: var(--text); border-color: var(--accent); font-weight: 600; } 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; } /* tables */
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--line); vertical-align: middle; } table { width:100%; border-collapse:collapse; }
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } 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.actions { white-space: nowrap; text-align: right; } td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
.tablewrap { overflow-x: auto; } tbody tr:hover td { background:var(--surface-2); }
.empty { padding: 1.2rem; text-align: center; color: var(--muted); } 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; } /* badges: status always carries a text label, never color alone */
.ev { display: flex; gap: .6rem; padding: .25rem 0; border-bottom: 1px solid var(--line); align-items: baseline; } .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; }
.ev time { color: var(--muted); font-size: .8rem; white-space: nowrap; min-width: 4.8rem; } .badge-success { background:var(--success-bg); color:var(--success); }
.ev .tag { font-size: .7rem; text-transform: uppercase; padding: 0 .4rem; border-radius: 4px; background: var(--chip); color: var(--muted); white-space: nowrap; } .badge-warning { background:var(--warning-bg); color:var(--warning); }
.ev .tag.join, .ev .tag.online, .ev .tag.start { background: var(--ok-bg); color: var(--ok); } .badge-danger { background:var(--danger-bg); color:var(--danger); }
.ev .tag.leave { background: var(--chip); } .badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.ev .tag.offline, .ev .tag.ban, .ev .tag.kick, .ev .tag.error { background: var(--bad-bg); color: var(--bad); } .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); }
.ev .tag.restart, .ev .tag.filter, .ev .tag.watch, .ev .tag.schedule { background: var(--warn-bg); color: var(--warn); } .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; }
.ev .txt { overflow-wrap: anywhere; } .logbox .cmd { color:var(--accent-text); } .logbox .bad { color:var(--danger); }
.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); }
dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--text); padding: 18px 20px; width: min(520px, calc(100vw - 32px)); } /* the event and chat feed */
dialog::backdrop { background: rgba(0,0,0,.45); } .feed { max-height:60vh; overflow-y:auto; }
dialog label { display: block; margin: .6rem 0 .15rem; font-size: .85rem; color: var(--muted); } .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); }
dialog label.check { display: flex; gap: .5rem; align-items: center; color: var(--text); font-size: .95rem; } .ev:last-child { border-bottom:0; }
dialog input[type=text], dialog input[type=number], dialog input[type=password], dialog select { width: 100%; } .ev time { color:var(--text-muted); font-variant-numeric:tabular-nums; white-space:nowrap; }
dialog .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; } .ev .txt { overflow-wrap:anywhere; }
dialog .multi label { display: flex; gap: .35rem; align-items: center; margin: 0; color: var(--text); font-size: .9rem; } .ev .txt strong { font-weight:600; }
dialog .buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; } .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)); } /* empty state, notice, loading */
.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; } .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); }
.toast.bad { background: var(--bad); color: #fff; } .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; } /* dialogs, tabs, toasts */
.login form > * { display: block; width: 100%; margin-top: .6rem; } .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); }
@media (max-width: 640px) { main { padding: 10px; } td.actions { white-space: normal; } } .modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.grow { flex: 1; min-width: 0; } .mt { margin-top: .7rem; } .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.");