Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created February 28, 2026 21:58
Show Gist options
  • Select an option

  • Save EncodeTheCode/2966b0564cdcc20655863365be8244a4 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/2966b0564cdcc20655863365be8244a4 to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mock ID — Names Letters Only + Editable Meta (Demo)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=Roboto+Slab:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--card-w: 820px;
--card-h: 420px;
--muted: #5b6b70;
--accent: #0a6fb1;
--photo-w: 140px;
--photo-h: 150px;
--padding: 20px;
}
*{box-sizing:border-box}
body{
font-family: 'Inter', system-ui, Roboto, Arial;
margin:0;
min-height:100vh;
display:flex;
align-items:center;
justify-content:center;
background: linear-gradient(180deg,#f6fbff 0%, #eef6fb 100%);
padding:28px;
color:#07202a;
}
.container{ display:flex; gap:28px; align-items:flex-start; max-width:1400px; width:100%; justify-content:center; flex-wrap:wrap; }
/* Card */
.id-card{ width:var(--card-w); height:var(--card-h); border-radius:12px; padding:var(--padding); position:relative;
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(245,250,255,0.94));
box-shadow: 0 14px 44px rgba(8,30,40,0.10); border:1px solid rgba(6,32,40,0.06); overflow:hidden; }
.id-card::after{
content:"DEMO — NOT A GOVERNMENT DOCUMENT";
position:absolute; left:-6%; top:54%; transform:rotate(-22deg); font-weight:900; font-size:26px;
color:rgba(7,32,40,0.05); letter-spacing:6px; pointer-events:none; white-space:nowrap;
}
.id-top{ display:flex; align-items:center; gap:12px; }
.brand{ width:110px; height:48px; border-radius:6px; background:linear-gradient(90deg,#0a6fb1,#39a7d8);
color:white; display:flex; align-items:center; justify-content:center; font-weight:800; letter-spacing:1px; font-size:13px; }
.title{ font-family: 'Roboto Slab', serif; font-size:18px; margin:0; }
.subtitle{ font-size:12px; color:var(--muted); margin-top:4px; }
.main{ display:flex; gap:18px; margin-top:16px; align-items:flex-start; padding-right: calc(var(--photo-w) + 36px); }
.fields{ flex:1; display:flex; flex-direction:column; gap:12px; }
.label{ font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.6px; margin-bottom:6px; }
.name-row{ display:flex; gap:12px; align-items:center; }
.name-field{
min-height:52px; border-radius:6px; padding:10px 12px; font-family:'Roboto Slab', serif; font-size:20px; font-weight:700;
color:#05202a; line-height:1.05; outline:none; background:rgba(10,107,177,0.02); border:1px solid rgba(6,32,40,0.045);
cursor:text; user-select:text; white-space:nowrap; overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; text-overflow:clip;
}
.given{ flex: 0 0 46ch; max-width:70%; } /* visually large to show 30 chars */
.family{ flex: 1 1 auto; min-width:12ch; }
.name-field[contenteditable="true"]:empty::before{ content:attr(data-placeholder); color:var(--muted); font-weight:400; }
.meta-row{ margin-top:8px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
/* meta editable chips */
.meta-chip{
padding:8px 12px; border-radius:8px; font-size:13px; color:var(--muted); border:1px solid rgba(6,32,40,0.04);
min-width:110px; text-align:center; background:rgba(250,250,250,0.6);
}
.meta-chip[contenteditable="true"]{ background: rgba(10,107,177,0.03); cursor:text; }
.photo { width:var(--photo-w); height:var(--photo-h); border-radius:6px; background:linear-gradient(180deg,#f0f7fb,#dfeff8);
border:1px dashed rgba(6,32,40,0.06); display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:12px;
position:absolute; right:var(--padding); bottom:var(--padding); box-shadow: 0 6px 18px rgba(12,40,60,0.03); z-index:10; }
/* Controls */
.controls{ width:360px; display:flex; flex-direction:column; gap:12px; }
.hidden-input{ width:100%; padding:10px 12px; border-radius:8px; border:1px solid rgba(6,32,40,0.06); font-size:15px; box-sizing:border-box; }
.hidden-input[type="date"]{ padding:8px 12px; }
.counter{ font-size:13px; color:var(--muted); text-align:right; }
.note{ font-size:12px; color:#7a8a90; }
button{ padding:10px 12px; border-radius:8px; border:1px solid rgba(6,32,40,0.06); background:white; cursor:pointer; font-size:14px; }
.name-field::-webkit-scrollbar { height:8px; }
.name-field::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.08); border-radius:8px; }
</style>
</head>
<body>
<div class="container" role="main">
<section class="id-card" aria-label="Sample identification card (mock)">
<div class="id-top">
<div class="brand" aria-hidden="true">ID DEMO</div>
<div style="flex:1;">
<h3 class="title">Sample Identification</h3>
<div class="subtitle">Prototype — clearly labeled sample (not official)</div>
</div>
</div>
<div class="main" style="align-items:flex-start;">
<div class="fields">
<div class="label">Name</div>
<div class="name-row" aria-hidden="false">
<div id="givenNames" class="name-field given" contenteditable="true" role="textbox"
aria-label="Given names editable" spellcheck="false" data-placeholder="Given names">Example</div>
<div id="familyName" class="name-field family" contenteditable="true" role="textbox"
aria-label="Family name editable" spellcheck="false" data-placeholder="Family name">Person</div>
</div>
<div class="meta-row" style="margin-top:14px;">
<div>
<div class="label" style="margin:0 0 6px 0;">ID no.</div>
<div id="idNumber" class="meta-chip" contenteditable="true" role="textbox" aria-label="ID number editable">DEMO-000-000</div>
</div>
<div>
<div class="label" style="margin:0 0 6px 0;">DOB</div>
<div id="dobCard" class="meta-chip" contenteditable="true" role="textbox" aria-label="Date of birth editable">YYYY-MM-DD</div>
</div>
<div>
<div class="label" style="margin:0 0 6px 0;">Expires</div>
<div id="expCard" class="meta-chip" contenteditable="true" role="textbox" aria-label="Expiration date editable">YYYY-MM-DD</div>
</div>
</div>
</div>
</div>
<div class="photo" aria-hidden="true">Photo<br>Placeholder</div>
<div style="position:absolute; right:22px; bottom:calc(var(--photo-h) + 32px); font-size:12px; color:rgba(7,32,40,0.28);">
DEMO • NOT A GOVERNMENT DOCUMENT
</div>
</section>
<!-- Controls -->
<aside class="controls" aria-label="Controls for editing">
<div>
<div class="label" style="margin:0 0 6px 0;">Edit names and meta (click card or use inputs)</div>
<!-- name mirrors -->
<input id="givenInput" class="hidden-input" type="text" maxlength="30" aria-label="Given names input" placeholder="Given names (letters & spaces only, max 30)" />
<input id="familyInput" class="hidden-input" type="text" maxlength="30" aria-label="Family name input" placeholder="Family name (letters & spaces only, max 30)" style="margin-top:8px" />
<!-- editable meta -->
<input id="idInput" class="hidden-input" type="text" aria-label="ID number input" placeholder="ID number (any chars allowed)" style="margin-top:8px" />
<label style="font-size:12px;color:var(--muted);margin-top:8px">Date of birth</label>
<input id="dobInput" class="hidden-input" type="date" aria-label="Date of birth input" />
<label style="font-size:12px;color:var(--muted);margin-top:8px">Expires</label>
<input id="expInput" class="hidden-input" type="date" aria-label="Expiration date input" />
<div class="counter" id="counter">Given: 0/30 • Family: 0/30</div>
<div class="note" style="margin-top:8px;">Name fields accept **letters and spaces only**. No digits/symbols. Dates & ID no. are editable below.</div>
</div>
<div>
<button id="resetBtn">Reset example</button>
</div>
</aside>
</div>
<script>
/******************************
* Configuration
******************************/
const GIVEN_MAX = 30;
const FAMILY_MAX = 30;
/* Elements */
const givenEl = document.getElementById('givenNames');
const familyEl = document.getElementById('familyName');
const givenInput = document.getElementById('givenInput');
const familyInput = document.getElementById('familyInput');
const counter = document.getElementById('counter');
const idCard = document.getElementById('idNumber');
const idInput = document.getElementById('idInput');
const dobCard = document.getElementById('dobCard');
const expCard = document.getElementById('expCard');
const dobInput = document.getElementById('dobInput');
const expInput = document.getElementById('expInput');
const resetBtn = document.getElementById('resetBtn');
/******************************
* Helpers: character rules
******************************/
// Allow Unicode letters and space only (no numbers, no punctuation)
// Use Unicode property \p{L} to allow letters from any language, with the 'u' flag.
const allowedNameRegex = /^\p{L}+(?:[ ]\p{L}+)*$/u; // full-match for validation of full string (one or more names separated by single spaces)
// For incremental checks we'll test proposed insert text: must contain only letters or spaces
const allowedInsertedCharsRegex = /^[\p{L} ]+$/u;
function collapseSpacesPreserveEdges(s){
return (s || '').replace(/\u00A0/g, ' ').replace(/\s+/g, ' ');
}
function collapseAndTrim(s){
return collapseSpacesPreserveEdges(s).trim();
}
function visibleLength(s){
return collapseAndTrim(s).length;
}
function updateCounter(){
const givLen = visibleLength(givenEl.innerText);
const famLen = visibleLength(familyEl.innerText);
counter.textContent = `Given: ${givLen}/${GIVEN_MAX} • Family: ${famLen}/${FAMILY_MAX}`;
counter.style.color = (givLen > GIVEN_MAX || famLen > FAMILY_MAX) ? 'crimson' : '';
}
/******************************
* Selection / caret helpers
******************************/
function getSelectionRangeWithin(el){
const sel = window.getSelection();
if (!sel.rangeCount) return { start:0, end:0 };
const range = sel.getRangeAt(0);
const pre = range.cloneRange();
pre.selectNodeContents(el);
pre.setEnd(range.startContainer, range.startOffset);
const start = pre.toString().length;
const pre2 = range.cloneRange();
pre2.selectNodeContents(el);
pre2.setEnd(range.endContainer, range.endOffset);
const end = pre2.toString().length;
return { start, end };
}
function setCaretAt(el, chars){
if (chars < 0) chars = 0;
el.focus();
const range = document.createRange();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
let node;
let charsLeft = chars;
let found = false;
while ((node = walker.nextNode())) {
if (node.nodeValue.length >= charsLeft){
range.setStart(node, charsLeft);
range.collapse(true);
found = true;
break;
} else {
charsLeft -= node.nodeValue.length;
}
}
if (!found){
range.selectNodeContents(el);
range.collapse(false);
}
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function insertTextAtCaret(el, text){
const sel = window.getSelection();
if (!sel.rangeCount){
el.focus();
el.innerText = (el.innerText || '') + text;
setCaretAt(el, (el.innerText || '').length);
return;
}
const range = sel.getRangeAt(0);
if (!el.contains(range.commonAncestorContainer)){
setCaretAt(el, (el.innerText || '').length);
}
const r = window.getSelection().getRangeAt(0);
r.deleteContents();
const node = document.createTextNode(text);
r.insertNode(node);
r.setStartAfter(node);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
}
/******************************
* Name field handlers
******************************/
// beforeinput: check insertion (single characters or insertion text) for allowed chars and max length
function nameBeforeInput(e, ownEl, ownMax){
// Only intercept insertions with data (typing and some composition events)
if (!e.inputType) return;
if (e.inputType.startsWith('insert') && e.data !== null){
const inserted = e.data;
// allow composition sequences (insertCompositionText) to pass through; final validation occurs in input
if (e.inputType === 'insertCompositionText') return;
// If inserted contains disallowed characters -> block
if (!allowedInsertedCharsRegex.test(inserted)){
e.preventDefault();
return;
}
// Check length with selection replacement taken into account
const { start, end } = getSelectionRangeWithin(ownEl);
const current = ownEl.innerText || '';
// proposed visible string (collapse & trim for counting)
const proposed = current.slice(0, start) + inserted + current.slice(end);
if (visibleLength(proposed) > ownMax){
e.preventDefault();
return;
}
}
// other inputTypes allowed; final validation will happen on input event
}
// paste: filter allowed chars and insert up to remaining length
function namePaste(e, ownEl, ownMax){
e.preventDefault();
const raw = (e.clipboardData || window.clipboardData).getData('text') || '';
if (!raw) return;
// collapse whitespace in pasted text to single spaces (keeps UX clean), preserve letters
let pasted = raw.replace(/\u00A0/g, ' ').replace(/\s+/g, ' ');
// filter out any character that isn't a letter or space
pasted = Array.from(pasted).filter(ch => {
return /^[\p{L} ]$/u.test(ch);
}).join('');
if (!pasted) return;
// insert only allowed portion given current visible length and selection
const currentVisible = visibleLength(ownEl.innerText);
const sel = getSelectionRangeWithin(ownEl);
// selection replacement frees up selection length
const selectionLen = sel.end - sel.start;
const remaining = Math.max(0, ownMax - currentVisible + selectionLen);
if (remaining <= 0) return;
const allowedText = pasted.slice(0, remaining);
insertTextAtCaret(ownEl, allowedText);
// keep caret visible
setTimeout(()=> { ownEl.scrollLeft = ownEl.scrollWidth; }, 0);
syncNameToInput(ownEl);
updateCounter();
}
// input event: final safety; remove any disallowed chars (preserve caret as best as possible) and trim to max
function nameInput(ownEl, ownMax, mirrorInput){
const raw = ownEl.innerText || '';
// preserve leading/trailing spaces while cleaning internal characters
const edges = raw.match(/^(\s*)([\s\S]*?)(\s*)$/);
const leading = edges ? edges[1] : '';
const middle = edges ? edges[2] : raw;
const trailing = edges ? edges[3] : '';
// collapse multiple whitespace inside for tidy internal behavior while typing we allow them until blur,
// but here we will only remove disallowed characters
const cleanedMiddle = Array.from(middle).filter(ch => /^[\p{L} ]$/u.test(ch)).join('');
let newVal = (leading + cleanedMiddle + trailing) || '\u00A0';
// if visible length > ownMax, trim visible content (trimmed middle) to fit
const visible = collapseAndTrim(newVal);
if (visible.length > ownMax){
const allowed = ownMax;
// produce a trimmed visible string
const trimmedVisible = visible.slice(0, allowed);
newVal = trimmedVisible || '\u00A0';
}
// If changed, update element while attempting to keep caret near previous position
if (newVal !== raw){
// compute previous caret index and attempt to map it
const sel = getSelectionRangeWithin(ownEl);
const prevPos = sel.end;
ownEl.innerText = newVal;
// place caret at min(prevPos, newVal.length)
setCaretAt(ownEl, Math.min(prevPos, (ownEl.innerText || '').length));
}
// mirror to input (trimmed)
mirrorInput.value = collapseAndTrim(ownEl.innerText);
updateCounter();
}
function syncNameToInput(ownEl){
if (ownEl === givenEl) givenInput.value = collapseAndTrim(givenEl.innerText);
else familyInput.value = collapseAndTrim(familyEl.innerText);
}
// mirror input -> contenteditable (enforce only letters/spaces and maxlength)
function mirrorNameInput(mirrorEl, ownEl, ownMax){
let v = mirrorEl.value || '';
// collapse multiple spaces, then remove disallowed chars
v = v.replace(/\u00A0/g, ' ').replace(/\s+/g, ' ');
v = Array.from(v).filter(ch => /^[\p{L} ]$/u.test(ch)).join('');
if (v.trim().length > ownMax) v = v.trim().slice(0, ownMax);
mirrorEl.value = v;
ownEl.innerText = v || '\u00A0';
updateCounter();
}
/******************************
* ID / date fields handlers
******************************/
// id number: free-form. Mirror both ways.
function syncIdToInput(){ idInput.value = idCard.innerText.trim(); }
function syncIdFromInput(){ idCard.innerText = idInput.value || '\u00A0'; }
// dates: when date input changes, update card display with YYYY-MM-DD (if value set)
function formatDateForCard(dateValue){
// dateValue is value from input[type=date] in YYYY-MM-DD or empty
return dateValue || 'YYYY-MM-DD';
}
function syncDobFromInput(){ dobCard.innerText = formatDateForCard(dobInput.value); }
function syncExpFromInput(){ expCard.innerText = formatDateForCard(expInput.value); }
// when user edits the card date text itself, if it matches YYYY-MM-DD update the date input
function syncDobFromCard(){
const txt = dobCard.innerText.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(txt)){
// validate date correctness loosely by attempting Date parse
const d = new Date(txt + 'T00:00:00');
if (!isNaN(d.getTime())){
dobInput.value = txt;
}
}
}
function syncExpFromCard(){
const txt = expCard.innerText.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(txt)){
const d = new Date(txt + 'T00:00:00');
if (!isNaN(d.getTime())){
expInput.value = txt;
}
}
}
/******************************
* Init & event wiring
******************************/
function init(){
givenEl.innerText = 'Example';
familyEl.innerText = 'Person';
givenInput.value = 'Example';
familyInput.value = 'Person';
idCard.innerText = 'DEMO-000-000';
idInput.value = 'DEMO-000-000';
dobCard.innerText = 'YYYY-MM-DD';
expCard.innerText = 'YYYY-MM-DD';
dobInput.value = '';
expInput.value = '';
updateCounter();
}
// Name field events
givenEl.addEventListener('beforeinput', (e)=> nameBeforeInput(e, givenEl, GIVEN_MAX));
familyEl.addEventListener('beforeinput', (e)=> nameBeforeInput(e, familyEl, FAMILY_MAX));
givenEl.addEventListener('paste', (e)=> namePaste(e, givenEl, GIVEN_MAX));
familyEl.addEventListener('paste', (e)=> namePaste(e, familyEl, FAMILY_MAX));
givenEl.addEventListener('input', ()=> nameInput(givenEl, GIVEN_MAX, givenInput));
familyEl.addEventListener('input', ()=> nameInput(familyEl, FAMILY_MAX, familyInput));
givenEl.addEventListener('blur', ()=> { givenEl.innerText = collapseAndTrim(givenEl.innerText) || '\u00A0'; syncNameToInput(givenEl); updateCounter(); });
familyEl.addEventListener('blur', ()=> { familyEl.innerText = collapseAndTrim(familyEl.innerText) || '\u00A0'; syncNameToInput(familyEl); updateCounter(); });
givenInput.addEventListener('input', ()=> mirrorNameInput(givenInput, givenEl, GIVEN_MAX));
familyInput.addEventListener('input', ()=> mirrorNameInput(familyInput, familyEl, FAMILY_MAX));
// ensure focusing mirror focuses card for UX
givenInput.addEventListener('focus', ()=> { givenEl.focus(); setTimeout(()=> { givenEl.scrollLeft = givenEl.scrollWidth; }, 0); });
familyInput.addEventListener('focus', ()=> { familyEl.focus(); setTimeout(()=> { familyEl.scrollLeft = familyEl.scrollWidth; }, 0); });
// ID fields
idCard.addEventListener('input', syncIdToInput);
idInput.addEventListener('input', syncIdFromInput);
// DOB / expires: wire both ways
dobInput.addEventListener('input', syncDobFromInput);
expInput.addEventListener('input', syncExpFromInput);
dobCard.addEventListener('input', ()=> { syncDobFromCard(); });
expCard.addEventListener('input', ()=> { syncExpFromCard(); });
// reset
resetBtn.addEventListener('click', init);
// small helper for caret set (used by other code)
function setCaretAt(el, chars){
if (chars < 0) chars = 0;
el.focus();
const range = document.createRange();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
let node;
let charsLeft = chars;
let found = false;
while ((node = walker.nextNode())) {
if (node.nodeValue.length >= charsLeft){
range.setStart(node, charsLeft);
range.collapse(true);
found = true;
break;
} else {
charsLeft -= node.nodeValue.length;
}
}
if (!found){
range.selectNodeContents(el);
range.collapse(false);
}
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
// init on load
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment