Files
Module-Rust/client/src/routes/admin/ChatTitles.jsx
wtclaude 481837e937
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s
feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)
PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- raid.started, raid.completed, raid.purchased and raid.locked public,
  raid.ended staff's; raidableBases.js reads each row per viewer (raiders'
  and a buyer's names only for the presence audience, D376/D390; Steam ids,
  position and a run's key only for staff). Feed lines in the World filter.
- The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget
  rust.raids): difficulty, profile and base from option sources listing each
  server's own names (D386), a spot as a world call's location with a height
  adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base
  the raids helper made is a world resource, so eventWorld's revert and
  reconcile serve it and world.expired marks it expired; one it did not make
  records nothing and the run log says it despawns on its own timer. Refused
  at verify for a server that said it cannot; a dry run with a spot returns
  the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready
  retries in ten minutes.
- Warlord: raids_completed, title category raidscompleted, the leaderboard's
  Raid bases column (sort=raidsCompleted).
- The map's raids layer (D114), public by default with its own audience; the
  run's key is dropped before any viewer gets it.
- Server settings' line for RaidableBases; its profiles in Mod configuration
  under a virtual path (D205).
- Swagger fragment, bundle and README.

Tests: server 684/684 (raidableBases.test.js new, every name in it invented);
client 99/99, build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:52 -05:00

