FiveRP: the server from the hosting account, not the earlier one

Wrong server was published before. This is the one that runs on the
hosting account the video was made on: FiveRP - two-step registration
(account, then an identity printed onto a passport), login that returns
you to your resident, and a loading screen driven by the game's own
streaming events.

  resources/[local]/fiverp-auth          NUI + scrypt hashes + oxmysql
  resources/[local]/fiverp-characters    identity, character load, spawn
  resources/[local]/fiverp-loadscreen    loading screen
  sql/schema.sql                         accounts, characters
  docs/DESIGN.md                         the design system every screen follows
  docs/screens/                          shot from the live server

Only our own code is in here. cfx-server-data and oxmysql are fetched by
install.sh instead of being vendored, which keeps the repo at 3.7 MB.

install.sh does the whole box in one command: recommended FXServer
build, cfx-server-data, oxmysql, MariaDB with the schema, resources,
server.cfg (mode 600 - it carries the key and the database password),
boot entry, then it waits for the Cfx registration. Tested end to end on
a spare install root: 29 resources scanned, database and schema created,
and it stopped exactly where a wrong licence key should stop it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 5
2026-08-12 23:42:20 +00:00
co-authored by Claude Opus 5
parent 71856b15e9
commit c857979a55
88 changed files with 3904 additions and 7463 deletions
@@ -0,0 +1,512 @@
/* FiveRP — character slots and appearance creator.
Every control writes into one `look` object; that object is what the ped is
rebuilt from on the client and what the row stores in the database, so the
preview and the saved character can never drift apart. */
const RES = 'fiverp-characters';
function post(name, data) {
return fetch(`https://${RES}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data || {})
}).catch(() => {});
}
const $ = (id) => document.getElementById(id);
const root = $('root');
const screens = { select: $('screenSelect'), editor: $('screenEditor') };
// ------------------------------------------------------------------ state
// Parent heads: the game keeps the male faces at the bottom of the list and
// the female ones above them.
const FATHERS = 21; // head ids 0..20
const MOTHERS = 24; // head ids 21..44
const FEATURES = [
'Nose width', 'Nose height', 'Nose length', 'Nose bridge', 'Nose tip',
'Nose bridge shift', 'Brow height', 'Brow width', 'Cheekbone height',
'Cheekbone width', 'Cheek width', 'Eye opening', 'Lip thickness',
'Jaw width', 'Jaw length', 'Chin height', 'Chin length', 'Chin width',
'Chin dimple', 'Neck thickness'
];
let limits = {
overlays: [24, 29, 34, 15, 75, 7, 12, 11, 10, 18, 17, 12],
hair: 1, torso: 1, undershirt: 1, legs: 1, shoes: 1
};
let slots = [];
let ctx = { slot: 1, id: null, mode: 'create' };
let tab = 'heritage';
let look = blankLook();
function blankLook() {
return {
model: 'mp_m_freemode_01',
blend: { shapeFirst: 0, shapeSecond: 21, shapeMix: 0.5, skinFirst: 0, skinSecond: 21, skinMix: 0.5 },
features: new Array(20).fill(0),
overlays: Array.from({ length: 12 }, () => ({ v: 255, o: 1, c1: 0, c2: 0 })),
hair: { style: 0, color: 0, highlight: 0 },
eye: 0,
outfit: { torso: 0, undershirt: 0, legs: 0, shoes: 0 }
};
}
// The ped rebuild is cheap but not free; coalesce a slider drag into one call
// per frame-ish so dragging stays smooth.
let pushTimer = null;
function pushLook() {
clearTimeout(pushTimer);
pushTimer = setTimeout(() => post('preview', look), 45);
}
// ----------------------------------------------------------- slot screen
function show(name) {
Object.entries(screens).forEach(([key, el]) => el.classList.toggle('is-active', key === name));
}
function when(value) {
if (!value) return null;
const d = new Date(String(value).replace(' ', 'T'));
if (isNaN(d)) return null;
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
function renderSlots() {
const host = $('slots');
host.innerHTML = '';
slots.forEach((s, i) => {
const card = document.createElement('article');
card.className = 'slot fade-up delay-' + Math.min(i + 1, 3);
if (!s.id) {
card.classList.add('slot--empty');
card.innerHTML = `
<div class="plus">+</div>
<div class="label">Create character</div>
<div class="hint">Slot ${s.slot} is free</div>`;
card.addEventListener('click', () => openEditor({ slot: s.slot, mode: 'create' }));
} else {
const played = when(s.lastPlayed);
card.innerHTML = `
<p class="slot-no">Slot ${s.slot}</p>
<h2 class="slot-name">${s.firstName}<br>${s.lastName}</h2>
<p class="slot-meta">${played ? 'Last seen ' + played : 'Never played'}</p>
<span class="slot-badge ${s.ready ? '' : 'is-draft'}">${s.ready ? 'Resident' : 'Needs appearance'}</span>
<div class="slot-actions"></div>`;
const actions = card.querySelector('.slot-actions');
const primary = document.createElement('button');
primary.className = 'btn-primary';
primary.textContent = s.ready ? 'Enter the city' : 'Finish character';
primary.addEventListener('click', () => {
if (s.ready) post('playChar', { id: s.id });
else openEditor({ slot: s.slot, id: s.id, mode: 'finish', first: s.firstName, last: s.lastName });
});
// Deleting is permanent, so the button asks once before it fires.
const remove = document.createElement('button');
remove.className = 'btn-ghost btn-danger';
remove.textContent = 'Delete';
let armed = false;
remove.addEventListener('click', () => {
if (!armed) {
armed = true;
remove.textContent = 'Confirm delete';
setTimeout(() => { armed = false; remove.textContent = 'Delete'; }, 4000);
return;
}
post('deleteChar', { id: s.id });
});
actions.append(primary, remove);
}
host.appendChild(card);
});
}
// --------------------------------------------------------------- controls
function control(label, opts) {
const wrap = document.createElement('div');
wrap.className = 'ctl';
const top = document.createElement('div');
top.className = 'ctl-top';
const name = document.createElement('span');
name.className = 'ctl-name';
name.textContent = label;
const val = document.createElement('span');
val.className = 'ctl-val';
top.append(name, val);
const input = document.createElement('input');
input.type = 'range';
input.min = opts.min;
input.max = opts.max;
input.step = opts.step || 1;
input.value = opts.get();
const paint = () => { val.textContent = (opts.fmt || String)(Number(input.value)); };
paint();
input.addEventListener('input', () => {
opts.set(Number(input.value));
paint();
pushLook();
});
wrap.append(top, input);
return wrap;
}
function groupLabel(text) {
const p = document.createElement('p');
p.className = 'group-label';
p.textContent = text;
return p;
}
// An overlay slider where the first stop means "none" (the game uses 255).
function overlayControl(host, label, index, colour) {
const count = limits.overlays[index] || 1;
host.appendChild(control(label, {
min: 0, max: count, step: 1,
get: () => (look.overlays[index].v === 255 ? 0 : look.overlays[index].v + 1),
set: (v) => { look.overlays[index].v = v === 0 ? 255 : v - 1; },
fmt: (v) => (v === 0 ? 'None' : String(v))
}));
host.appendChild(control(label + ' — strength', {
min: 0, max: 1, step: 0.05,
get: () => look.overlays[index].o,
set: (v) => { look.overlays[index].o = v; },
fmt: (v) => Math.round(v * 100) + '%'
}));
if (colour) {
host.appendChild(control(label + ' — colour', {
min: 0, max: 63, step: 1,
get: () => look.overlays[index].c1,
set: (v) => { look.overlays[index].c1 = v; look.overlays[index].c2 = v; },
fmt: (v) => '#' + v
}));
}
}
const TABS = [
{
id: 'heritage', name: 'Heritage',
build(host) {
const toggle = document.createElement('div');
toggle.className = 'toggle';
[['mp_m_freemode_01', 'Male'], ['mp_f_freemode_01', 'Female']].forEach(([model, label]) => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
b.className = look.model === model ? 'is-on' : '';
b.addEventListener('click', () => {
if (look.model === model) return;
look.model = model;
// A new body resets the wardrobe indices; the old ones may not exist.
look.outfit = { torso: 0, undershirt: 0, legs: 0, shoes: 0 };
look.hair.style = 0;
post('preview', look);
renderTab();
});
toggle.appendChild(b);
});
host.append(groupLabel('Body'), toggle);
host.appendChild(groupLabel('Parents'));
host.appendChild(control('Father', {
min: 0, max: FATHERS - 1,
get: () => look.blend.shapeFirst,
set: (v) => { look.blend.shapeFirst = v; look.blend.skinFirst = v; },
fmt: (v) => String(v + 1)
}));
host.appendChild(control('Mother', {
min: 21, max: 21 + MOTHERS - 1,
get: () => look.blend.shapeSecond,
set: (v) => { look.blend.shapeSecond = v; look.blend.skinSecond = v; },
fmt: (v) => String(v - 20)
}));
host.appendChild(control('Resemblance', {
min: 0, max: 1, step: 0.02,
get: () => look.blend.shapeMix,
set: (v) => { look.blend.shapeMix = v; },
fmt: (v) => v <= 0.5
? 'Father ' + Math.round((1 - v) * 100) + '%'
: 'Mother ' + Math.round(v * 100) + '%'
}));
host.appendChild(control('Skin tone', {
min: 0, max: 1, step: 0.02,
get: () => look.blend.skinMix,
set: (v) => { look.blend.skinMix = v; },
fmt: (v) => Math.round(v * 100) + '%'
}));
}
},
{
id: 'face', name: 'Face',
build(host) {
host.appendChild(groupLabel('Structure'));
FEATURES.forEach((label, i) => {
host.appendChild(control(label, {
min: -1, max: 1, step: 0.05,
get: () => look.features[i],
set: (v) => { look.features[i] = v; },
fmt: (v) => (v > 0 ? '+' : '') + v.toFixed(2)
}));
});
}
},
{
id: 'hair', name: 'Hair',
build(host) {
host.appendChild(groupLabel('Hair'));
host.appendChild(control('Style', {
min: 0, max: Math.max(0, limits.hair - 1),
get: () => look.hair.style,
set: (v) => { look.hair.style = v; },
fmt: (v) => (v === 0 ? 'Bald' : String(v))
}));
host.appendChild(control('Colour', {
min: 0, max: 63,
get: () => look.hair.color,
set: (v) => { look.hair.color = v; },
fmt: (v) => '#' + v
}));
host.appendChild(control('Highlights', {
min: 0, max: 63,
get: () => look.hair.highlight,
set: (v) => { look.hair.highlight = v; },
fmt: (v) => '#' + v
}));
host.appendChild(groupLabel('Eyebrows'));
overlayControl(host, 'Eyebrows', 2, 'hair');
host.appendChild(groupLabel('Facial hair'));
overlayControl(host, 'Beard', 1, 'hair');
host.appendChild(groupLabel('Body hair'));
overlayControl(host, 'Chest hair', 10, 'hair');
}
},
{
id: 'skin', name: 'Skin',
build(host) {
host.appendChild(groupLabel('Complexion'));
overlayControl(host, 'Complexion', 6, null);
overlayControl(host, 'Blemishes', 0, null);
overlayControl(host, 'Ageing', 3, null);
overlayControl(host, 'Sun damage', 7, null);
overlayControl(host, 'Freckles', 9, null);
overlayControl(host, 'Body blemishes', 11, null);
host.appendChild(groupLabel('Eyes'));
host.appendChild(control('Eye colour', {
min: 0, max: 31,
get: () => look.eye,
set: (v) => { look.eye = v; },
fmt: (v) => '#' + v
}));
host.appendChild(groupLabel('Makeup'));
overlayControl(host, 'Makeup', 4, 'makeup');
overlayControl(host, 'Blush', 5, 'makeup');
overlayControl(host, 'Lipstick', 8, 'makeup');
}
},
{
id: 'style', name: 'Clothes',
build(host) {
host.appendChild(groupLabel('Outfit'));
const rows = [
['Top', 'torso', 'torso'],
['Undershirt', 'undershirt', 'undershirt'],
['Legs', 'legs', 'legs'],
['Shoes', 'shoes', 'shoes']
];
rows.forEach(([label, key, limitKey]) => {
host.appendChild(control(label, {
min: 0, max: Math.max(0, (limits[limitKey] || 1) - 1),
get: () => look.outfit[key],
set: (v) => { look.outfit[key] = v; },
fmt: (v) => String(v + 1)
}));
});
}
},
{
id: 'identity', name: 'Identity',
build(host) {
host.appendChild(groupLabel('Legal name'));
const row = document.createElement('div');
row.className = 'field-row';
[['first', 'First name', 'John'], ['last', 'Last name', 'Doe']].forEach(([key, label, hint]) => {
const cell = document.createElement('div');
const lab = document.createElement('label');
lab.className = 'field-label';
lab.textContent = label;
const input = document.createElement('input');
input.className = 'field';
input.id = 'name-' + key;
input.maxLength = 24;
input.placeholder = hint;
input.value = ctx[key] || '';
input.disabled = ctx.mode === 'finish';
input.addEventListener('input', () => { ctx[key] = input.value.trim(); });
cell.append(lab, input);
row.appendChild(cell);
});
host.appendChild(row);
const note = document.createElement('p');
note.className = 'foot-note';
note.style.marginTop = '12px';
note.textContent = ctx.mode === 'finish'
? 'This name was registered with your passport and cannot be changed.'
: 'English letters only. This is the name Los Santos will know you by.';
host.appendChild(note);
}
}
];
function renderTabs() {
const host = $('tabs');
host.innerHTML = '';
TABS.forEach((t) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tab' + (t.id === tab ? ' is-on' : '');
b.textContent = t.name;
b.addEventListener('click', () => { tab = t.id; renderTabs(); renderTab(); });
host.appendChild(b);
});
}
function renderTab() {
const host = $('controls');
host.innerHTML = '';
host.scrollTop = 0;
const def = TABS.find((t) => t.id === tab) || TABS[0];
def.build(host);
renderTabs();
}
// ---------------------------------------------------------------- editor
function openEditor(options) {
ctx = {
slot: options.slot,
id: options.id || null,
mode: options.mode,
first: options.first || '',
last: options.last || ''
};
look = blankLook();
tab = 'heritage';
notice('');
$('editorSlot').textContent = 'Slot ' + ctx.slot;
$('btnSave').textContent = 'Save & enter city';
show('editor');
renderTab();
post('startEditor', { slot: ctx.slot, id: ctx.id, appearance: look });
}
function notice(text) {
const el = $('notice');
el.textContent = text || '';
el.classList.toggle('is-shown', !!text);
}
const RE_NAME = /^[A-Za-z]{2,24}$/;
function save() {
if (ctx.mode === 'create') {
if (!RE_NAME.test(ctx.first || '') || !RE_NAME.test(ctx.last || '')) {
tab = 'identity';
renderTab();
return notice('Enter a first and last name — 2 to 24 English letters each.');
}
}
notice('');
$('btnSave').disabled = true;
if (ctx.mode === 'finish') post('finishChar', { id: ctx.id, appearance: look });
else post('createChar', { slot: ctx.slot, firstName: ctx.first, lastName: ctx.last, appearance: look });
}
// Randomise heritage and the obvious surface details, leaving the 20 face
// sliders alone — random values there produce a melted face, not a person.
function randomise() {
const r = (n) => Math.floor(Math.random() * n);
look.blend.shapeFirst = look.blend.skinFirst = r(FATHERS);
look.blend.shapeSecond = look.blend.skinSecond = 21 + r(MOTHERS);
look.blend.shapeMix = Math.round(Math.random() * 50) / 50;
look.blend.skinMix = Math.round(Math.random() * 50) / 50;
look.hair.style = r(Math.max(1, limits.hair));
look.hair.color = look.hair.highlight = r(24);
look.eye = r(12);
look.overlays[2].v = r(limits.overlays[2] || 1); // eyebrows
look.overlays[2].c1 = look.overlays[2].c2 = look.hair.color;
look.outfit.torso = r(Math.max(1, limits.torso));
look.outfit.legs = r(Math.max(1, limits.legs));
look.outfit.shoes = r(Math.max(1, limits.shoes));
post('preview', look);
renderTab();
}
$('btnSave').addEventListener('click', save);
$('btnRandom').addEventListener('click', randomise);
$('btnCancel').addEventListener('click', () => {
show('select');
post('backToSelect', {});
});
$('rotLeft').addEventListener('click', () => post('rotate', { by: -20 }));
$('rotRight').addEventListener('click', () => post('rotate', { by: 20 }));
document.querySelectorAll('.chip[data-zoom]').forEach((chip) => {
chip.addEventListener('click', () => {
document.querySelectorAll('.chip[data-zoom]').forEach((c) => c.classList.remove('is-on'));
chip.classList.add('is-on');
post('zoom', { part: chip.dataset.zoom });
});
});
// ------------------------------------------------------------ from client
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.action === 'open') {
root.classList.add('is-open');
show(msg.view || 'select');
} else if (msg.action === 'close') {
root.classList.remove('is-open');
} else if (msg.action === 'slots') {
slots = msg.payload || [];
renderSlots();
} else if (msg.action === 'limits') {
limits = Object.assign(limits, msg.payload || {});
if (screens.editor.classList.contains('is-active')) renderTab();
} else if (msg.action === 'saveResult') {
const p = msg.payload || {};
$('btnSave').disabled = false;
if (p.ok) {
// Straight into the world with the character just written to the row.
post('playChar', { id: p.id || ctx.id });
} else {
notice(p.error || 'Something went wrong. Try again.');
}
}
});
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FiveRP — Characters</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="root" id="root">
<!-- ------------------------------------------------ character slots -->
<section class="screen screen--select" id="screenSelect">
<header class="head fade-up">
<p class="eyebrow">Los Santos &middot; Residency Office</p>
<h1 class="title">Choose your character</h1>
<p class="sub">Three residency slots are available on this account.</p>
</header>
<div class="slots" id="slots"></div>
<p class="foot-note fade-up delay-3">Deleting a resident is permanent — the slot is freed for a new file.</p>
</section>
<!-- ---------------------------------------------- appearance editor -->
<section class="screen screen--editor" id="screenEditor">
<aside class="panel">
<header class="panel-head">
<p class="eyebrow" id="editorSlot">Slot 1</p>
<h2 class="panel-title">Create your resident</h2>
</header>
<nav class="tabs" id="tabs"></nav>
<div class="controls" id="controls"></div>
<p class="notice" id="notice"></p>
<footer class="panel-foot">
<button class="btn-ghost" id="btnCancel" type="button">Back</button>
<button class="btn-ghost btn-dice" id="btnRandom" type="button">Randomise</button>
<button class="btn-primary" id="btnSave" type="button">Save &amp; enter city</button>
</footer>
</aside>
<div class="stagebar">
<button class="round" id="rotLeft" type="button" aria-label="Rotate left">&#8249;</button>
<div class="chips">
<button class="chip is-on" data-zoom="head" type="button">Head</button>
<button class="chip" data-zoom="body" type="button">Body</button>
<button class="chip" data-zoom="legs" type="button">Legs</button>
</div>
<button class="round" id="rotRight" type="button" aria-label="Rotate right">&#8250;</button>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,297 @@
/* FiveRP — character slots and appearance creator.
Follows CLAUDE.md. Two hard rules from that document apply everywhere here:
no backdrop-filter and no box-shadow. CEF composites both as opaque black
plates the size of the element, which is the black-square artefact. Depth
comes from opacity, hairlines and inner borders instead. */
@font-face {
font-family: 'Unbounded';
src: url('fonts/unbounded-latin.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+2122, U+2191, U+2193, U+2212;
}
@font-face {
font-family: 'Unbounded';
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0100-02AF, U+0304, U+0308, U+1E00-1EFF, U+2020, U+20A0-20AB;
}
@font-face {
font-family: 'Unbounded';
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
font-weight: 300 800; font-display: block;
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
:root {
--surface: rgba(255, 255, 255, 0.95);
--surface-soft: rgba(255, 255, 255, 0.88);
--fg: #1d1d1f;
--fg-secondary: #6e6e73;
--fg-tertiary: #a1a1a6;
--accent: #0071e3;
--accent-strong: #0077ed;
--accent-soft: rgba(0, 113, 227, 0.08);
--line: rgba(0, 0, 0, 0.06);
--line-strong: rgba(0, 0, 0, 0.10);
--field-bg: rgba(0, 0, 0, 0.04);
--danger: #ff3b30;
--radius-lg: 1.5rem;
--radius-md: 1rem;
--radius-sm: 0.7rem;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
color-scheme: light;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: transparent; /* the game is the backdrop */
overflow: hidden;
font-family: 'Unbounded', -apple-system, 'Segoe UI', sans-serif;
color: var(--fg);
-webkit-font-smoothing: antialiased;
user-select: none;
}
.root { position: fixed; inset: 0; display: none; }
.root.is-open { display: block; }
.screen { position: absolute; inset: 0; display: none; }
.screen.is-active { display: flex; }
/* --------------------------------------------------------- shared bits */
.eyebrow {
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.title {
font-size: 30px; font-weight: 700; letter-spacing: -0.035em;
line-height: 1.1; margin-top: 10px;
}
.sub { margin-top: 8px; font-size: 13px; font-weight: 400; color: var(--fg-secondary); }
.btn-primary {
border: none; border-radius: 999px; padding: 11px 22px;
font-family: inherit; font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
color: #fff; cursor: pointer;
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
transition: filter .2s var(--ease), transform .2s var(--ease);
}
.btn-primary:hover { filter: brightness(1.08); }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: .45; pointer-events: none; }
.btn-ghost {
border: none; border-radius: 999px; padding: 11px 18px;
font-family: inherit; font-size: 13px; font-weight: 600;
color: var(--accent); background-color: var(--accent-soft); cursor: pointer;
transition: background-color .2s var(--ease);
}
.btn-ghost:hover { background-color: rgba(0, 113, 227, 0.14); }
.btn-danger { color: var(--danger); background-color: rgba(255, 59, 48, 0.08); }
.btn-danger:hover { background-color: rgba(255, 59, 48, 0.15); }
/* ---------------------------------------------------------- slot screen */
.screen--select { flex-direction: column; align-items: center; justify-content: center; gap: 34px; }
.head { text-align: center; }
.slots { display: flex; gap: 16px; }
.slot {
position: relative;
width: 268px; min-height: 330px;
display: flex; flex-direction: column;
padding: 26px 22px 22px;
border-radius: var(--radius-lg);
border: 1px solid var(--line-strong);
background: var(--surface);
transition: transform .35s var(--ease), border-color .35s var(--ease);
}
.slot:hover { transform: translateY(-4px); border-color: rgba(0, 0, 0, 0.14); }
.slot-no {
font-size: 10px; font-weight: 600; letter-spacing: 0.20em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.slot-name {
margin-top: 14px;
font-size: 21px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2;
}
.slot-meta { margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--fg-secondary); }
.slot-badge {
display: inline-block; margin-top: 12px; align-self: flex-start;
padding: 4px 11px; border-radius: 999px;
font-size: 10px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--accent); background: var(--accent-soft);
}
.slot-badge.is-draft { color: #b25000; background: rgba(255, 149, 0, 0.12); }
.slot-actions { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
.slot-actions button { width: 100%; }
/* Empty slot: one big invitation, centred. */
.slot--empty {
align-items: center; justify-content: center; text-align: center;
gap: 14px; cursor: pointer;
background: var(--surface-soft);
border-style: dashed; border-color: rgba(0, 0, 0, 0.14);
}
.slot--empty:hover { border-color: var(--accent); background: rgba(255, 255, 255, 0.97); }
.plus {
width: 54px; height: 54px; border-radius: 50%;
display: grid; place-items: center;
font-size: 26px; font-weight: 300; line-height: 1;
color: var(--accent); background: var(--accent-soft);
}
.slot--empty .label { font-size: 14px; font-weight: 600; letter-spacing: -0.02em; }
.slot--empty .hint { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
.foot-note { font-size: 11px; font-weight: 400; color: var(--fg-tertiary); }
/* -------------------------------------------------------- editor screen */
.screen--editor { pointer-events: none; } /* the ped stays clickable-through */
.screen--editor .panel,
.screen--editor .stagebar { pointer-events: auto; }
.panel {
position: absolute; top: 24px; right: 24px; bottom: 24px;
width: 372px;
display: flex; flex-direction: column;
padding: 24px 0 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--line-strong);
background: var(--surface);
}
.panel-head { padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.panel-title { margin-top: 8px; font-size: 20px; font-weight: 700; letter-spacing: -0.03em; }
.tabs {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 14px 24px; border-bottom: 1px solid var(--line);
}
.tab {
border: none; border-radius: 999px; padding: 7px 13px;
font-family: inherit; font-size: 10px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
transition: color .2s var(--ease), background-color .2s var(--ease);
}
.tab:hover { color: var(--fg); }
.tab.is-on { color: #fff; background: var(--accent); }
.controls { flex: 1; overflow-y: auto; padding: 18px 24px 8px; }
.controls::-webkit-scrollbar { width: 5px; }
.controls::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.14); border-radius: 3px; }
.group-label {
margin: 16px 0 10px;
font-size: 10px; font-weight: 600; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.group-label:first-child { margin-top: 0; }
.ctl { margin-bottom: 13px; }
.ctl-top {
display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px;
}
.ctl-name { font-size: 11px; font-weight: 500; letter-spacing: -0.01em; color: var(--fg); }
.ctl-val { font-size: 10px; font-weight: 600; color: var(--fg-tertiary); font-variant-numeric: tabular-nums; }
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
background: rgba(0, 0, 0, 0.09); cursor: pointer; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
background: #fff; border: 1px solid rgba(0, 0, 0, 0.16);
transition: transform .15s var(--ease), border-color .15s var(--ease);
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.12); border-color: var(--accent); }
/* Two-state toggle (male / female) */
.toggle { display: flex; gap: 6px; margin-bottom: 16px; }
.toggle button {
flex: 1; border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
padding: 10px; font-family: inherit; font-size: 12px; font-weight: 600;
letter-spacing: -0.01em; color: var(--fg-secondary);
background: rgba(255, 255, 255, 0.7); cursor: pointer;
transition: all .2s var(--ease);
}
.toggle button.is-on { color: #fff; background: var(--accent); border-color: var(--accent); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field-label {
display: block; margin-bottom: 6px;
font-size: 10px; font-weight: 600; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--fg-tertiary);
}
.field {
width: 100%; border: 1px solid transparent; border-radius: var(--radius-sm);
padding: 11px 12px; font-family: inherit; font-size: 13px; font-weight: 500;
color: var(--fg); background: var(--field-bg); outline: none;
transition: background-color .2s, border-color .2s;
}
.field::placeholder { color: var(--fg-tertiary); font-weight: 400; }
.field:focus { background: #fff; border-color: var(--accent); }
.notice {
margin: 0 24px; min-height: 0; font-size: 11px; font-weight: 500; color: var(--danger);
opacity: 0; transition: opacity .2s var(--ease);
}
.notice.is-shown { opacity: 1; margin-bottom: 10px; }
.panel-foot {
display: flex; gap: 8px; padding: 16px 24px 0;
border-top: 1px solid var(--line);
}
.panel-foot .btn-primary { flex: 1; }
.btn-dice { padding: 11px 14px; }
/* Camera controls, centred under the ped. */
.stagebar {
position: absolute; left: calc(50% - 210px); bottom: 40px;
transform: translateX(-50%);
display: flex; align-items: center; gap: 10px;
padding: 8px; border-radius: 999px;
border: 1px solid var(--line-strong); background: var(--surface);
}
.round {
width: 34px; height: 34px; border: none; border-radius: 50%;
font-family: inherit; font-size: 17px; font-weight: 500; line-height: 1;
color: var(--fg-secondary); background: var(--field-bg); cursor: pointer;
transition: color .2s, background-color .2s;
}
.round:hover { color: #fff; background: var(--accent); }
.chips { display: flex; gap: 4px; }
.chip {
border: none; border-radius: 999px; padding: 8px 14px;
font-family: inherit; font-size: 10px; font-weight: 600;
letter-spacing: 0.09em; text-transform: uppercase;
color: var(--fg-secondary); background: transparent; cursor: pointer;
transition: color .2s, background-color .2s;
}
.chip:hover { color: var(--fg); }
.chip.is-on { color: #fff; background: var(--accent); }
/* ---------------------------------------------------------------- motion */
.fade-up { animation: fadeUp .6s var(--ease) both; }
.delay-1 { animation-delay: .06s; }
.delay-2 { animation-delay: .12s; }
.delay-3 { animation-delay: .18s; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
transition-duration: .001ms !important;
}
}