fix(rust): nothing names who is online by default
The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.
The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:
* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
Below the audience the feed keeps the server's own story (wipe, start,
shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
rust_settings table with an optional per-server override on
rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
not taken from the token, so a demotion or a ban applies on the next
request. Walked: a moderator demoted mid-session lost the roll call on
the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
Rust visibility; every save is one activity-log row.
The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -154,6 +154,17 @@ export const adminPermissions = {
|
||||
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
|
||||
}
|
||||
|
||||
// ── admin · visibility ────────────────────────────────────────────────────
|
||||
//
|
||||
// Who may see who is online. The org lead's rule is that nothing names who is
|
||||
// online by default; this is where an operator deliberately widens it. A save
|
||||
// answers the whole new state, so the screen re-renders from the server's word
|
||||
// rather than from what it sent.
|
||||
export const adminVisibility = {
|
||||
read: () => req('/admin/rust/visibility'),
|
||||
save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
|
||||
}
|
||||
|
||||
// ── admin · mod configuration (R18) ───────────────────────────────────────
|
||||
//
|
||||
// Every call here is a LIVE round trip to a game host, which makes this the only
|
||||
@@ -226,6 +237,7 @@ export default {
|
||||
admin,
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
adminVisibility,
|
||||
adminUserLinks,
|
||||
adminUserPermissions,
|
||||
BASE,
|
||||
|
||||
26
client/src/components/Empty.jsx
Normal file
26
client/src/components/Empty.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── An empty state with a heading and a sentence ──────────────────────────
|
||||
//
|
||||
// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
|
||||
// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
|
||||
// template teaches — and React drops an unknown prop without a word, so every
|
||||
// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
|
||||
// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
|
||||
// when the "12 players online" it depended on came out as nothing.
|
||||
//
|
||||
// Fixed here rather than in core: core's component is shared by every module,
|
||||
// and a module-side wrapper changes nothing anybody else renders. The client
|
||||
// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
|
||||
// cannot come back.
|
||||
|
||||
import { EmptyState } from '../core.js'
|
||||
|
||||
export default function Empty({ title, message }) {
|
||||
return (
|
||||
<EmptyState>
|
||||
{title && (
|
||||
<strong style={{ display: 'block', color: 'var(--head)', marginBottom: message ? 6 : 0 }}>{title}</strong>
|
||||
)}
|
||||
{message && <span>{message}</span>}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
@@ -11,11 +11,13 @@
|
||||
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||
// this job.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
import { hiddenMessage } from './Online.jsx'
|
||||
|
||||
const TONE = {
|
||||
kill: 'var(--accent-bright)',
|
||||
@@ -79,10 +81,24 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
off" must not blank itself the first time a request does. */}
|
||||
{error && !data && <ErrorState error={error} />}
|
||||
|
||||
{/* Below the operator's presence audience the server withholds every item
|
||||
that names a player who was on — the killfeed, chat, joins — and keeps
|
||||
only the server's own story. Said once, above the rows, so a thin feed
|
||||
reads as withheld rather than as a quiet server. */}
|
||||
{data && data.presenceHidden && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && events.length === 0 && (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="Nothing here yet"
|
||||
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
|
||||
message={
|
||||
data.presenceHidden
|
||||
? 'Nothing this server has reported about itself matches.'
|
||||
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||
// strip for anybody who disagrees.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
@@ -32,13 +33,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
)
|
||||
|
||||
const rows = data ? data.leaderboard : []
|
||||
// Present on every row or on none — the server decides per request.
|
||||
const showLastSeen = rows.some((row) => 'lastSeen' in row)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No scores yet"
|
||||
message={
|
||||
wipeId
|
||||
@@ -80,7 +83,13 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
{/* The server withholds `lastSeen` below the operator's presence
|
||||
audience — a gather tally refreshes it every minute somebody plays,
|
||||
so it would name who is online. The column goes with it rather
|
||||
than rendering a row of dashes that look like "never". */}
|
||||
{showLastSeen && (
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -98,7 +107,9 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
{showLastSeen && (
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||
// is a live question.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { duration, shortId } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
@@ -25,9 +26,23 @@ export default function Online({ serverId, online }) {
|
||||
if (loading) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} />
|
||||
|
||||
// Nothing names who is online by default (the org lead's rule). Below the
|
||||
// operator's audience the server answers a count and no names, and the page
|
||||
// says so — an empty list here would read as "nobody is on", which is a
|
||||
// different claim and a false one.
|
||||
if (data && data.hidden) {
|
||||
const count = Number(data.count) || 0
|
||||
return (
|
||||
<Empty
|
||||
title={online ? `${count.toLocaleString()} ${count === 1 ? 'player' : 'players'} online` : 'The server is offline'}
|
||||
message={hiddenMessage(data.audience)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (players.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||
message={
|
||||
online
|
||||
@@ -92,3 +107,16 @@ function sessionSoFar(connectedAt) {
|
||||
if (Number.isNaN(since)) return ''
|
||||
return duration((Date.now() - since) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why something was withheld, in words a visitor can act on.
|
||||
*
|
||||
* `what` completes the sentence — "who they are", "what players did". The
|
||||
* audience is the operator's (`staff` unless widened), and only `signed_in` is
|
||||
* something a visitor can do anything about.
|
||||
*/
|
||||
export function hiddenMessage(audience, what = 'who they are') {
|
||||
if (audience === 'signed_in') return `Sign in to see ${what}.`
|
||||
if (audience === 'public') return `This site is not showing ${what} right now.`
|
||||
return `Only this site’s staff can see ${what}.`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// id the events and the leaderboard filter by. There is no second derivation
|
||||
// anywhere that could disagree.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
||||
|
||||
if (wipes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No wipes recorded"
|
||||
message="A wipe appears here once this server has reported something during it."
|
||||
/>
|
||||
|
||||
@@ -23,9 +23,10 @@ import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||
import Account from './routes/player/Account.jsx'
|
||||
import Permissions from './routes/admin/Permissions.jsx'
|
||||
import ModConfig from './routes/admin/ModConfig.jsx'
|
||||
import Visibility from './routes/admin/Visibility.jsx'
|
||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
import { IconKey, IconLink, IconSliders } from './icons.jsx'
|
||||
import { IconEye, IconKey, IconLink, IconSliders } from './icons.jsx'
|
||||
|
||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||
// and prefixes every route path with it.
|
||||
@@ -94,6 +95,10 @@ registry.registerRoutes(ID, {
|
||||
// `/admin/rust/config` and core's admin gate applies to it exactly as it
|
||||
// does to the page above.
|
||||
{ path: 'config', element: <ModConfig /> },
|
||||
// Who may see who is online — a third neighbour. The org lead's rule is that
|
||||
// nothing names who is online by default; this is where an operator widens
|
||||
// it on purpose, fleet-wide or per server.
|
||||
{ path: 'visibility', element: <Visibility /> },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -142,6 +147,7 @@ registry.registerNav(ID, {
|
||||
items: [
|
||||
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
|
||||
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
|
||||
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -83,4 +83,16 @@ export const IconSliders = () => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders }
|
||||
/**
|
||||
* An eye — the admin sidebar's row for who may see who is online.
|
||||
*
|
||||
* The page decides what the public can SEE, so the glyph is the act of seeing.
|
||||
*/
|
||||
export const IconEye = () => (
|
||||
<Icon>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders, IconEye }
|
||||
|
||||
196
client/src/routes/admin/Visibility.jsx
Normal file
196
client/src/routes/admin/Visibility.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
|
||||
//
|
||||
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
|
||||
// is online by default — the narrowest audience, staff, unless an operator
|
||||
// deliberately widens it here. A count of players is public at every setting.
|
||||
//
|
||||
// One fleet default and an optional override per server, because a creative or
|
||||
// PvE server may reasonably publish a roll call a PvP server must not — and a
|
||||
// server that has not chosen follows the fleet, so narrowing the fleet narrows
|
||||
// every server that never said otherwise.
|
||||
//
|
||||
// The page says what "who is online" covers, because it is wider than the tab
|
||||
// of the same name: the killfeed, chat and joins in the feed, and the
|
||||
// leaderboard's "last seen" all name a player who was on at a given moment.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const INHERIT = ''
|
||||
|
||||
const LABEL = {
|
||||
staff: 'Staff only',
|
||||
signed_in: 'Signed-in members',
|
||||
public: 'Everyone',
|
||||
}
|
||||
|
||||
const DESCRIBE = {
|
||||
staff: 'Admins and moderators. The default.',
|
||||
signed_in: 'Anybody with an account on this site.',
|
||||
public: 'Anybody at all, signed in or not.',
|
||||
}
|
||||
|
||||
function Card({ title, subtitle, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle} aria-label={label}>
|
||||
{inherit && <option value={INHERIT}>{inherit}</option>}
|
||||
{audiences.map((a) => (
|
||||
<option key={a} value={a}>{LABEL[a] || a}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Visibility() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
|
||||
|
||||
const [fleet, setFleet] = useState('staff')
|
||||
const [servers, setServers] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// The form starts from what the server said and is reset from it after every
|
||||
// save — the answer to a PUT is the new state, so what is on screen is always
|
||||
// the site's word rather than what this page sent.
|
||||
const load = useCallback((state) => {
|
||||
setFleet(state.presence.fleet)
|
||||
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) load(data)
|
||||
}, [data, load])
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const audiences = data.audiences
|
||||
const rows = data.presence.servers
|
||||
|
||||
const dirtyFleet = fleet !== data.presence.fleet
|
||||
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0
|
||||
|
||||
const effective = (id) => servers[id] || fleet
|
||||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setSaved(false)
|
||||
try {
|
||||
const body = {}
|
||||
if (dirtyFleet) body.fleet = fleet
|
||||
if (dirtyServers.length) {
|
||||
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
|
||||
}
|
||||
load(await api.adminVisibility.save(body))
|
||||
setSaved(true)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={save} style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Nothing on this site names who is online unless you choose to show it. That covers more than
|
||||
the Online tab: the joins, deaths and chat in each server’s feed, and the leaderboard’s “last
|
||||
seen”, all say that a named player was on at a given moment. How many players are online is
|
||||
always shown.
|
||||
</p>
|
||||
|
||||
<Card title="Who is online" subtitle="the default for every server">
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||||
<AudienceSelect value={fleet} onChange={setFleet} audiences={audiences} label="Fleet default" />
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{DESCRIBE[fleet]}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Per server" subtitle="an override, or the default above">
|
||||
{rows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{rows.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<AudienceSelect
|
||||
value={servers[s.id] ?? INHERIT}
|
||||
onChange={(v) => setServers((prev) => ({ ...prev, [s.id]: v }))}
|
||||
audiences={audiences}
|
||||
inherit={`Default (${LABEL[fleet] || fleet})`}
|
||||
label={`Who is online on ${s.name}`}
|
||||
/>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
{servers[s.id] ? 'its own setting' : 'follows the default'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{widened && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem' }}>
|
||||
Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is
|
||||
undefended.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy || !dirty}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && !dirty && <span className="dim" style={{ fontSize: '0.8rem' }}>Saved.</span>}
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.84rem',
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
// is down. The site's availability does not depend on the game's.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import Empty from '../../components/Empty.jsx'
|
||||
import { ago, count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
@@ -61,7 +62,7 @@ export default function Servers() {
|
||||
empty game — it is an install that is not finished. Saying so beats a
|
||||
blank page that looks like a failure. */}
|
||||
{data && servers.length === 0 && (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No servers yet"
|
||||
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
|
||||
/>
|
||||
|
||||
49
client/test/uiKitProps.test.js
Normal file
49
client/test/uiKitProps.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// ── The UI kit's props, as core actually reads them ───────────────────────
|
||||
//
|
||||
// React drops an unknown prop without a word, so a UI-kit component called with
|
||||
// the wrong one renders — just not what was written. Two of these have shipped
|
||||
// from this org already: `PageHeader subtitle` (Teams phase 11, the kit's
|
||||
// template) and `EmptyState title/message` (this module, phases 4 to 8 — every
|
||||
// empty panel was a blank box until the presence fix's browser walk).
|
||||
//
|
||||
// A DOM-less runner cannot see a blank box, so this reads the source instead:
|
||||
// it names the props core's components do NOT take and fails on any use of them.
|
||||
// It is a claim about core that must be re-read when core's kit changes —
|
||||
// written down rather than imported, because no core is in this process.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
|
||||
/** Every .jsx/.js under src/. */
|
||||
function sources(dir = SRC) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) return sources(full)
|
||||
return /\.(jsx?|mjs)$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
// Core's `components/PageState.jsx` and `PageHeader.jsx`, read 2026-09-23 at the
|
||||
// pinned core (ci/core-ref.json).
|
||||
const REFUSED = {
|
||||
// `EmptyState({ children })` — children only.
|
||||
EmptyState: /<EmptyState\b[^>]*\b(title|message|description|text)\s*=/,
|
||||
// `PageHeader({ eyebrow, title, lead, center })` — there is no `subtitle`.
|
||||
PageHeader: /<PageHeader\b[^>]*\bsubtitle\s*=/,
|
||||
}
|
||||
|
||||
test('no UI-kit component is handed a prop core does not read', () => {
|
||||
const offences = []
|
||||
for (const file of sources()) {
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
for (const [component, pattern] of Object.entries(REFUSED)) {
|
||||
if (pattern.test(text)) offences.push(`${path.relative(SRC, file)}: ${component}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offences, [], 'use components/Empty.jsx for a titled empty state')
|
||||
})
|
||||
Reference in New Issue
Block a user