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:
co-authored by
Claude Opus 5
parent
71856b15e9
commit
c857979a55
@@ -0,0 +1,391 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const RESOURCE = 'fiverp-auth';
|
||||
|
||||
const stage = document.getElementById('stage');
|
||||
const slab = document.getElementById('slab');
|
||||
const railFill = document.getElementById('railFill');
|
||||
const permit = document.getElementById('permit');
|
||||
const sheen = document.getElementById('sheen');
|
||||
const seal = document.getElementById('seal');
|
||||
|
||||
const cardNo = document.getElementById('cardNo');
|
||||
const cardFirst = document.getElementById('cardFirst');
|
||||
const cardLast = document.getElementById('cardLast');
|
||||
const cardDob = document.getElementById('cardDob');
|
||||
const cardIssue = document.getElementById('cardIssue');
|
||||
const cardExpiry = document.getElementById('cardExpiry');
|
||||
const cardSign = document.getElementById('cardSign');
|
||||
const mrz1 = document.getElementById('mrz1');
|
||||
const mrz2 = document.getElementById('mrz2');
|
||||
const stampDate = document.getElementById('stampDate');
|
||||
|
||||
const views = {
|
||||
login: document.getElementById('formLogin'),
|
||||
account: document.getElementById('formAccount'),
|
||||
identity: document.getElementById('formIdentity')
|
||||
};
|
||||
|
||||
let current = 'login';
|
||||
let busy = false;
|
||||
// Set when sign-in finds an account whose registration never reached step 2.
|
||||
let resuming = false;
|
||||
|
||||
// ------------------------------------------------------------------ post
|
||||
function post(endpoint, data) {
|
||||
return fetch(`https://${RESOURCE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
body: JSON.stringify(data || {})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ views
|
||||
function show(view) {
|
||||
current = view;
|
||||
slab.dataset.view = view;
|
||||
|
||||
Object.entries(views).forEach(([name, form]) => {
|
||||
form.classList.toggle('is-current', name === view);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.rail-step').forEach((step) => {
|
||||
const which = step.dataset.step;
|
||||
step.classList.toggle('is-active', which === view);
|
||||
step.classList.toggle('is-done', which === 'account' && view === 'identity');
|
||||
});
|
||||
railFill.classList.toggle('is-full', view === 'identity');
|
||||
|
||||
const first = views[view].querySelector('input');
|
||||
if (first) setTimeout(() => first.focus(), 240);
|
||||
}
|
||||
|
||||
function notice(view, message, good) {
|
||||
const el = views[view].querySelector('[data-notice]');
|
||||
if (!message) {
|
||||
el.classList.remove('is-shown', 'is-good');
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.classList.add('is-shown');
|
||||
el.classList.toggle('is-good', !!good);
|
||||
}
|
||||
|
||||
function setBusy(view, state) {
|
||||
busy = state;
|
||||
const btn = views[view].querySelector('.btn');
|
||||
btn.classList.toggle('is-busy', state);
|
||||
btn.disabled = state;
|
||||
}
|
||||
|
||||
function values(view) {
|
||||
const out = {};
|
||||
views[view].querySelectorAll('input').forEach((input) => {
|
||||
out[input.name] = input.value.trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function markBad(view, names) {
|
||||
views[view].querySelectorAll('input').forEach((input) => {
|
||||
input.classList.toggle('is-bad', names.includes(input.name));
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================== the passport
|
||||
// Everything on the data page is derived from the name, so the same resident
|
||||
// always gets the same document — it reads as a record, not a random mock-up.
|
||||
|
||||
function seedOf(text) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash;
|
||||
}
|
||||
|
||||
function passportNumber(seed) {
|
||||
return String(seed % 1000000000).padStart(9, '0');
|
||||
}
|
||||
|
||||
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||||
|
||||
function human(date) {
|
||||
return String(date.getDate()).padStart(2, '0') + ' ' +
|
||||
MONTHS[date.getMonth()] + ' ' + date.getFullYear();
|
||||
}
|
||||
|
||||
// MRZ dates are YYMMDD.
|
||||
function mrzDate(date) {
|
||||
return String(date.getFullYear() % 100).padStart(2, '0') +
|
||||
String(date.getMonth() + 1).padStart(2, '0') +
|
||||
String(date.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ICAO 9303 check digit: weights cycle 7-3-1 over the field.
|
||||
function checkDigit(field) {
|
||||
const WEIGHTS = [7, 3, 1];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < field.length; i++) {
|
||||
const c = field[i];
|
||||
let v;
|
||||
if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48;
|
||||
else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55;
|
||||
else v = 0; // '<' filler
|
||||
sum += v * WEIGHTS[i % 3];
|
||||
}
|
||||
return String(sum % 10);
|
||||
}
|
||||
|
||||
function pad(text, length) {
|
||||
return (text + '<'.repeat(length)).slice(0, length);
|
||||
}
|
||||
|
||||
function buildDocument(firstName, lastName) {
|
||||
const seed = seedOf((firstName + lastName).toLowerCase());
|
||||
const number = passportNumber(seed);
|
||||
|
||||
const now = new Date();
|
||||
const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate());
|
||||
// A plausible adult birth date, stable for this name.
|
||||
const dob = new Date(
|
||||
now.getFullYear() - (21 + (seed % 34)),
|
||||
(seed >>> 7) % 12,
|
||||
1 + ((seed >>> 13) % 28)
|
||||
);
|
||||
|
||||
const surname = lastName.toUpperCase();
|
||||
const given = firstName.toUpperCase();
|
||||
|
||||
const line1 = pad('P<USA' + surname + '<<' + given, 44);
|
||||
|
||||
const numCd = checkDigit(number);
|
||||
const dobCd = checkDigit(mrzDate(dob));
|
||||
const expCd = checkDigit(mrzDate(expiry));
|
||||
const personal = '<'.repeat(14);
|
||||
const personalCd = checkDigit(personal);
|
||||
const composite = number + numCd + mrzDate(dob) + dobCd +
|
||||
mrzDate(expiry) + expCd + personal + personalCd;
|
||||
|
||||
const line2 = number + numCd + 'USA' + mrzDate(dob) + dobCd + 'X' +
|
||||
mrzDate(expiry) + expCd + personal + personalCd +
|
||||
checkDigit(composite);
|
||||
|
||||
return { number, issue, expiry, dob, line1, line2 };
|
||||
}
|
||||
|
||||
function printName(el, text) {
|
||||
if (!text) { el.textContent = ''; return; }
|
||||
if (el.textContent === text) return;
|
||||
el.textContent = '';
|
||||
const span = document.createElement('span');
|
||||
span.className = 'printed';
|
||||
span.textContent = text;
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
const BLANK1 = 'P<USA' + '<'.repeat(39);
|
||||
const BLANK2 = '<'.repeat(10) + 'USA' + '<'.repeat(31);
|
||||
|
||||
function fillPassport(firstName, lastName) {
|
||||
printName(cardFirst, firstName.toUpperCase());
|
||||
printName(cardLast, lastName.toUpperCase());
|
||||
|
||||
// Below two letters there is not enough to derive a document from.
|
||||
if (firstName.length < 2 || lastName.length < 2) {
|
||||
cardNo.textContent = '—';
|
||||
cardDob.textContent = '—';
|
||||
cardIssue.textContent = '—';
|
||||
cardExpiry.textContent = '—';
|
||||
cardSign.textContent = '';
|
||||
mrz1.textContent = BLANK1;
|
||||
mrz2.textContent = BLANK2;
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = buildDocument(firstName, lastName);
|
||||
cardNo.textContent = doc.number;
|
||||
cardDob.textContent = human(doc.dob);
|
||||
cardIssue.textContent = human(doc.issue);
|
||||
cardExpiry.textContent = human(doc.expiry);
|
||||
cardSign.textContent = firstName + ' ' + lastName;
|
||||
mrz1.textContent = doc.line1;
|
||||
mrz2.textContent = doc.line2;
|
||||
stampDate.textContent = human(doc.issue);
|
||||
}
|
||||
|
||||
function syncPassport() {
|
||||
const { firstName, lastName } = values('identity');
|
||||
fillPassport(firstName, lastName);
|
||||
}
|
||||
|
||||
function issuePassport() {
|
||||
permit.classList.add('is-issued');
|
||||
seal.classList.add('is-stamped');
|
||||
sheen.style.opacity = '0.85';
|
||||
}
|
||||
|
||||
// Laminate glare tracks the cursor across the page.
|
||||
permit.addEventListener('pointermove', (e) => {
|
||||
const box = permit.getBoundingClientRect();
|
||||
sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`);
|
||||
sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- password meter
|
||||
const pwInput = views.account.querySelector('input[name="password"]');
|
||||
const meter = views.account.querySelector('[data-meter]');
|
||||
|
||||
pwInput.addEventListener('input', () => {
|
||||
const v = pwInput.value;
|
||||
meter.classList.toggle('is-live', v.length > 0);
|
||||
let level = 1;
|
||||
if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2;
|
||||
if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3;
|
||||
meter.dataset.level = String(level);
|
||||
meter.querySelector('i').style.width = `${level * 33.4}%`;
|
||||
});
|
||||
|
||||
views.identity.querySelectorAll('input').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
// Keep the document honest: only English letters ever reach it.
|
||||
input.value = input.value.replace(/[^A-Za-z]/g, '');
|
||||
syncPassport();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ validation
|
||||
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
|
||||
const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/;
|
||||
const RE_NAME = /^[A-Za-z]{2,24}$/;
|
||||
|
||||
function checkAccount(v) {
|
||||
if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']];
|
||||
if (!RE_USERNAME.test(v.username)) return ['Username must be 3–32 characters, letters, numbers or underscore.', ['username']];
|
||||
if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']];
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkIdentity(v) {
|
||||
if (!RE_NAME.test(v.firstName)) return ['First name must be 2–24 English letters.', ['firstName']];
|
||||
if (!RE_NAME.test(v.lastName)) return ['Last name must be 2–24 English letters.', ['lastName']];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- submits
|
||||
views.login.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('login');
|
||||
if (!v.username || !v.password) {
|
||||
markBad('login', [!v.username ? 'username' : 'password']);
|
||||
return notice('login', 'Enter your username and password.');
|
||||
}
|
||||
markBad('login', []);
|
||||
notice('login', '');
|
||||
setBusy('login', true);
|
||||
post('login', v);
|
||||
});
|
||||
|
||||
views.account.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('account');
|
||||
const bad = checkAccount(v);
|
||||
if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); }
|
||||
markBad('account', []);
|
||||
notice('account', '');
|
||||
setBusy('account', true);
|
||||
post('registerAccount', v);
|
||||
});
|
||||
|
||||
views.identity.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const v = values('identity');
|
||||
const bad = checkIdentity(v);
|
||||
if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); }
|
||||
markBad('identity', []);
|
||||
notice('identity', '');
|
||||
setBusy('identity', true);
|
||||
post(resuming ? 'resumeIdentity' : 'registerIdentity', v);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-goto]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (busy) return;
|
||||
notice(current, '');
|
||||
show(btn.dataset.goto);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ messages from Lua
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data || {};
|
||||
const p = msg.payload || {};
|
||||
|
||||
switch (msg.action) {
|
||||
case 'open':
|
||||
stage.classList.add('is-open');
|
||||
stage.setAttribute('aria-hidden', 'false');
|
||||
show('login');
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
stage.classList.remove('is-open');
|
||||
stage.setAttribute('aria-hidden', 'true');
|
||||
break;
|
||||
|
||||
case 'accountResult':
|
||||
setBusy('account', false);
|
||||
if (p.ok) {
|
||||
notice('account', '');
|
||||
show('identity');
|
||||
syncPassport();
|
||||
} else {
|
||||
notice('account', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'identityResult':
|
||||
setBusy('identity', false);
|
||||
if (p.ok) {
|
||||
fillPassport(String(p.firstName), String(p.lastName));
|
||||
issuePassport();
|
||||
notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true);
|
||||
views.identity.querySelector('.btn span').textContent = 'Entering the city…';
|
||||
views.identity.querySelector('.btn').disabled = true;
|
||||
// Nothing left to go back to once the document exists.
|
||||
views.identity.querySelector('.switch').style.display = 'none';
|
||||
} else {
|
||||
if (p.reset) { resuming = false; show('account'); }
|
||||
notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'loginResult':
|
||||
setBusy('login', false);
|
||||
if (p.ok && p.needsIdentity) {
|
||||
// Account is real but has no resident yet — finish step 2.
|
||||
resuming = true;
|
||||
show('identity');
|
||||
syncPassport();
|
||||
notice('identity', 'Your file is open. Name your resident to finish.', true);
|
||||
} else if (p.ok) {
|
||||
notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true);
|
||||
views.login.querySelector('.btn span').textContent = 'Entering the city…';
|
||||
views.login.querySelector('.btn').disabled = true;
|
||||
} else {
|
||||
markBad('login', ['username', 'password']);
|
||||
notice('login', p.error || 'Something went wrong.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the error ring as soon as the player starts fixing the field.
|
||||
document.querySelectorAll('input').forEach((input) => {
|
||||
input.addEventListener('input', () => input.classList.remove('is-bad'));
|
||||
});
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FiveRP — Passport Office</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage" id="stage" aria-hidden="true">
|
||||
<section class="slab" id="slab" data-view="login">
|
||||
|
||||
<!-- ------------------------------------------------------------- form -->
|
||||
<div class="pane pane-form">
|
||||
<p class="eyebrow">Los Santos <span class="dot"></span> Passport Office</p>
|
||||
|
||||
<div class="rail" id="rail" role="list">
|
||||
<div class="rail-step is-active" data-step="account" role="listitem">
|
||||
<span class="rail-mark"></span>
|
||||
<span class="rail-label">Account</span>
|
||||
</div>
|
||||
<span class="rail-line"><i id="railFill"></i></span>
|
||||
<div class="rail-step" data-step="identity" role="listitem">
|
||||
<span class="rail-mark"></span>
|
||||
<span class="rail-label">Identity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign in -->
|
||||
<form class="view" data-view="login" id="formLogin" novalidate>
|
||||
<h1 class="title">Welcome back</h1>
|
||||
<p class="sub">Sign in to return to the city.</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Username</span>
|
||||
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" placeholder="••••••••">
|
||||
</label>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Sign in</span></button>
|
||||
|
||||
<p class="switch">New to Los Santos? <button type="button" class="link" data-goto="account">Create an account</button></p>
|
||||
</form>
|
||||
|
||||
<!-- Register step 1 -->
|
||||
<form class="view" data-view="account" id="formAccount" novalidate>
|
||||
<h1 class="title">Open your file</h1>
|
||||
<p class="sub">The registry keeps this private. It is how you sign back in.</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Email</span>
|
||||
<input type="email" name="email" autocomplete="email" spellcheck="false" placeholder="you@example.com">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Username</span>
|
||||
<input type="text" name="username" autocomplete="username" spellcheck="false" placeholder="marcus_r">
|
||||
<span class="hint">3–32 characters. Letters, numbers, underscore.</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Password</span>
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="At least 8 characters">
|
||||
<span class="pwmeter" data-meter><i></i></span>
|
||||
</label>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Continue</span></button>
|
||||
|
||||
<p class="switch">Already registered? <button type="button" class="link" data-goto="login">Sign in</button></p>
|
||||
</form>
|
||||
|
||||
<!-- Register step 2 -->
|
||||
<form class="view" data-view="identity" id="formIdentity" novalidate>
|
||||
<h1 class="title">Name your resident</h1>
|
||||
<p class="sub">This is the name the city will know you by. English letters only.</p>
|
||||
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span class="label">First name</span>
|
||||
<input type="text" name="firstName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Marcus">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">Last name</span>
|
||||
<input type="text" name="lastName" autocomplete="off" spellcheck="false" maxlength="24" placeholder="Reyes">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="notice" data-notice></p>
|
||||
<button class="btn" type="submit"><span>Issue passport</span></button>
|
||||
|
||||
<p class="switch"><button type="button" class="link" data-goto="account">Back to account details</button></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------- passport -->
|
||||
<div class="pane pane-card" id="pane-card">
|
||||
<div class="passport" id="permit">
|
||||
<div class="pp-guilloche" aria-hidden="true"></div>
|
||||
<div class="pp-sheen" id="sheen" aria-hidden="true"></div>
|
||||
|
||||
<header class="pp-head">
|
||||
<span class="pp-seal" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64">
|
||||
<circle class="ring-out" cx="32" cy="32" r="30"/>
|
||||
<circle class="ring-in" cx="32" cy="32" r="26"/>
|
||||
<!-- 13 stars, arced over the shield -->
|
||||
<g class="stars">
|
||||
<circle cx="32" cy="14" r="1.5"/>
|
||||
<circle cx="25" cy="15.3" r="1.5"/><circle cx="39" cy="15.3" r="1.5"/>
|
||||
<circle cx="19" cy="19" r="1.5"/><circle cx="45" cy="19" r="1.5"/>
|
||||
<circle cx="15" cy="25" r="1.5"/><circle cx="49" cy="25" r="1.5"/>
|
||||
</g>
|
||||
<!-- shield: blue chief over 13 pales -->
|
||||
<path class="shield-chief" d="M17 28h30v7H17z"/>
|
||||
<g class="shield-pales">
|
||||
<path d="M17 35h3v9.5c0 .6.1 1.2.2 1.8H17z"/>
|
||||
<path d="M23 35h3v13h-3z"/>
|
||||
<path d="M29 35h3v13h-3z"/>
|
||||
<path d="M35 35h3v13h-3z"/>
|
||||
<path d="M41 35h3v13h-3z"/>
|
||||
</g>
|
||||
<path class="shield-edge" d="M17 28h30v16.5c0 5.5-6.5 10.5-15 13.5-8.5-3-15-8-15-13.5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="pp-head-text">
|
||||
<span class="pp-kind">Passport</span>
|
||||
<span class="pp-country">United States of America</span>
|
||||
</span>
|
||||
<span class="pp-chip" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 16">
|
||||
<rect x="0.6" y="0.6" width="18.8" height="14.8" rx="2.4"/>
|
||||
<path d="M7 .6v15M13 .6v15M.6 5.4h18.8M.6 10.6h18.8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="pp-body">
|
||||
<div class="pp-left">
|
||||
<div class="pp-photo">
|
||||
<svg class="pp-bust" viewBox="0 0 48 60" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="bust" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#9aa7b8"/>
|
||||
<stop offset="1" stop-color="#5d6b7e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="24" cy="18" r="11" fill="url(#bust)"/>
|
||||
<path d="M3 60c0-11.5 9.4-20 21-20s21 8.5 21 20z" fill="url(#bust)"/>
|
||||
</svg>
|
||||
<span class="pp-photo-label">Photo</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-sign">
|
||||
<span class="pp-sign-ink" id="cardSign"></span>
|
||||
<span class="pp-sign-rule"></span>
|
||||
<span class="pp-sign-label">Signature</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="pp-fields">
|
||||
<div class="pp-f pp-f--type"><dt>Type</dt><dd>P</dd></div>
|
||||
<div class="pp-f pp-f--code"><dt>Code</dt><dd>USA</dd></div>
|
||||
<div class="pp-f pp-f--no"><dt>Passport No.</dt><dd id="cardNo" class="mono">—</dd></div>
|
||||
|
||||
<div class="pp-f pp-f--wide"><dt>Surname</dt>
|
||||
<dd class="pp-name" id="cardLast" data-empty="—"></dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Given names</dt>
|
||||
<dd class="pp-name" id="cardFirst" data-empty="—"></dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Nationality</dt>
|
||||
<dd>United States of America</dd></div>
|
||||
|
||||
<div class="pp-f"><dt>Date of birth</dt><dd class="mono" id="cardDob">—</dd></div>
|
||||
<div class="pp-f"><dt>Sex</dt><dd>X</dd></div>
|
||||
<div class="pp-f pp-f--wide"><dt>Place of birth</dt><dd>Los Santos, San Andreas</dd></div>
|
||||
<div class="pp-f"><dt>Date of issue</dt><dd class="mono" id="cardIssue">—</dd></div>
|
||||
<div class="pp-f"><dt>Date of expiration</dt><dd class="mono" id="cardExpiry">—</dd></div>
|
||||
|
||||
<!-- Spans 4 of 6 columns, leaving the bottom-right corner clear so
|
||||
the entry stamp lands on paper rather than on top of data. -->
|
||||
<div class="pp-f pp-f--auth"><dt>Authority</dt>
|
||||
<dd>United States Department of State</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="pp-ghost" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 60">
|
||||
<circle cx="24" cy="20" r="12"/>
|
||||
<path d="M2 60c0-12 10-21 22-21s22 9 22 21z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-mrz mono">
|
||||
<span id="mrz1">P<USA<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<</span>
|
||||
<span id="mrz2"><<<<<<<<<<USA<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<</span>
|
||||
</div>
|
||||
|
||||
<div class="pp-stamp" id="seal" aria-hidden="true">
|
||||
<span class="pp-stamp-top">Admitted</span>
|
||||
<span class="pp-stamp-mid">Los Santos</span>
|
||||
<span class="pp-stamp-bot" id="stampDate">— — —</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pp-caption">Issued once. The city will know you by this name.</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,727 @@
|
||||
/* FiveRP — passport office (registration + sign in).
|
||||
Design system: /opt/fivem/server-data/CLAUDE.md
|
||||
|
||||
Deliberately zero box-shadow on panels. CEF composites the shadow of a
|
||||
backdrop-filtered element as an opaque rectangle, which is what put a black
|
||||
square behind this panel in game. Depth here comes from blur, hairlines and
|
||||
inner highlights only. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
|
||||
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
|
||||
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Unbounded';
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: block;
|
||||
src: url('fonts/unbounded-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--panel: rgba(255, 255, 255, 0.80);
|
||||
--field-bg: rgba(0, 0, 0, 0.04);
|
||||
--field-bg-focus:#ffffff;
|
||||
|
||||
--fg: #1d1d1f;
|
||||
--fg-secondary: #6e6e73;
|
||||
--fg-tertiary: #a1a1a6;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-strong: #0077ed;
|
||||
--accent-soft: rgba(0, 113, 227, 0.08);
|
||||
--ring: rgba(0, 113, 227, 0.18);
|
||||
|
||||
--line: rgba(0, 0, 0, 0.06);
|
||||
--line-strong: rgba(0, 0, 0, 0.10);
|
||||
--hover: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--success: #34c759;
|
||||
--danger: #ff3b30;
|
||||
|
||||
--radius-lg: 1.5rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-sm: 0.7rem;
|
||||
|
||||
/* passport ink */
|
||||
--pp-navy: #10315e;
|
||||
--pp-label: #46719f;
|
||||
--pp-value: #14202f;
|
||||
|
||||
--sans: 'Unbounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas,
|
||||
'Liberation Mono', 'DejaVu Sans Mono', monospace;
|
||||
--ease: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent; /* the game is behind this page */
|
||||
overflow: hidden;
|
||||
font-family: var(--sans);
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- stage */
|
||||
.stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 460ms var(--ease), visibility 0s linear 460ms;
|
||||
/* Lightening only. Never darken the game to make the panel readable —
|
||||
that is what produced the grey box before. */
|
||||
background: radial-gradient(90% 70% at 50% 50%,
|
||||
rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 72%);
|
||||
}
|
||||
.stage.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 460ms var(--ease), visibility 0s;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- slab */
|
||||
.slab {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 520px;
|
||||
width: min(1080px, 94vw);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
opacity: 0;
|
||||
transition:
|
||||
width 620ms var(--ease),
|
||||
grid-template-columns 620ms var(--ease),
|
||||
transform 620ms var(--ease),
|
||||
opacity 480ms var(--ease);
|
||||
}
|
||||
.stage.is-open .slab { transform: none; opacity: 1; }
|
||||
|
||||
/* Signing in needs no passport — collapse to a single column. */
|
||||
.slab[data-view="login"] {
|
||||
width: min(460px, 94vw);
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
.slab[data-view="login"] .pane-card {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.pane { min-width: 0; }
|
||||
|
||||
.pane-form {
|
||||
padding: 40px 40px 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-card {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.14));
|
||||
transition: opacity 420ms var(--ease), transform 620ms var(--ease);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- eyebrow */
|
||||
.eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.20em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.eyebrow .dot {
|
||||
width: 3px; height: 3px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- rail */
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 360ms var(--ease);
|
||||
}
|
||||
/* Signing in has no steps, so the rail collapses margin and all. */
|
||||
.slab[data-view="account"] .rail,
|
||||
.slab[data-view="identity"] .rail { opacity: 1; height: auto; margin-bottom: 30px; }
|
||||
|
||||
.rail-step { display: flex; align-items: center; gap: 8px; }
|
||||
.rail-mark {
|
||||
position: relative;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--line-strong);
|
||||
background: transparent;
|
||||
transition: background-color .3s var(--ease), border-color .3s var(--ease);
|
||||
}
|
||||
.rail-step.is-active .rail-mark {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
.rail-step.is-done .rail-mark {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
/* Done and active must not look identical — done gets a tick. */
|
||||
.rail-step.is-done .rail-mark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2.4px; top: 1px;
|
||||
width: 3px; height: 5.5px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
.rail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-tertiary);
|
||||
transition: color .3s var(--ease);
|
||||
}
|
||||
.rail-step.is-active .rail-label,
|
||||
.rail-step.is-done .rail-label { color: var(--fg-secondary); }
|
||||
|
||||
.rail-line {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 1.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--line-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-line i {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width 560ms var(--ease);
|
||||
}
|
||||
.rail-line i.is-full { width: 100%; }
|
||||
|
||||
/* ------------------------------------------------------------------- views */
|
||||
.view {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view.is-current { display: flex; animation: viewIn .5s var(--ease) both; }
|
||||
|
||||
@keyframes viewIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.1;
|
||||
color: var(--fg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ fields */
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 13px 14px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--fg);
|
||||
background: var(--field-bg);
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
transition: background-color .2s, border-color .2s, box-shadow .2s;
|
||||
}
|
||||
.field input::placeholder { color: var(--fg-tertiary); }
|
||||
.field input:focus {
|
||||
background: var(--field-bg-focus);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field input.is-bad {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-tertiary);
|
||||
}
|
||||
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.row .field { margin-bottom: 16px; }
|
||||
|
||||
/* password strength */
|
||||
.pwmeter {
|
||||
display: block;
|
||||
height: 3px;
|
||||
margin-top: 9px;
|
||||
border-radius: 2px;
|
||||
background: var(--line);
|
||||
opacity: 0;
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
.pwmeter.is-live { opacity: 1; }
|
||||
.pwmeter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger);
|
||||
transition: width .3s var(--ease), background-color .3s ease;
|
||||
}
|
||||
.pwmeter[data-level="2"] i { background: #ff9f0a; }
|
||||
.pwmeter[data-level="3"] i { background: var(--success); }
|
||||
|
||||
/* ------------------------------------------------------------------ notice */
|
||||
.notice {
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--danger);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height .3s var(--ease), opacity .3s ease, margin .3s var(--ease);
|
||||
}
|
||||
.notice.is-shown {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.notice.is-good { color: #1f8f3d; }
|
||||
|
||||
/* ----------------------------------------------------------------- buttons */
|
||||
.btn {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 14px 24px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background-image: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
transition: filter .2s ease, transform .12s var(--ease), opacity .2s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.btn:active:not(:disabled) { transform: scale(0.978); }
|
||||
.btn:disabled { opacity: .55; cursor: default; }
|
||||
|
||||
.btn.is-busy span { opacity: 0; }
|
||||
.btn.is-busy::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 16px; height: 16px;
|
||||
margin: -8px 0 0 -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.switch {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
/* ==================================================================== PASSPORT
|
||||
A United States passport data page. This is the one saturated object on the
|
||||
screen; the form beside it stays neutral so the document reads as the hero. */
|
||||
|
||||
.passport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 125 / 88; /* ID-3, the real bio-page proportion */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
padding: 11px 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--pp-value);
|
||||
background:
|
||||
radial-gradient(78% 66% at 16% 14%, rgba(255,255,255,0.92), rgba(255,255,255,0) 62%),
|
||||
linear-gradient(155deg, #f8fbff 0%, #e9f1fa 46%, #f5f1e7 100%);
|
||||
border: 1px solid rgba(16, 49, 94, 0.16);
|
||||
transition: transform 700ms var(--ease);
|
||||
}
|
||||
.passport.is-issued { transform: translateY(-3px); }
|
||||
|
||||
/* Engine-turned security print. Three cheap repeating gradients read
|
||||
convincingly as guilloche at this scale. */
|
||||
.pp-guilloche {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .55;
|
||||
background:
|
||||
repeating-radial-gradient(circle at 20% 44%, rgba(16,49,94,.055) 0 1px, transparent 1px 7px),
|
||||
repeating-radial-gradient(circle at 80% 62%, rgba(16,49,94,.05) 0 1px, transparent 1px 9px),
|
||||
repeating-linear-gradient(58deg, rgba(16,49,94,.032) 0 1px, transparent 1px 6px);
|
||||
}
|
||||
|
||||
/* Laminate glare that follows the cursor. */
|
||||
.pp-sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .55;
|
||||
background: radial-gradient(circle at var(--mx, 78%) var(--my, 10%),
|
||||
rgba(255,255,255,0.75) 0%, rgba(255,255,255,0) 46%);
|
||||
transition: opacity .5s ease;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ passport head */
|
||||
.pp-head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px solid rgba(16, 49, 94, 0.18);
|
||||
}
|
||||
.pp-seal { flex: none; width: 30px; height: 30px; display: block; }
|
||||
.pp-seal svg { width: 100%; height: 100%; display: block; }
|
||||
.pp-seal .ring-out,
|
||||
.pp-seal .ring-in { fill: none; stroke: rgba(16,49,94,.55); stroke-width: 1.1; }
|
||||
.pp-seal .ring-in { stroke-width: .6; }
|
||||
.pp-seal .stars { fill: rgba(16,49,94,.6); }
|
||||
.pp-seal .shield-chief { fill: rgba(16,49,94,.75); }
|
||||
.pp-seal .shield-pales { fill: rgba(16,49,94,.28); }
|
||||
.pp-seal .shield-edge { fill: none; stroke: rgba(16,49,94,.65); stroke-width: 1.2; }
|
||||
|
||||
.pp-head-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.pp-kind {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.30em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-navy);
|
||||
}
|
||||
.pp-country {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
}
|
||||
.pp-chip { margin-left: auto; width: 19px; height: 15px; opacity: .6; }
|
||||
.pp-chip svg { width: 100%; height: 100%; fill: none; stroke: rgba(16,49,94,.6); stroke-width: 1; }
|
||||
|
||||
/* ------------------------------------------------------------ passport body */
|
||||
.pp-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.pp-left { display: flex; flex-direction: column; gap: 9px; }
|
||||
|
||||
.pp-photo {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
border: 1px solid rgba(16, 49, 94, 0.22);
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, #eef2f7, #dde5ee);
|
||||
aspect-ratio: 35 / 45; /* passport photo proportion */
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
}
|
||||
.pp-bust { width: 76%; height: 88%; align-self: end; }
|
||||
.pp-photo-label {
|
||||
position: absolute;
|
||||
top: 4px; left: 5px;
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(16, 49, 94, 0.42);
|
||||
}
|
||||
|
||||
/* The secondary faded portrait real passports carry to the right of the data. */
|
||||
.pp-ghost {
|
||||
position: absolute;
|
||||
right: 2px; top: 34%;
|
||||
width: 50px;
|
||||
opacity: .1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-ghost svg { width: 100%; display: block; fill: var(--pp-navy); }
|
||||
|
||||
.pp-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 4px 10px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.pp-f { grid-column: span 2; min-width: 0; }
|
||||
.pp-f--type { grid-column: span 1; }
|
||||
.pp-f--code { grid-column: span 1; }
|
||||
.pp-f--no { grid-column: span 4; }
|
||||
.pp-f--wide { grid-column: span 6; }
|
||||
|
||||
.pp-f dt {
|
||||
font-size: 6.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pp-f dd {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--pp-value);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pp-f dd.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.pp-f--auth { grid-column: span 4; }
|
||||
.pp-f--auth dd { font-size: 8.5px; font-weight: 400; color: var(--pp-label); }
|
||||
|
||||
/* The two fields the player actually controls — set larger than the rest. */
|
||||
.pp-name {
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.25 !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-navy) !important;
|
||||
min-height: 19px;
|
||||
}
|
||||
.pp-name:empty::before {
|
||||
content: attr(data-empty);
|
||||
color: rgba(16, 49, 94, 0.25);
|
||||
}
|
||||
/* Each keystroke re-prints the value; the fade makes it feel typeset. */
|
||||
.printed { display: inline-block; animation: printIn .4s var(--ease) both; }
|
||||
@keyframes printIn {
|
||||
from { opacity: 0; transform: translateY(3px); filter: blur(3px); }
|
||||
to { opacity: 1; transform: none; filter: blur(0); }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- signature */
|
||||
.pp-sign {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pp-sign-ink {
|
||||
font-family: 'Segoe Script', 'Bradley Hand', 'Snell Roundhand', cursive;
|
||||
font-size: 11px;
|
||||
color: rgba(16, 49, 94, 0.75);
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transform: rotate(-1.5deg);
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
.pp-sign-rule { height: 1px; background: rgba(16, 49, 94, 0.28); }
|
||||
.pp-sign-label {
|
||||
font-size: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pp-label);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- MRZ */
|
||||
.pp-mrz {
|
||||
margin: 6px -15px 0;
|
||||
margin-top: auto; /* pinned to the foot of the page */
|
||||
padding: 6px 15px 7px;
|
||||
border-top: 1px solid rgba(16, 49, 94, 0.18);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.pp-mrz span {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.055em;
|
||||
color: var(--pp-navy);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- stamp */
|
||||
.pp-stamp {
|
||||
position: absolute;
|
||||
right: 13px; bottom: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 7px 13px;
|
||||
border: 2px solid rgba(176, 42, 42, 0.62);
|
||||
border-radius: 4px;
|
||||
color: rgba(176, 42, 42, 0.78);
|
||||
/* multiply lets the guilloche show through, like real stamp ink */
|
||||
mix-blend-mode: multiply;
|
||||
transform: rotate(-13deg) scale(1.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 420ms var(--ease), transform 520ms cubic-bezier(.2,1.4,.4,1);
|
||||
}
|
||||
.pp-stamp.is-stamped { opacity: 1; transform: rotate(-13deg) scale(1); }
|
||||
|
||||
.pp-stamp-top, .pp-stamp-bot {
|
||||
font-size: 6.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pp-stamp-mid {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pp-caption {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--fg-tertiary);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
|
||||
::selection { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
|
||||
/* ---------------------------------------------------------------- NUI final
|
||||
CEF turns backdrop-filter and box-shadow into opaque black plates at the
|
||||
element bounds, so both are gone from this file entirely. Panels earn their
|
||||
separation from opacity and hairlines instead of blur, which means the
|
||||
surface has to carry more white than it did when a blur sat behind it.
|
||||
Appended last so it wins over anything above. */
|
||||
:root {
|
||||
--surface: rgba(255, 255, 255, 0.95);
|
||||
--panel: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
.slab {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.stage { background: none; }
|
||||
.field:focus,
|
||||
.field:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
Reference in New Issue
Block a user