Created
February 28, 2026 21:28
-
-
Save EncodeTheCode/ba995ef5f58aee49fc9464d4d226e382 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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