456 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Admin · Rust · Servers — chat titles and the voice (phase 17) ─────────
//
// Two things the servers page gained with BetterChat and PopupNotifications
// becoming optional (PLAN.md §33):
//
// • **A server's chat titles** (D135, D136): rules that rank the current wipe,
// in the operator's order, and how many a player shows. Saved with their own
// PUT, because they are not part of the server row — an operator retitling
// "Top Killer" must not have to re-type a sidecar address.
// • **The voice** (D140): the one styled permission group news and event lines
// are said in. A fleet setting, so it sits once at the foot of the page.
//
// Neither needs BetterChat to save. Titles are held by the plugin until BetterChat
// arrives, and the voice is said by our own plugin whether BetterChat is there or
// not; the "What this server has" line says which is which.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { contrastInk } from '../../lib/format.js'
const MODES = [
{ id: 'first', label: 'The first title they earn', hint: 'The rule highest in the list wins.' },
{ id: 'all', label: 'Every title they earn' },
{ id: 'upto', label: 'Up to a number of titles', hint: 'In list order.' },
]
/**
* What a rule can rank (PLAN_REDESIGNS §5): the server's categories, each with
* the title it shows now (D175), then playtime, which has no title of its own.
*/
export function statOptions(categories) {
return [
...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })),
{ id: 'playtime', label: 'Playtime', title: null },
// RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile.
{ id: 'profilekills', label: 'Kills of an NPC profile', title: null },
]
}
const statOf = (categories, id) => statOptions(categories).find((s) => s.id === id) || { id, label: id, title: null }
/** A rule's title as players see it: its own text, else its category's. */
export const ruleTitle = (rule, categories) => rule.text || statOf(categories, rule.stat).title || ''
/** One line summarising a server's titles, for its row on the list. */
export function titlesSummary(titles, push, categories) {
const rules = (titles && titles.rules) || []
if (!rules.length) return 'No chat titles.'
const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.toLowerCase()})`).join(', ')
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
return `Chat titles: ${list}.${shown}`
}
/** A title as the leaderboard will show it. */
function Chip({ text, color }) {
return (
<span
className="sans"
style={{ padding: '1px 6px', borderRadius: 999, fontSize: '0.72rem', fontWeight: 600, background: color, color: contrastInk(color) }}
>
{text || '…'}
</span>
)
}
export function TitlesForm({ server, categories, onSaved, onCancel }) {
const start = server.titles || { mode: 'first', max: 2, rules: [] }
const stats = statOptions(categories)
const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id])
const profiles = (npc && npc.profiles) || []
const [mode, setMode] = useState(start.mode)
const [max, setMax] = useState(start.max)
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const setRule = (i, key) => (e) => {
const value = e.target.value
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' || key === 'profile' ? Number(value) : value } : r)))
}
const move = (i, by) => setRules((list) => {
const next = [...list]
const [r] = next.splice(i, 1)
next.splice(i + by, 0, r)
return next
})
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.admin.saveTitles(server.id, { mode, max: Number(max), rules })
onSaved()
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={save} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 12, marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>Chat titles on {server.name}</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
Each rule gives a title to the top players on this wipe for one stat. A stat of zero earns nothing, so a fresh wipe
has no titles until somebody plays. Titles show in game chat when BetterChat is installed, and beside the name on
the leaderboard here and in the app. Up to ten rules; a title is at most 24 characters and cannot carry markup.
Leave a title blank to use the category’s title, which is set once for every server below the list.
</p>
{rules.map((r, i) => {
const fallback = statOf(categories, r.stat).title
return (
<div key={i} className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', fontSize: '0.84rem' }}>
<span className="dim" style={{ width: 18 }}>{i + 1}</span>
<span>Top</span>
<input type="number" min={1} max={10} value={r.topN} onChange={setRule(i, 'topN')} style={{ ...inputStyle, width: 60 }} aria-label="How many players" />
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
{stats.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
{r.stat === 'profilekills' && (
<select value={r.profile || ''} onChange={setRule(i, 'profile')} style={inputStyle} aria-label="NPC profile" required>
<option value="" disabled>{profiles.length ? 'Which NPC?' : 'No NPC profile on this server'}</option>
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} ({p.name})</option>)}
</select>
)}
<span>earn</span>
{/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */}
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} style={{ ...inputStyle, width: 160 }} aria-label="Title" />
<input type="color" value={r.color} onChange={setRule(i, 'color')} aria-label="Colour" style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }} />
<Chip text={r.text || fallback} color={r.color} />
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button type="button" className="btn" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
<button type="button" className="btn" disabled={i === rules.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
<button type="button" className="btn" onClick={() => setRules((list) => list.filter((_, n) => n !== i))}>Remove</button>
</span>
</div>
)
})}
{rules.length < 10 && (
<div>
<button type="button" className="btn" onClick={() => setRules((list) => [...list, { stat: 'kills', topN: 1, text: '', color: '#ffaa55' }])}>
Add a rule
</button>
</div>
)}
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', fontSize: '0.84rem' }}>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
A player shows
<select value={mode} onChange={(e) => setMode(e.target.value)} style={inputStyle}>
{MODES.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</label>
{mode === 'upto' && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
at most
<input type="number" min={1} max={5} value={max} onChange={(e) => setMax(e.target.value)} style={{ ...inputStyle, width: 60 }} />
</label>
)}
<span className="dim" style={{ fontSize: '0.74rem' }}>{(MODES.find((m) => m.id === mode) || {}).hint || ''}</span>
</div>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save titles'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
/**
* The category titles (D175): one title per category for the whole site, which
* every rule of that category without its own text shows. Each row saves on its
* own, and Reset brings the module's default back.
*/
export function CategoryTitles({ categories, onSaved }) {
const [drafts, setDrafts] = useState({})
const [busy, setBusy] = useState('')
const [errors, setErrors] = useState({})
if (!categories || !categories.length) return null
const save = async (stat, text) => {
setBusy(stat)
setErrors((e) => ({ ...e, [stat]: '' }))
try {
await api.admin.saveTitleCategory(stat, text)
setDrafts((d) => {
const next = { ...d }
delete next[stat]
return next
})
onSaved()
} catch (err) {
setErrors((e) => ({ ...e, [stat]: err.message || 'That did not save.' }))
} finally {
setBusy('')
}
}
return (
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Category titles</h2>
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
The title each category gives, on every server. A rule that has its own title keeps it; every other rule of the
category shows this one from the next push. Reset brings back the default.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(140px, 1fr) minmax(110px, auto) minmax(160px, 1.2fr) auto', gap: '6px 10px', alignItems: 'center' }}>
<span className="dim" style={{ fontSize: '0.74rem' }}>Category</span>
<span className="dim" style={{ fontSize: '0.74rem' }}>Default</span>
<span className="dim" style={{ fontSize: '0.74rem' }}>This site’s title</span>
<span />
{categories.map((c) => {
const draft = drafts[c.stat]
const value = draft !== undefined ? draft : c.text || ''
const changed = draft !== undefined && draft !== (c.text || '')
return (
<Row key={c.stat}>
<span>{c.label}</span>
<span className="dim">{c.default}</span>
<span style={{ display: 'grid', gap: 2 }}>
<input
value={value}
placeholder={c.default}
maxLength={24}
onChange={(e) => setDrafts((d) => ({ ...d, [c.stat]: e.target.value }))}
style={inputStyle}
aria-label={`${c.label} title`}
/>
{errors[c.stat] && <span style={{ color: '#d08a2a', fontSize: '0.74rem' }}>{errors[c.stat]}</span>}
</span>
<span style={{ display: 'flex', gap: 4 }}>
<button type="button" className="btn" disabled={!changed || busy === c.stat} onClick={() => save(c.stat, value)}>Save</button>
<button type="button" className="btn" disabled={!c.text || busy === c.stat} onClick={() => save(c.stat, '')}>Reset</button>
</span>
</Row>
)
})}
</div>
</section>
)
}
/** A grid row's cells, without a wrapper element the grid would lay out as one cell. */
function Row({ children }) {
return <>{children}</>
}
/** What a server says it has loaded, as one sentence (§33.2 `integrations`). */
export function integrationsLine(r) {
if (!r || !r.integrations) {
return r && r.ok === false
? `The game could not be asked (${r.status}).`
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
}
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
const withGc = r.integrations.guardedCrate ? `${withDt} ${guardedCrateLine(r.integrations.guardedCrate, r.console)}` : withDt
const withZ = r.integrations.zLevels ? `${withGc} ${zLevelsLine(r.integrations.zLevels)}` : withGc
return r.integrations.raidableBases ? `${withZ} ${raidableBasesLine(r.integrations.raidableBases, r.console)}` : withZ
}
/**
* RaidableBases (PLAN_REDESIGNS §11.4), from protocol 14: its edition, its own
* difficulties (D386), CopyPaste, and the raids helper that makes a step's base the
* run's (D204) — and what a step may do without it (D348, D387).
*/
export function raidableBasesLine(rb, probe) {
if (!rb.loaded) {
return `RaidableBases is not loaded — no raid bases on the feed or the map, and a step cannot spawn one.${rb.helper && rb.helper.state !== 'missing' ? ' RunicGatewayRaids.cs is installed and waits for it.' : ''}`
}
const name = `RaidableBases ${rb.version || ''} (${rb.premium ? 'paid' : 'free'} edition)`.replace(' ()', '').trim()
const difficulties = Array.isArray(rb.difficulties) && rb.difficulties.length
? ` Its difficulties: ${rb.difficulties.map((d) => d.name).join(', ')}.`
: ''
if (rb.copyPaste && !rb.copyPaste.loaded) {
return `${name} is loaded but CopyPaste is missing: it cannot paste a base, and a step cannot spawn one.`
}
const state = rb.helper ? rb.helper.state : 'missing'
if (state === 'patched') {
const grid = rb.gridReady === false ? ' It is still building its spawn grid (up to ten minutes after it loads); a step waits for it.' : ''
const profiles = Array.isArray(rb.profiles) ? ` ${rb.profiles.length} profile${rb.profiles.length === 1 ? '' : 's'}.` : ''
return `${name} is loaded with RunicGatewayRaids.cs — its bases show on the feed and the map, and a step may spawn one that is the event’s: at a spot, with its own settings and lock, despawned when the event ends.${difficulties}${profiles}${grid}`
}
const helper = state === 'missing'
? 'RunicGatewayRaids.cs is not installed'
: `RunicGatewayRaids.cs could not patch it${rb.helper && rb.helper.reason ? ` (${rb.helper.reason})` : ''}`
if (rb.spawnCall) {
return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, at a difficulty, and it despawns on its own timer.${difficulties}`
}
if (probe && probe.plugins === false) {
return `${name} is loaded — its bases show on the feed and the map. ${helper}, and this framework cannot run its console command, so a step cannot spawn one (Rust-Plugins#34).`
}
return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, and it despawns on its own timer.${difficulties}`
}
/**
* ZLevels Remastered (PLAN_REDESIGNS §14.3.6), from protocol 14. Only whether it is
* loaded: its levels never reach the site (D374), and its XP boosts are permissions
* an event lends like any other.
*/
export function zLevelsLine(z) {
if (!z.loaded) return 'ZLevels is not loaded — there are no XP boosts for an event to lend.'
return `${`ZLevels ${z.version || ''}`.trim()} is loaded — an event may lend its XP boosts (zlevelsremastered.vip1 to vip3) as permissions.`
}
/**
* Guarded Crate (PLAN_REDESIGNS §14.3.5), from protocol 14, with NpcSpawn beside it
* (D364): its crates on the feed and the map, and whether a step can place one,
* which needs NpcSpawn for the guards and the framework to run its console command.
*/
export function guardedCrateLine(gc, probe) {
const npc = gc.npcSpawn && gc.npcSpawn.loaded ? `NpcSpawn ${gc.npcSpawn.version || ''}`.trim() : null
if (!gc.loaded) {
return `Guarded Crate is not loaded — a step cannot place a guarded crate.${npc ? ` ${npc} is loaded.` : ''}`
}
const name = `Guarded Crate ${gc.version || ''}`.trim()
const events = Array.isArray(gc.events) && gc.events.length ? ` Its events: ${gc.events.join(', ')}.` : ''
if (!npc) {
return `${name} is loaded but NpcSpawn is missing: its events cannot start, and a step cannot place one.`
}
if (probe && probe.plugins === false) {
return `${name} is loaded with ${npc} — its crates show on the feed and the map, but this framework cannot run its console command, so a step cannot place one (Rust-Plugins#34).${events}`
}
return `${name} is loaded with ${npc} — its crates show on the feed and the map, and a step may place one.${events}`
}
/**
* Dangerous Treasures (PLAN_REDESIGNS §14.3.4), from protocol 14: its events on the
* feed and the map, and whether a step can start one, which needs the framework
* to run its console command (hello's `console.plugins`, D363).
*/
export function dangerousTreasuresLine(dt, probe) {
if (!dt.loaded) return 'Dangerous Treasures is not loaded — a step cannot start a treasure event.'
const name = `Dangerous Treasures ${dt.version || ''}`.trim()
if (probe && probe.plugins === false) {
return `${name} is loaded — its events show on the feed and the map, but this framework cannot run its console command, so a step cannot start one (Rust-Plugins#34).`
}
return `${name} is loaded — its events show on the feed and the map, and a step may start one.`
}
/** Dynamic PVP (PLAN_REDESIGNS §14.3.3), from protocol 14: its zones on the feed and the map. */
export function dynamicPvpLine(d) {
if (!d.loaded) return 'Dynamic PVP is not loaded — no PvP zones of its own on the feed or the map.'
return `${`Dynamic PVP ${d.version || ''}`.trim()} is loaded — its PvP zones show on the feed and the map.`
}
/** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */
export function truePveLine(tp) {
if (!tp.loaded) return 'TruePVE is not loaded — a zone step cannot change PvP rules.'
return `${`TruePVE ${tp.version || ''}`.trim()} is loaded — a zone step may turn off PvP protection or name a ruleset.`
}
/**
* Backpacks (PLAN_REDESIGNS §11.3.2), from protocol 14. Its sizes are what a
* Bigger backpack step may lease (D337), so they are said.
*/
export function backpacksLine(b) {
if (!b.loaded) return 'Backpacks is not loaded — backpack views and bigger-backpack rewards wait for it.'
const name = `Backpacks ${b.version || ''}`.trim()
const sizes = Array.isArray(b.sizes) ? b.sizes : []
return sizes.length > 1
? `${name} is loaded — sizes ${sizes.join(', ')}; an event may lend any above ${sizes[0]}.`
: `${name} is loaded, but offers no size above its smallest, so an event has nothing bigger to lend.`
}
/**
* Economics (PLAN_REDESIGNS §11.2.2), from protocol 14. Its starting balance
* decides who the richest column ranks (D336), so a config the plugin could not
* read is said: the site then assumes Economics' default of 1000.
*/
export function economicsLine(e) {
if (!e.loaded) return 'Economics is not loaded — coin rewards, balances and the richest column wait for it.'
const name = `Economics ${e.version || ''}`.trim()
if (e.startingBalance === undefined || e.startingBalance === null) {
return `${name} is loaded, but its config could not be read — the site assumes a starting balance of 1,000.`
}
const wipes = e.wipesWithMap === true ? 'balances wipe with the map' : e.wipesWithMap === false ? 'balances carry over a wipe' : null
return `${name} is loaded — starting balance ${Number(e.startingBalance).toLocaleString()}${wipes ? `, ${wipes}` : ''}.`
}
export function VoiceCard() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.admin.voice(), [reloads])
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const choose = async (group) => {
setBusy(true)
setError('')
try {
await api.admin.saveVoice(group)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
const current = data.options.find((o) => o.group === data.voice)
return (
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Announcement voice</h2>
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
News posts and event announcements said in game chat can wear the title and colours of one permission group that
has a chat style. The line has no sender, so no player’s name appears. It works whether or not BetterChat is
installed. Popups are plain text.
</p>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
Say them as
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
<option value="">Plain chat</option>
{data.options.map((o) => (
<option key={o.group} value={o.group}>{o.name} — {o.title} ({o.where})</option>
))}
</select>
</label>
{data.voice && !current && (
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
The chosen group no longer has a chat style, so lines are said in plain chat until it has one again or another
voice is chosen.
</p>
)}
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}
{!data.options.length && (
<p className="dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>No group has a chat style yet. Give one a style under Permissions.</p>
)}
{error && <p style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>}
</section>
)
}
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '5px 8px',
fontSize: '0.84rem',
}