Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save EncodeTheCode/ba995ef5f58aee49fc9464d4d226e382 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 — Spaces Fixed (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: 680px;
--card-h: 380px;
--muted: #5b6b70;
--accent: #0a6fb1;
--photo-w: 140px;
--photo-h: 150px;
--padding: 18px;
--max-ch: 30ch;
}
*{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:1200px;
width:100%;
justify-content:center;
flex-wrap:wrap;
}
.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 12px 40px 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:52%;
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:100px; height:46px; 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:16px; margin-top:14px; align-items:flex-start;
padding-right: calc(var(--photo-w) + 24px);
}
.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:46px;
border-radius:6px;
padding:8px 10px;
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 36ch; max-width:60%; }
.family{ flex: 1 1 auto; min-width:10ch; }
.name-field[contenteditable="true"]:empty::before{
content:attr(data-placeholder);
color:var(--muted);
font-weight:400;
}
.meta-row{ margin-top:6px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.meta-chip{
background:rgba(10,107,177,0.03); 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;
}
.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{ 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; }
.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; }
@media (max-width:900px){
:root{ --card-w: 100%; }
.id-card{ width:100%; max-width:720px; }
.controls{ width:100%; }
.given{ flex-basis: 40ch; }
.photo{ right:12px; bottom:12px; }
.main{ padding-right: calc(var(--photo-w) + 20px); }
}
</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" aria-hidden="true" style="margin-top:14px;">
<div>
<div class="label" style="margin:0 0 6px 0;">ID no.</div>
<div class="meta-chip">DEMO-000-000</div>
</div>
<div>
<div class="label" style="margin:0 0 6px 0;">DOB</div>
<div class="meta-chip">YYYY-MM-DD</div>
</div>
<div>
<div class="label" style="margin:0 0 6px 0;">Expires</div>
<div class="meta-chip">YYYY-MM-DD</div>
</div>
</div>
</div>
</div>
<div class="photo" aria-hidden="true">Photo<br>Placeholder</div>
<div style="position:absolute; right:14px; bottom:calc(var(--photo-h) + 28px); font-size:12px; color:rgba(7,32,40,0.28);">
DEMO • NOT A GOVERNMENT DOCUMENT
</div>
</section>
<aside class="controls" aria-label="Controls">
<div>
<div class="label" style="margin:0 0 6px 0;">Edit names (click card or use inputs)</div>
<input id="givenInput" class="hidden-input" type="text" aria-label="Given names input" placeholder="Given names" />
<input id="familyInput" class="hidden-input" type="text" aria-label="Family name input" placeholder="Family name" style="margin-top:8px" />
<div class="counter" id="counter">30 characters remaining</div>
<div class="note" style="margin-top:6px;">Total allowed (Given + Family): <strong>30</strong>. Spaces are allowed and count toward the limit.</div>
</div>
<div>
<button id="resetBtn">Reset example</button>
</div>
</aside>
</div>
<script>
const MAX_TOTAL = 30;
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 resetBtn = document.getElementById('resetBtn');
// Helpers: preserve spaces while collapsing multi-space sequences when counting or on blur
function collapseSpacesPreserveEdges(s){
return (s || '').replace(/\u00A0/g, ' ').replace(/\s+/g, ' ');
}
function collapseAndTrim(s){
return collapseSpacesPreserveEdges(s).trim();
}
function combinedLengthForCounting(g, f){
return collapseAndTrim(g).length + collapseAndTrim(f).length;
}
function updateCounter(){
const len = combinedLengthForCounting(givenEl.innerText, familyEl.innerText);
const rem = MAX_TOTAL - len;
counter.textContent = (rem >= 0 ? rem : 0) + ' characters remaining';
counter.style.color = rem < 0 ? 'crimson' : '';
}
// get selection start & end indices inside a contenteditable
function getSelectionRangeWithin(el){
const sel = window.getSelection();
if (!sel.rangeCount) return { start: 0, end: 0 };
const range = sel.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(el);
preRange.setEnd(range.startContainer, range.startOffset);
const start = preRange.toString().length;
const preRange2 = range.cloneRange();
preRange2.selectNodeContents(el);
preRange2.setEnd(range.endContainer, range.endOffset);
const end = preRange2.toString().length;
return { start, end };
}
// set caret at character index inside contenteditable
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);
}
// insert text at caret (used for paste)
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);
}
// BEFOREINPUT: allow normal typing (including space & composition) but block only those insertions that would overflow
function handleBeforeInput(e, ownEl, otherEl){
// Only check insertions (text & paste events handled separately). Allow deletions/navigation.
if (e.inputType && e.inputType.startsWith('insert') && e.data !== null){
// selection range within ownEl
const { start, end } = getSelectionRangeWithin(ownEl);
const current = ownEl.innerText || '';
const proposed = current.slice(0, start) + e.data + current.slice(end);
const other = otherEl.innerText || '';
if (combinedLengthForCounting(proposed, other) > MAX_TOTAL){
e.preventDefault();
return;
}
}
// for composition/IME ('insertCompositionText'), don't block here; validation on input will keep things in bounds
}
// paste handler: insert only allowed portion
function handlePaste(e, ownEl, otherEl){
e.preventDefault();
const raw = (e.clipboardData || window.clipboardData).getData('text') || '';
const paste = collapseSpacesPreserveEdges(raw);
if (!paste) return;
const { start, end } = getSelectionRangeWithin(ownEl);
const ownCurrent = ownEl.innerText || '';
const otherCurrent = otherEl.innerText || '';
const selectionLen = end - start;
const currentCombined = combinedLengthForCounting(ownCurrent, otherCurrent);
// available remaining = MAX_TOTAL - (currentCombined) + selectionLen (since selection will be replaced)
const remaining = Math.max(0, MAX_TOTAL - currentCombined + selectionLen);
if (remaining <= 0) return;
const allowedText = paste.slice(0, remaining); // preserve spaces from paste
// insert at caret
insertTextAtCaret(ownEl, allowedText);
// keep text tidy (do not aggressively remove spaces while typing)
syncToInputs();
updateCounter();
// scroll field so caret is visible
setTimeout(()=> { ownEl.scrollLeft = ownEl.scrollWidth; }, 0);
}
// input event: normalize small things but do not remove spaces the user types. If the combined length is exceeded (rare), revert the insertion.
function handleInput(ownEl, otherEl, mirrorEl){
// compute combined length
const g = ownEl === givenEl ? ownEl.innerText : givenEl.innerText;
const f = ownEl === familyEl ? ownEl.innerText : familyEl.innerText;
if (combinedLengthForCounting(g, f) > MAX_TOTAL){
// overflow: try to revert the last edit by using the selection prev stored or by trimming the field to fit
// Simple safe fallback: trim the editing field's trimmed content to allowed size
const otherTrim = collapseAndTrim(otherEl.innerText);
const allowed = Math.max(0, MAX_TOTAL - otherTrim.length);
const curr = collapseSpacesPreserveEdges(ownEl.innerText);
// prefer to keep leading/trailing spaces while trimming inner visible chars
let trimmed = curr;
// trim visible characters (without leading/trailing) to allowed
const leading = trimmed.match(/^\s*/)[0] || '';
const trailing = trimmed.match(/\s*$/)[0] || '';
const middle = trimmed.slice(leading.length, trimmed.length - trailing.length);
const middleTrimmed = middle.trim().slice(0, allowed);
const newVal = (leading + middleTrimmed + trailing) || '\u00A0';
ownEl.innerText = newVal;
}
// sync mirror input with trimmed & collapsed value (no leading/trailing spaces)
mirrorEl.value = collapseAndTrim(ownEl.innerText);
updateCounter();
}
// blur: collapse multiple spaces and trim edges to tidy fields
function handleBlur(ownEl, mirrorEl){
const collapsed = collapseAndTrim(ownEl.innerText);
ownEl.innerText = collapsed || '\u00A0';
mirrorEl.value = collapsed;
updateCounter();
}
// Mirror input handlers: enforce combined limit by trimming the field being edited
function handleMirrorInput(mirrorEl, ownEl, otherMirrorEl){
let val = mirrorEl.value || '';
val = collapseSpacesPreserveEdges(val);
const otherVal = collapseAndTrim(otherMirrorEl.value || '');
if (val.trim().length + otherVal.length > MAX_TOTAL){
// trim the active mirror input to allowed length
const allowed = MAX_TOTAL - otherVal.length;
val = val.trim().slice(0, allowed);
}
mirrorEl.value = val;
// reflect into contenteditable (preserve a non-empty node)
ownEl.innerText = val || '\u00A0';
updateCounter();
}
function syncToInputs(){
givenInput.value = collapseAndTrim(givenEl.innerText);
familyInput.value = collapseAndTrim(familyEl.innerText);
updateCounter();
}
function init(){
givenEl.innerText = 'Example';
familyEl.innerText = 'Person';
givenInput.value = 'Example';
familyInput.value = 'Person';
updateCounter();
}
// Attach events
// Use beforeinput to block problematic insertions while allowing native typing & composition (spaces, IME)
givenEl.addEventListener('beforeinput', (e)=> handleBeforeInput(e, givenEl, familyEl));
familyEl.addEventListener('beforeinput', (e)=> handleBeforeInput(e, familyEl, givenEl));
// Paste
givenEl.addEventListener('paste', (e)=> handlePaste(e, givenEl, familyEl));
familyEl.addEventListener('paste', (e)=> handlePaste(e, familyEl, givenEl));
// Input: final validation/fallback
givenEl.addEventListener('input', ()=> handleInput(givenEl, familyEl, givenInput));
familyEl.addEventListener('input', ()=> handleInput(familyEl, givenEl, familyInput));
// Blur: tidy whitespace
givenEl.addEventListener('blur', ()=> handleBlur(givenEl, givenInput));
familyEl.addEventListener('blur', ()=> handleBlur(familyEl, familyInput));
// Mirror inputs: trim & sync while typing; enforce combined limit
givenInput.addEventListener('input', ()=> handleMirrorInput(givenInput, givenEl, familyInput));
familyInput.addEventListener('input', ()=> handleMirrorInput(familyInput, familyEl, givenInput));
// Focus mirror inputs should place caret at end of editable (nice UX)
function setCaretToEnd(el){
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
setTimeout(()=> { el.scrollLeft = el.scrollWidth; }, 0);
}
givenInput.addEventListener('focus', ()=> { setCaretToEnd(givenEl); });
familyInput.addEventListener('focus', ()=> { setCaretToEnd(familyEl); });
resetBtn.addEventListener('click', init);
// init
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment