Airline — Gate Agent Boarding UI
A status-forward gate-agent boarding console for a fictional airline. The flight header tracks load, boarded count, gate and live status, while a scan-to-board panel returns realistic verdicts — accepted, standby for wrong group, or seat-duplicate flag. Agents open boarding groups in sequence, work an upgrade and standby queue, and filter or search a live passenger manifest. Built with semantic HTML, aviation-clean CSS, and dependency-free vanilla JavaScript.
MCP
Code
:root {
--sky: #0a66c2;
--sky-d: #084e95;
--sky-50: #e9f2fb;
--cloud: #f5f8fc;
--sunrise: #ff7a33;
--sunrise-50: #fff0e7;
--ink: #13233b;
--ink-2: #3a4d68;
--muted: #6b7c93;
--bg: #f5f8fc;
--surface: #ffffff;
--line: rgba(19, 35, 59, 0.1);
--line-2: rgba(19, 35, 59, 0.18);
--ok: #1f9d62;
--warn: #e0962a;
--danger: #d4493e;
--boarding: #1f9d62;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-sm: 0 1px 2px rgba(19, 35, 59, 0.06);
--sh-md: 0 6px 20px rgba(19, 35, 59, 0.08);
--sh-lg: 0 18px 44px rgba(19, 35, 59, 0.12);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "tnum" 0;
}
.tnum, .fh-time, .fh-flight, .sr-seat, .fh-stat-v, .man-seat, .q-seat {
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
button, input { font-family: inherit; }
.app {
max-width: 1180px;
margin: 0 auto;
padding: 20px 18px 48px;
}
/* ---------- Flight header ---------- */
.flight-head {
background: linear-gradient(135deg, var(--sky) 0%, var(--sky-d) 100%);
color: #fff;
border-radius: var(--r-lg);
padding: 20px 22px 0;
box-shadow: var(--sh-lg);
display: grid;
grid-template-columns: 1fr auto auto;
gap: 18px 28px;
align-items: center;
position: relative;
overflow: hidden;
}
.flight-head::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(420px 200px at 88% -30%, rgba(255, 122, 51, 0.32), transparent 70%),
radial-gradient(360px 220px at 6% 130%, rgba(255, 255, 255, 0.1), transparent 70%);
pointer-events: none;
}
.fh-brand { display: flex; align-items: center; gap: 12px; padding-bottom: 18px; }
.fh-logo {
width: 42px; height: 42px;
display: grid; place-items: center;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 12px;
color: #fff;
}
.fh-air { display: flex; flex-direction: column; line-height: 1.2; }
.fh-air strong { font-size: 15px; font-weight: 700; }
.fh-flight {
font-size: 13px; font-weight: 600;
color: rgba(255, 255, 255, 0.78);
}
.fh-route { display: flex; align-items: center; gap: 18px; padding-bottom: 18px; }
.fh-ap { display: flex; flex-direction: column; line-height: 1.15; }
.fh-ap-arr { text-align: right; }
.fh-code { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; }
.fh-time { font-size: 16px; font-weight: 600; }
.fh-meta { font-size: 11.5px; color: rgba(255, 255, 255, 0.72); margin-top: 2px; }
.fh-leg { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 96px; }
.fh-dur { font-size: 11px; font-weight: 600; color: rgba(255, 255, 255, 0.82); }
.fh-line {
position: relative; width: 100%; height: 18px;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.fh-line i {
position: absolute; left: 0; right: 0; top: 50%;
height: 0; border-top: 2px dashed rgba(255, 255, 255, 0.5);
}
.fh-line svg { position: relative; z-index: 1; transform: rotate(90deg); }
.fh-stats { display: flex; gap: 22px; align-items: center; padding-bottom: 18px; }
.fh-stat { display: flex; flex-direction: column; gap: 3px; }
.fh-stat-l { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.7); }
.fh-stat-v { font-size: 22px; font-weight: 800; }
.fh-stat-sub { font-size: 14px; font-weight: 600; color: rgba(255, 255, 255, 0.7); }
.fh-progress {
grid-column: 1 / -1;
height: 5px;
background: rgba(255, 255, 255, 0.22);
border-radius: 99px 99px 0 0;
overflow: hidden;
}
.fh-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--sunrise), #ffb27a);
border-radius: 99px;
transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
/* ---------- Pills ---------- */
.pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700;
padding: 5px 11px; border-radius: 99px;
white-space: nowrap;
}
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 99px; background: currentColor; }
.pill-boarding { background: rgba(31, 157, 98, 0.16); color: #d6ffe9; }
.pill-ontime { background: rgba(31, 157, 98, 0.14); color: var(--ok); }
.pill-delayed { background: rgba(224, 150, 42, 0.16); color: var(--warn); }
.pill-departed { background: var(--sky-50); color: var(--sky-d); }
.pill-cancelled { background: rgba(212, 73, 62, 0.14); color: var(--danger); }
.pill-standby { background: rgba(224, 150, 42, 0.16); color: var(--warn); }
.pill-flagged { background: rgba(212, 73, 62, 0.14); color: var(--danger); }
.pill-sm { font-size: 11px; padding: 3px 9px; }
/* ---------- Grid layout ---------- */
.grid {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 16px;
margin-top: 16px;
}
.scan { grid-row: span 2; }
.manifest { grid-column: 1 / -1; }
@media (max-width: 880px) {
.grid { grid-template-columns: 1fr; }
.scan { grid-row: auto; }
}
/* ---------- Cards ---------- */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
padding: 16px 16px 18px;
display: flex;
flex-direction: column;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 14px;
}
.card-head h2 { margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font-size: 14px; font-weight: 600;
border: 1px solid transparent; border-radius: var(--r-sm);
padding: 10px 16px; cursor: pointer;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.btn:active { transform: translateY(1px) scale(0.99); }
.btn:focus-visible { outline: 3px solid rgba(10, 102, 194, 0.35); outline-offset: 2px; }
.btn-primary { background: var(--sky); color: #fff; box-shadow: var(--sh-sm); }
.btn-primary:hover { background: var(--sky-d); }
.btn-ghost { background: var(--surface); color: var(--ink-2); border-color: var(--line-2); }
.btn-ghost:hover { background: var(--cloud); color: var(--ink); }
.btn-mini { padding: 6px 12px; font-size: 12.5px; background: var(--sky-50); color: var(--sky-d); border-color: transparent; }
.btn-mini:hover { background: #d8e8fa; }
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-block { width: 100%; margin-top: 10px; }
.btn-scan { flex: 1; padding: 13px 16px; font-size: 15px; }
/* ---------- Scan panel ---------- */
.group-tag {
font-size: 12px; font-weight: 700;
color: var(--boarding);
background: rgba(31, 157, 98, 0.12);
padding: 4px 10px; border-radius: 99px;
}
.scan-stage {
border: 1.5px dashed var(--line-2);
border-radius: var(--r-md);
background: var(--cloud);
min-height: 168px;
display: grid; place-items: center;
padding: 18px;
position: relative;
overflow: hidden;
}
.scan-idle { text-align: center; color: var(--muted); display: grid; gap: 10px; justify-items: center; }
.scan-icon { color: var(--sky); opacity: 0.55; }
.scan-idle p { margin: 0; font-size: 13.5px; max-width: 220px; }
.scan-idle strong { color: var(--ink-2); }
.scan-result { width: 100%; display: grid; gap: 14px; animation: pop 0.32s cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
.sr-pax { display: flex; align-items: center; gap: 14px; }
.sr-seat {
width: 58px; height: 58px; flex: none;
display: grid; place-items: center;
font-size: 20px; font-weight: 800;
background: var(--sky); color: #fff;
border-radius: 12px;
box-shadow: var(--sh-sm);
}
.sr-id { display: flex; flex-direction: column; line-height: 1.25; }
.sr-id strong { font-size: 17px; font-weight: 700; }
.sr-id span { font-size: 13px; color: var(--muted); }
.sr-verdict {
border-radius: var(--r-sm);
padding: 12px 14px;
display: grid; gap: 6px;
background: var(--surface);
border: 1px solid var(--line);
}
.sr-badge {
align-self: start;
font-size: 13px; font-weight: 800;
padding: 5px 12px; border-radius: 99px;
}
.sr-verdict p { margin: 0; font-size: 13.5px; color: var(--ink-2); }
.scan-stage.ok .sr-seat { background: var(--ok); }
.scan-stage.ok .sr-badge { background: rgba(31, 157, 98, 0.15); color: var(--ok); }
.scan-stage.warn .sr-seat { background: var(--warn); }
.scan-stage.warn .sr-badge { background: rgba(224, 150, 42, 0.18); color: var(--warn); }
.scan-stage.danger .sr-seat { background: var(--danger); }
.scan-stage.danger .sr-badge { background: rgba(212, 73, 62, 0.15); color: var(--danger); }
.scan-stage.ok { border-color: rgba(31, 157, 98, 0.45); }
.scan-stage.warn { border-color: rgba(224, 150, 42, 0.5); }
.scan-stage.danger { border-color: rgba(212, 73, 62, 0.45); }
.scan-actions { display: flex; gap: 10px; margin-top: 14px; }
.scan-counters {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 10px; margin-top: 14px;
}
.sc-item {
background: var(--cloud); border-radius: var(--r-sm);
padding: 10px 12px; text-align: center;
}
.sc-item span { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.sc-item strong { font-size: 20px; font-weight: 800; }
/* ---------- Boarding groups ---------- */
.group-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.group-row {
display: flex; align-items: center; gap: 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 11px 13px;
background: var(--surface);
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.group-row.is-open { border-color: var(--boarding); box-shadow: 0 0 0 1px var(--boarding) inset; background: rgba(31, 157, 98, 0.04); }
.group-row.is-done { opacity: 0.6; }
.gr-badge {
width: 30px; height: 30px; flex: none;
display: grid; place-items: center;
font-size: 13px; font-weight: 800;
border-radius: 8px;
background: var(--sky-50); color: var(--sky-d);
}
.group-row.is-open .gr-badge { background: var(--boarding); color: #fff; }
.gr-info { flex: 1; display: flex; flex-direction: column; line-height: 1.25; }
.gr-info strong { font-size: 14px; font-weight: 700; }
.gr-info span { font-size: 12px; color: var(--muted); }
.gr-state { font-size: 12px; font-weight: 700; }
.gr-state.open { color: var(--boarding); }
.gr-state.done { color: var(--muted); }
.gr-state.wait { color: var(--ink-2); }
.groups-note { margin: 12px 0 0; font-size: 12.5px; color: var(--muted); }
/* ---------- Queue ---------- */
.queue-count { font-size: 12.5px; font-weight: 600; color: var(--ink-2); }
.q-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; min-height: 30px; }
.q-row {
display: flex; align-items: center; gap: 11px;
border: 1px solid var(--line); border-radius: var(--r-sm);
padding: 10px 12px; background: var(--surface);
animation: pop 0.28s ease;
}
.q-kind {
width: 36px; height: 36px; flex: none;
display: grid; place-items: center; border-radius: 9px;
font-size: 11px; font-weight: 800; letter-spacing: 0.02em;
}
.q-kind.upgrade { background: var(--sunrise-50); color: var(--sunrise); }
.q-kind.standby { background: rgba(224, 150, 42, 0.15); color: var(--warn); }
.q-info { flex: 1; display: flex; flex-direction: column; line-height: 1.25; }
.q-info strong { font-size: 14px; font-weight: 600; }
.q-info span { font-size: 12px; color: var(--muted); }
.q-seat { font-size: 13px; font-weight: 700; color: var(--ink-2); }
.q-empty { font-size: 13px; color: var(--muted); text-align: center; padding: 16px 0; }
.q-row.leaving { animation: leave 0.35s ease forwards; }
@keyframes leave { to { opacity: 0; transform: translateX(20px); height: 0; padding: 0; margin: 0; } }
/* ---------- Manifest ---------- */
.man-filter { display: flex; gap: 6px; }
.chip {
font-size: 12.5px; font-weight: 600;
padding: 6px 12px; border-radius: 99px;
border: 1px solid var(--line-2); background: var(--surface);
color: var(--ink-2); cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chip:hover { background: var(--cloud); }
.chip.is-active { background: var(--sky); color: #fff; border-color: var(--sky); }
.man-search {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--line-2); border-radius: var(--r-sm);
padding: 0 12px; margin-bottom: 12px;
color: var(--muted);
}
.man-search input {
border: 0; outline: 0; background: transparent;
padding: 10px 0; font-size: 14px; flex: 1; color: var(--ink);
}
.man-search:focus-within { border-color: var(--sky); box-shadow: 0 0 0 3px rgba(10, 102, 194, 0.12); }
.man-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.man-row {
display: grid;
grid-template-columns: 56px 1fr auto auto;
gap: 12px; align-items: center;
padding: 11px 6px;
border-bottom: 1px solid var(--line);
}
.man-row:last-child { border-bottom: 0; }
.man-seat {
font-size: 14px; font-weight: 700; color: var(--ink-2);
background: var(--cloud); border-radius: 7px;
padding: 6px 0; text-align: center;
}
.man-id { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.man-id strong { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.man-id span { font-size: 12px; color: var(--muted); }
.man-cabin { font-size: 11.5px; font-weight: 700; color: var(--sky-d); text-transform: uppercase; letter-spacing: 0.04em; }
.man-row.boarded { background: rgba(31, 157, 98, 0.035); }
.man-empty { padding: 24px 0; text-align: center; color: var(--muted); font-size: 13.5px; }
/* ---------- Toasts ---------- */
.toast-wrap {
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 50;
width: min(92vw, 420px);
}
.toast {
background: var(--ink); color: #fff;
border-radius: var(--r-sm); padding: 12px 15px;
font-size: 13.5px; font-weight: 500;
box-shadow: var(--sh-lg);
display: flex; align-items: center; gap: 10px;
animation: toastin 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast::before { content: ""; width: 8px; height: 8px; border-radius: 99px; background: var(--ok); flex: none; }
.toast.warn::before { background: var(--warn); }
.toast.danger::before { background: var(--danger); }
.toast.leaving { animation: toastout 0.3s ease forwards; }
@keyframes toastin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes toastout { to { opacity: 0; transform: translateY(14px); } }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.flight-head { grid-template-columns: 1fr; gap: 14px 0; padding: 18px 18px 0; }
.fh-brand, .fh-route, .fh-stats { padding-bottom: 0; }
.fh-stats { justify-content: space-between; padding-bottom: 16px; flex-wrap: wrap; }
.fh-progress { margin-top: 2px; }
}
@media (max-width: 520px) {
.app { padding: 14px 12px 40px; }
.fh-code { font-size: 26px; }
.fh-leg { min-width: 70px; }
.fh-route { gap: 12px; }
.man-row { grid-template-columns: 48px 1fr auto; }
.man-cabin { display: none; }
.scan-actions { flex-direction: column; }
.btn-scan { width: 100%; }
.card-head { flex-wrap: wrap; }
.man-filter { width: 100%; }
}(function () {
"use strict";
// ---------- Data ----------
var LOAD = 186;
// Boarding groups, in order. Group index that is currently "open".
var groups = [
{ id: 1, name: "Group 1 — First & Business", note: "Rows 1–6", state: "done" },
{ id: 2, name: "Group 2 — Priority & Sky Elite", note: "Rows 7–14", state: "open" },
{ id: 3, name: "Group 3 — Main Cabin", note: "Rows 15–28", state: "wait" },
{ id: 4, name: "Group 4 — Economy", note: "Rows 29–40", state: "wait" },
{ id: 5, name: "Group 5 — Basic", note: "Standby & rear", state: "wait" }
];
// Passenger manifest. group = boarding group they belong to.
var firstNames = ["Amara", "Tomas", "Wei", "Noor", "Diego", "Ingrid", "Kwame", "Sofia",
"Raj", "Lena", "Mateo", "Yuki", "Omar", "Freya", "Hassan", "Priya", "Lukas", "Aiko",
"Carlos", "Nadia", "Finn", "Zara", "Bjorn", "Mira", "Theo", "Anaya"];
var lastNames = ["Okafor", "Reyes", "Zhang", "Haddad", "Moreau", "Berg", "Mensah", "Costa",
"Patel", "Novak", "Silva", "Tanaka", "Farouk", "Lindqvist", "Ali", "Rao", "Hofer", "Sato",
"Vega", "Kovac", "Walsh", "Khan", "Eriksen", "Devi", "Klein", "Sharma"];
var cabins = ["First", "Business", "Economy", "Economy", "Economy"];
function seatLabel(i) {
var row = Math.floor(i / 6) + 1;
var col = "ABCDEF"[i % 6];
return row + col;
}
var passengers = [];
for (var i = 0; i < 64; i++) {
var g = i < 4 ? 1 : i < 14 ? 2 : i < 34 ? 3 : i < 52 ? 4 : 5;
var cabin = g === 1 ? "First" : g === 2 ? "Business" : "Economy";
passengers.push({
id: i,
seat: seatLabel(i),
name: firstNames[i % firstNames.length] + " " + lastNames[(i * 7) % lastNames.length],
group: g,
cabin: cabin,
boarded: g === 1, // group 1 already boarded
flag: i === 9 ? "dupe" : null // one seat-duplicate to demo
});
}
// Pre-mark group 1 boarded count into the running total below.
var queue = [
{ id: "q1", kind: "upgrade", name: "Priya Rao", from: "Economy 22C", to: "Business 4A", seat: "4A" },
{ id: "q2", kind: "standby", name: "Marcus Hale", from: "Standby #1", to: "—", seat: "—" },
{ id: "q3", kind: "standby", name: "Ella Bright", from: "Standby #2", to: "—", seat: "—" }
];
var counters = { accepted: 0, standby: 0, flagged: 0 };
var manifestFilter = "all";
var searchTerm = "";
var scannedDupe = false;
// ---------- DOM ----------
var $ = function (s) { return document.querySelector(s); };
var boardedCountEl = $("#boardedCount");
var boardBar = $("#boardBar");
var flightStatusEl = $("#flightStatus");
$("#loadCount").textContent = LOAD;
// ---------- Helpers ----------
function boardedTotal() {
return passengers.filter(function (p) { return p.boarded; }).length;
}
// virtual baseline so the counter feels like a full flight, not just the 64 sample rows
var virtualBase = 0;
function updateBoarded() {
var n = boardedTotal() + virtualBase;
if (n > LOAD) n = LOAD;
boardedCountEl.textContent = n;
var pct = Math.min(100, Math.round((n / LOAD) * 100));
boardBar.style.width = pct + "%";
if (pct >= 100) {
flightStatusEl.textContent = "Boarding complete";
flightStatusEl.className = "pill pill-departed";
}
}
function activeGroup() {
return groups.find(function (g) { return g.state === "open"; });
}
function toast(msg, kind) {
var wrap = $("#toastWrap");
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
wrap.appendChild(el);
setTimeout(function () {
el.classList.add("leaving");
setTimeout(function () { el.remove(); }, 320);
}, 2600);
}
// ---------- Render: groups ----------
function renderGroups() {
var list = $("#groupList");
list.innerHTML = "";
groups.forEach(function (g) {
var li = document.createElement("li");
li.className = "group-row" + (g.state === "open" ? " is-open" : g.state === "done" ? " is-done" : "");
var stateTxt = g.state === "open" ? "Boarding" : g.state === "done" ? "Done" : "Waiting";
li.innerHTML =
'<span class="gr-badge">' + g.id + "</span>" +
'<span class="gr-info"><strong>' + g.name + "</strong><span>" + g.note + "</span></span>" +
'<span class="gr-state ' + (g.state === "open" ? "open" : g.state === "done" ? "done" : "wait") + '">' + stateTxt + "</span>";
list.appendChild(li);
});
var ag = activeGroup();
$("#activeGroupTag").textContent = ag ? ag.id : "—";
var btn = $("#nextGroupBtn");
btn.disabled = !ag || ag.id === groups[groups.length - 1].id ? false : false;
if (!ag) { btn.disabled = true; btn.textContent = "All groups open"; }
if (ag) {
$("#groupsNote").textContent =
"Group " + ag.id + " now boarding. Scans outside the open group route to standby.";
} else {
$("#groupsNote").textContent = "All boarding groups are open. Final call.";
}
}
function openNextGroup() {
var ag = activeGroup();
if (!ag) { toast("All groups already open.", "warn"); return; }
ag.state = "done";
var idx = groups.indexOf(ag);
if (idx + 1 < groups.length) {
groups[idx + 1].state = "open";
toast("Group " + groups[idx + 1].id + " is now boarding.");
} else {
toast("Final group cleared. Last call.", "warn");
}
renderGroups();
}
// ---------- Render: queue ----------
function renderQueue() {
var list = $("#queueList");
list.innerHTML = "";
$("#queueCount").textContent = queue.length;
if (!queue.length) {
var empty = document.createElement("li");
empty.className = "q-empty";
empty.textContent = "Queue cleared — no one waiting.";
list.appendChild(empty);
return;
}
queue.forEach(function (q) {
var li = document.createElement("li");
li.className = "q-row";
li.dataset.id = q.id;
li.innerHTML =
'<span class="q-kind ' + q.kind + '">' + (q.kind === "upgrade" ? "UPG" : "SBY") + "</span>" +
'<span class="q-info"><strong>' + q.name + "</strong><span>" + q.from + (q.to !== "—" ? " → " + q.to : "") + "</span></span>" +
'<span class="q-seat">' + q.seat + "</span>";
list.appendChild(li);
});
}
function clearNextStandby() {
if (!queue.length) { toast("Queue is already empty.", "warn"); return; }
var item = queue[0];
var row = $('#queueList .q-row[data-id="' + item.id + '"]');
// find an open seat for standby; upgrades already carry a target seat
var seat = item.seat !== "—" ? item.seat : assignOpenSeat();
if (row) {
row.classList.add("leaving");
setTimeout(function () { finishClear(item, seat); }, 340);
} else {
finishClear(item, seat);
}
}
function assignOpenSeat() {
var taken = {};
passengers.forEach(function (p) { taken[p.seat] = true; });
for (var r = 28; r <= 40; r++) {
for (var c = 0; c < 6; c++) {
var s = r + "ABCDEF"[c];
if (!taken[s]) return s;
}
}
return "—";
}
function finishClear(item, seat) {
queue.shift();
// add the cleared passenger to manifest as boarded
passengers.push({
id: 1000 + Math.floor(Math.random() * 9999),
seat: seat,
name: item.name,
group: 5,
cabin: item.kind === "upgrade" ? "Business" : "Economy",
boarded: true,
flag: null
});
counters.accepted++;
updateCounters();
renderQueue();
renderManifest();
updateBoarded();
toast(item.name + " cleared into seat " + seat + ".");
}
// ---------- Render: counters ----------
function updateCounters() {
$("#cAccepted").textContent = counters.accepted;
$("#cStandby").textContent = counters.standby;
$("#cFlagged").textContent = counters.flagged;
}
// ---------- Render: manifest ----------
function renderManifest() {
var list = $("#manList");
list.innerHTML = "";
var rows = passengers.filter(function (p) {
if (manifestFilter === "boarded" && !p.boarded) return false;
if (manifestFilter === "not-boarded" && p.boarded) return false;
if (searchTerm) {
var t = searchTerm.toLowerCase();
if (p.name.toLowerCase().indexOf(t) === -1 && p.seat.toLowerCase().indexOf(t) === -1) return false;
}
return true;
});
if (!rows.length) {
var empty = document.createElement("li");
empty.className = "man-empty";
empty.textContent = "No passengers match this view.";
list.appendChild(empty);
return;
}
rows.slice(0, 40).forEach(function (p) {
var li = document.createElement("li");
li.className = "man-row" + (p.boarded ? " boarded" : "");
var pill = p.boarded
? '<span class="pill pill-ontime pill-sm">Boarded</span>'
: p.flag === "dupe"
? '<span class="pill pill-flagged pill-sm">Seat dupe</span>'
: '<span class="pill pill-delayed pill-sm">Not boarded</span>';
li.innerHTML =
'<span class="man-seat">' + p.seat + "</span>" +
'<span class="man-id"><strong>' + p.name + "</strong><span>Group " + p.group + "</span></span>" +
'<span class="man-cabin">' + p.cabin + "</span>" +
pill;
list.appendChild(li);
});
}
// ---------- Scan logic ----------
function nextScanCandidate() {
var ag = activeGroup();
var openId = ag ? ag.id : 99;
// priority: unboarded in open group, then the seat-dupe demo, then any unboarded
var inGroup = passengers.filter(function (p) { return !p.boarded && p.group === openId && !p.flag; });
if (inGroup.length) return { pax: inGroup[0], verdict: "accept" };
var dupe = passengers.filter(function (p) { return !p.boarded && p.flag === "dupe"; });
if (dupe.length && !scannedDupe) { scannedDupe = true; return { pax: dupe[0], verdict: "dupe" }; }
var outGroup = passengers.filter(function (p) { return !p.boarded && p.group > openId && !p.flag; });
if (outGroup.length) return { pax: outGroup[0], verdict: "standby" };
var anyLeft = passengers.filter(function (p) { return !p.boarded && !p.flag; });
if (anyLeft.length) return { pax: anyLeft[0], verdict: "accept" };
return null;
}
function showResult(pax, verdict) {
var stage = $("#scanStage");
$("#scanIdle").hidden = true;
$("#scanResult").hidden = false;
stage.classList.remove("ok", "warn", "danger");
$("#srSeat").textContent = pax.seat;
$("#srName").textContent = pax.name;
$("#srGroup").textContent = "Group " + pax.group + " · " + pax.cabin;
var badge = $("#srBadge");
var msg = $("#srMsg");
if (verdict === "accept") {
stage.classList.add("ok");
badge.textContent = "Accepted";
msg.textContent = "Welcome aboard — seat " + pax.seat + " confirmed.";
pax.boarded = true;
counters.accepted++;
toast(pax.name + " boarded · seat " + pax.seat + ".");
} else if (verdict === "standby") {
stage.classList.add("warn");
badge.textContent = "Standby — wrong group";
msg.textContent = pax.name + " is Group " + pax.group + ". Routed to standby until that group opens.";
counters.standby++;
if (!queue.some(function (q) { return q.name === pax.name; })) {
queue.push({ id: "sb" + pax.id, kind: "standby", name: pax.name, from: "Held — Group " + pax.group, to: "—", seat: "—" });
}
toast(pax.name + " sent to standby (Group " + pax.group + ").", "warn");
} else if (verdict === "dupe") {
stage.classList.add("danger");
badge.textContent = "Seat duplicate";
msg.textContent = "Seat " + pax.seat + " is already assigned. Hold passenger for reseat.";
counters.flagged++;
toast("Seat conflict on " + pax.seat + " — flagged.", "danger");
}
updateCounters();
updateBoarded();
renderManifest();
renderQueue();
}
function doScan() {
var c = nextScanCandidate();
if (!c) {
toast("Manifest fully processed.", "warn");
var stage = $("#scanStage");
stage.classList.remove("ok", "warn", "danger");
$("#scanIdle").hidden = false;
$("#scanResult").hidden = true;
return;
}
showResult(c.pax, c.verdict);
}
function manualOverride() {
// resolve the flagged seat-dupe by reseating to an open seat
var dupe = passengers.filter(function (p) { return p.flag === "dupe" && !p.boarded; })[0];
if (!dupe) { toast("Nothing pending manual override.", "warn"); return; }
var seat = assignOpenSeat();
dupe.flag = null;
dupe.seat = seat;
dupe.boarded = true;
counters.flagged = Math.max(0, counters.flagged - 1);
counters.accepted++;
updateCounters();
updateBoarded();
renderManifest();
var stage = $("#scanStage");
stage.classList.remove("danger");
stage.classList.add("ok");
$("#srBadge").textContent = "Reseated";
$("#srSeat").textContent = seat;
$("#srMsg").textContent = "Override applied — " + dupe.name + " reseated to " + seat + ".";
toast(dupe.name + " reseated to " + seat + " by override.");
}
// ---------- Events ----------
$("#scanBtn").addEventListener("click", doScan);
$("#overrideBtn").addEventListener("click", manualOverride);
$("#nextGroupBtn").addEventListener("click", openNextGroup);
$("#clearStandbyBtn").addEventListener("click", clearNextStandby);
Array.prototype.forEach.call(document.querySelectorAll(".man-filter .chip"), function (chip) {
chip.addEventListener("click", function () {
document.querySelectorAll(".man-filter .chip").forEach(function (c) {
c.classList.remove("is-active");
c.setAttribute("aria-selected", "false");
});
chip.classList.add("is-active");
chip.setAttribute("aria-selected", "true");
manifestFilter = chip.dataset.filter;
renderManifest();
});
});
$("#manSearch").addEventListener("input", function (e) {
searchTerm = e.target.value.trim();
renderManifest();
});
// ---------- Init ----------
// Seed a virtual baseline so the boarded counter reflects a busy gate:
// group 1 (4 sample rows boarded) + ~38 phantom early boarders.
virtualBase = 38;
renderGroups();
renderQueue();
renderManifest();
updateCounters();
updateBoarded();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skyward Air — Gate Agent Boarding</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app" role="application" aria-label="Gate agent boarding terminal">
<!-- Flight header -->
<header class="flight-head">
<div class="fh-brand">
<span class="fh-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L11 19v-5.5L21 16z"/></svg>
</span>
<div class="fh-air">
<strong>Skyward Air</strong>
<span class="fh-flight" id="flightNo">SK 482</span>
</div>
</div>
<div class="fh-route" aria-label="Route New York JFK to London Heathrow">
<div class="fh-ap">
<span class="fh-code">JFK</span>
<span class="fh-time">18:40</span>
<span class="fh-meta">Terminal 4 · New York</span>
</div>
<div class="fh-leg" aria-hidden="true">
<span class="fh-dur">7h 05m</span>
<span class="fh-line"><i></i><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L11 19v-5.5L21 16z"/></svg></span>
</div>
<div class="fh-ap fh-ap-arr">
<span class="fh-code">LHR</span>
<span class="fh-time">06:45</span>
<span class="fh-meta">Terminal 3 · London</span>
</div>
</div>
<div class="fh-stats">
<div class="fh-stat">
<span class="fh-stat-l">Gate</span>
<strong class="fh-stat-v">B22</strong>
</div>
<div class="fh-stat">
<span class="fh-stat-l">Boarded</span>
<strong class="fh-stat-v"><span id="boardedCount">0</span><span class="fh-stat-sub">/<span id="loadCount">186</span></span></strong>
</div>
<div class="fh-stat fh-stat-status">
<span class="fh-stat-l">Status</span>
<span class="pill pill-boarding" id="flightStatus">Boarding</span>
</div>
</div>
<div class="fh-progress" aria-hidden="true">
<div class="fh-progress-bar" id="boardBar" style="width:0%"></div>
</div>
</header>
<main class="grid">
<!-- Scan panel -->
<section class="card scan" aria-label="Scan to board">
<div class="card-head">
<h2>Scan to Board</h2>
<span class="group-tag">Group <span id="activeGroupTag">2</span> open</span>
</div>
<div class="scan-stage" id="scanStage" aria-live="polite">
<div class="scan-idle" id="scanIdle">
<span class="scan-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M3 12h18"/></svg>
</span>
<p>Tap <strong>Scan Pass</strong> to read the next boarding pass.</p>
</div>
<div class="scan-result" id="scanResult" hidden>
<div class="sr-pax">
<span class="sr-seat" id="srSeat">—</span>
<div class="sr-id">
<strong id="srName">—</strong>
<span id="srGroup">—</span>
</div>
</div>
<div class="sr-verdict" id="srVerdict">
<span class="sr-badge" id="srBadge">Accepted</span>
<p id="srMsg">Welcome aboard.</p>
</div>
</div>
</div>
<div class="scan-actions">
<button class="btn btn-primary btn-scan" id="scanBtn" type="button">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3"/><path d="M4 12h16"/></svg>
Scan Pass
</button>
<button class="btn btn-ghost" id="overrideBtn" type="button">Manual override</button>
</div>
<div class="scan-counters">
<div class="sc-item"><span>Accepted</span><strong id="cAccepted">0</strong></div>
<div class="sc-item"><span>Standby</span><strong id="cStandby">0</strong></div>
<div class="sc-item"><span>Flagged</span><strong id="cFlagged">0</strong></div>
</div>
</section>
<!-- Boarding group control -->
<section class="card groups" aria-label="Boarding group control">
<div class="card-head">
<h2>Boarding Groups</h2>
<button class="btn btn-mini" id="nextGroupBtn" type="button">Open next group</button>
</div>
<ul class="group-list" id="groupList"></ul>
<p class="groups-note" id="groupsNote">Group 2 (Priority) now boarding. Scans outside the open group route to standby.</p>
</section>
<!-- Standby / upgrade queue -->
<section class="card queue" aria-label="Standby and upgrade queue">
<div class="card-head">
<h2>Standby & Upgrades</h2>
<span class="queue-count"><span id="queueCount">3</span> waiting</span>
</div>
<ul class="q-list" id="queueList"></ul>
<button class="btn btn-ghost btn-block" id="clearStandbyBtn" type="button">Clear next standby into open seat</button>
</section>
<!-- Passenger manifest -->
<section class="card manifest" aria-label="Passenger manifest">
<div class="card-head">
<h2>Manifest</h2>
<div class="man-filter" role="tablist" aria-label="Filter passengers">
<button class="chip is-active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="chip" data-filter="boarded" role="tab" aria-selected="false">Boarded</button>
<button class="chip" data-filter="not-boarded" role="tab" aria-selected="false">Not boarded</button>
</div>
</div>
<div class="man-search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input type="search" id="manSearch" placeholder="Search name or seat…" aria-label="Search passengers" />
</div>
<ul class="man-list" id="manList"></ul>
</section>
</main>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Gate Agent Boarding UI
A working gate-agent console for Skyward Air flight SK 482 (JFK → LHR). The header reads like a real departure-control screen: aviation-blue route block with airport codes and 24h times, a gate label, a live boarded/load counter, a large status pill, and a sunrise-orange progress bar that fills as passengers stream through the door.
The heart of the screen is the scan-to-board panel. Each scan returns one of three realistic verdicts — Accepted (seat confirmed, counter ticks up), Standby when a passenger scans outside the open boarding group, or a Seat duplicate flag that holds them for reseat. A manual override reseats the flagged passenger into the next open seat. Boarding groups open in sequence, and the standby and upgrade queue clears the next waiting traveler into an available seat with a smooth dequeue animation.
Everything is filterable: the passenger manifest supports All / Boarded / Not boarded views plus a name-or-seat search, and every action raises a small toast. The build is pure HTML, CSS, and vanilla JavaScript — no frameworks, no build step — with tabular figures, AA-contrast colors, keyboard-usable controls, and a mobile-first layout that holds together down to 360px.
Illustrative UI only — fictional airline, not a real booking or flight system.