Pages Hard
Kitchen Display System (KDS)
Restaurant Kitchen Display — four-column ticket flow (New · Cooking · Ready · Served) with age-coloured cards, line check-off, advance / recall actions, and a live timer.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(44, 26, 14, 0.08), 0 2px 6px rgba(44, 26, 14, 0.06);
--shadow-2: 0 8px 24px rgba(44, 26, 14, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
font-family: var(--font-body);
background: var(--ink);
color: var(--bone);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
}
/* ── Topbar ── */
.topbar {
background: var(--forest-d);
padding: 14px 22px;
display: flex;
align-items: center;
gap: 24px;
border-bottom: 1px solid rgba(250, 247, 241, 0.08);
}
.brand {
display: flex;
flex-direction: column;
}
.brand-name {
font-weight: 800;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.brand-tag {
font-size: 0.66rem;
letter-spacing: 0.16em;
color: var(--gold-light);
text-transform: uppercase;
font-weight: 700;
}
.stats {
display: flex;
gap: 18px;
}
.stat {
font-size: 0.8rem;
color: rgba(250, 247, 241, 0.7);
}
.stat b {
font-family: var(--font-mono);
font-size: 1rem;
font-weight: 700;
color: var(--bone);
margin-right: 4px;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
}
.util-btn {
background: var(--gold);
color: var(--ink);
border: none;
border-radius: 999px;
padding: 8px 14px;
font-family: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.util-btn:hover {
background: var(--gold-light);
}
.clock {
font-family: var(--font-mono);
font-weight: 700;
font-size: 1rem;
color: var(--gold-light);
}
/* ── Board ── */
.board {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
padding: 18px;
overflow: hidden;
background: var(--ink);
}
.col {
background: rgba(250, 247, 241, 0.04);
border: 1px solid rgba(250, 247, 241, 0.08);
border-radius: var(--r-md);
display: flex;
flex-direction: column;
min-width: 0;
}
.col-head {
padding: 12px 14px;
border-bottom: 1px solid rgba(250, 247, 241, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.col-title {
font-size: 0.86rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.col-count {
font-family: var(--font-mono);
font-size: 0.78rem;
background: rgba(250, 247, 241, 0.1);
padding: 2px 8px;
border-radius: 999px;
font-weight: 700;
}
.col[data-status="new"] .col-title {
color: var(--gold-light);
}
.col[data-status="cook"] .col-title {
color: var(--terracotta);
}
.col[data-status="ready"] .col-title {
color: #6ec78a;
}
.col[data-status="served"] .col-title {
color: rgba(250, 247, 241, 0.5);
}
.col-body {
flex: 1;
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Tickets ── */
.ticket {
background: var(--bone);
color: var(--ink);
border-radius: var(--r-md);
padding: 10px 12px 12px;
box-shadow: var(--shadow-1);
display: flex;
flex-direction: column;
gap: 8px;
border-left: 4px solid var(--forest);
animation: tIn 0.2s ease;
}
@keyframes tIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.ticket.is-warm {
border-left-color: var(--warning);
}
.ticket.is-hot {
border-left-color: var(--danger);
animation: pulse 1.2s ease-in-out infinite;
}
.ticket.is-served {
opacity: 0.55;
border-left-color: var(--warm-gray);
}
@keyframes pulse {
0%,
100% {
box-shadow: var(--shadow-1);
}
50% {
box-shadow: 0 0 0 3px rgba(179, 67, 42, 0.2);
}
}
.t-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.t-table {
font-size: 0.95rem;
font-weight: 800;
letter-spacing: -0.005em;
}
.t-course {
font-size: 0.7rem;
color: var(--warm-gray);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
.t-age {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.95rem;
background: var(--cream-2);
color: var(--ink);
padding: 4px 10px;
border-radius: 999px;
}
.ticket.is-warm .t-age {
background: rgba(217, 144, 32, 0.18);
color: #8a5a1a;
}
.ticket.is-hot .t-age {
background: rgba(179, 67, 42, 0.18);
color: var(--danger);
}
.t-lines {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.t-line {
display: grid;
grid-template-columns: 22px 1fr auto;
align-items: baseline;
gap: 8px;
padding: 4px 0;
cursor: pointer;
border-bottom: 1px dashed rgba(44, 26, 14, 0.08);
font-size: 0.86rem;
line-height: 1.35;
}
.t-line:last-child {
border-bottom: none;
}
.t-line-qty {
font-family: var(--font-mono);
font-weight: 700;
color: var(--terracotta-d);
font-size: 0.85rem;
}
.t-line-name {
color: var(--ink);
font-weight: 600;
}
.t-line-mod {
font-size: 0.72rem;
color: var(--warm-gray);
display: block;
margin-top: 2px;
}
.t-line-tag {
font-size: 0.62rem;
background: var(--cream-2);
color: var(--ink-2);
padding: 2px 7px;
border-radius: 999px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.t-line.is-done .t-line-name {
text-decoration: line-through;
color: var(--warm-gray);
}
.t-line.is-done .t-line-qty {
color: var(--warm-gray);
}
.t-foot {
display: flex;
gap: 6px;
margin-top: 4px;
}
.t-btn {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
color: var(--ink);
font-family: inherit;
font-size: 0.78rem;
font-weight: 700;
padding: 7px 12px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s;
}
.t-btn:hover {
background: var(--cream-2);
}
.t-btn-back {
flex: 0 0 auto;
}
.t-btn-next {
flex: 1;
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
.t-btn-next:hover {
background: var(--forest-d);
}
.ticket.is-served .t-btn-next {
display: none;
}
.ticket[data-status="new"] .t-btn-back {
visibility: hidden;
}
.col-empty {
text-align: center;
font-size: 0.78rem;
color: rgba(250, 247, 241, 0.4);
padding: 24px 12px;
font-style: italic;
}
@media (max-width: 1100px) {
.board {
grid-template-columns: repeat(2, 1fr);
overflow: auto;
}
}
@media (max-width: 640px) {
.board {
grid-template-columns: 1fr;
}
.topbar {
flex-wrap: wrap;
gap: 12px;
}
}const COLUMNS = [
{ id: "new", title: "New" },
{ id: "cook", title: "Cooking" },
{ id: "ready", title: "Ready" },
{ id: "served", title: "Served" },
];
const SEED = [
{
id: "t-401",
table: "Table 4",
course: "2nd · Mains",
status: "cook",
age: 9 * 60,
lines: [
{ qty: 1, name: "Ribeye 14oz", mod: "Medium rare · Truffle fries", tag: "Hot" },
{ qty: 1, name: "Branzino entero", mod: "No fennel" },
{ qty: 1, name: "Risotto hongos", mod: "Veg" },
],
},
{
id: "t-402",
table: "Table 9",
course: "1st · Apps",
status: "ready",
age: 4 * 60,
lines: [
{ qty: 2, name: "Burrata huerta" },
{ qty: 1, name: "Pulpo brasa", tag: "GF" },
],
},
{
id: "t-403",
table: "Bar 2",
course: "Drinks",
status: "new",
age: 22,
lines: [
{ qty: 2, name: "Negroni sbagliato" },
{ qty: 1, name: "Spritz", mod: "Less ice" },
],
},
{
id: "t-404",
table: "Table 12",
course: "2nd · Mains",
status: "cook",
age: 15 * 60 + 20,
lines: [
{ qty: 1, name: "Costilla cordero", tag: "Hot" },
{ qty: 1, name: "Pollo carbón" },
{ qty: 1, name: "Plato huerto", mod: "Allergy: nuts" },
],
},
{
id: "t-405",
table: "Take-out",
course: "Pickup",
status: "new",
age: 90,
lines: [
{ qty: 1, name: "Pappardelle ragú" },
{ qty: 2, name: "Pan masa madre" },
{ qty: 1, name: "Tarta de queso" },
],
},
{
id: "t-406",
table: "Table 7",
course: "Dessert",
status: "ready",
age: 2 * 60,
lines: [
{ qty: 1, name: "Olive oil cake" },
{ qty: 1, name: "Chocolate ganache" },
],
},
];
const RANDOM_LINES = [
{ qty: 1, name: "Burrata huerta", mod: "Add focaccia" },
{ qty: 2, name: "Croquetas jamón" },
{ qty: 1, name: "Ensalada huerta", tag: "Veg" },
{ qty: 1, name: "Ribeye 14oz", mod: "Medium" },
{ qty: 1, name: "Salmón plancha" },
{ qty: 2, name: "Tinto natural" },
];
const RANDOM_TABLES = ["Table 3", "Table 5", "Table 11", "Bar 1", "Take-out"];
const RANDOM_COURSES = ["1st · Apps", "2nd · Mains", "Drinks", "Dessert"];
let tickets = SEED.map((t) => ({
...t,
doneLines: new Set(),
createdAt: Date.now() - t.age * 1000,
}));
const board = document.getElementById("board");
const tpl = document.getElementById("ticketTpl");
const statActive = document.getElementById("statActive");
const statAvg = document.getElementById("statAvg");
const statOver = document.getElementById("statOver");
const clockEl = document.getElementById("clock");
function buildBoard() {
board.innerHTML = COLUMNS.map(
(col) => `
<section class="col" data-status="${col.id}">
<header class="col-head">
<span class="col-title">${col.title}</span>
<span class="col-count" data-count="${col.id}">0</span>
</header>
<div class="col-body" data-col="${col.id}"></div>
</section>`
).join("");
}
function ageString(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function ageBucket(seconds) {
if (seconds >= 14 * 60) return "is-hot";
if (seconds >= 8 * 60) return "is-warm";
return "";
}
function renderTickets() {
COLUMNS.forEach((col) => {
const wrap = board.querySelector(`[data-col="${col.id}"]`);
wrap.innerHTML = "";
});
tickets.forEach((t) => {
const wrap = board.querySelector(`[data-col="${t.status}"]`);
if (!wrap) return;
const node = tpl.content.cloneNode(true);
const article = node.querySelector("[data-ticket]");
article.dataset.id = t.id;
article.dataset.status = t.status;
const seconds = (Date.now() - t.createdAt) / 1000;
const bucket = t.status === "served" ? "is-served" : ageBucket(seconds);
if (bucket) article.classList.add(bucket);
node.querySelector(".t-table").textContent = t.table;
node.querySelector(".t-course").textContent = t.course;
node.querySelector("[data-age]").textContent = ageString(seconds);
const linesEl = node.querySelector("[data-lines]");
linesEl.innerHTML = t.lines
.map(
(l, idx) => `
<li class="t-line ${t.doneLines.has(idx) ? "is-done" : ""}" data-line="${idx}">
<span class="t-line-qty">${l.qty}×</span>
<div>
<span class="t-line-name">${l.name}</span>
${l.mod ? `<span class="t-line-mod">${l.mod}</span>` : ""}
</div>
${l.tag ? `<span class="t-line-tag">${l.tag}</span>` : ""}
</li>`
)
.join("");
wrap.appendChild(node);
});
// Counts & header stats
let activeCount = 0;
let overCount = 0;
let totalAge = 0;
let ageSamples = 0;
COLUMNS.forEach((col) => {
const inCol = tickets.filter((t) => t.status === col.id);
board.querySelector(`[data-count="${col.id}"]`).textContent = inCol.length;
if (col.id !== "served") activeCount += inCol.length;
inCol.forEach((t) => {
const sec = (Date.now() - t.createdAt) / 1000;
if (col.id !== "served") {
totalAge += sec;
ageSamples += 1;
if (sec >= 14 * 60) overCount += 1;
}
});
if (inCol.length === 0) {
const empty = document.createElement("p");
empty.className = "col-empty";
empty.textContent = "No tickets.";
board.querySelector(`[data-col="${col.id}"]`).appendChild(empty);
}
});
statActive.textContent = activeCount;
statAvg.textContent = ageSamples ? Math.round(totalAge / ageSamples / 60) : 0;
statOver.textContent = overCount;
}
function refreshAgesAndStats() {
let activeCount = 0;
let overCount = 0;
let totalAge = 0;
let ageSamples = 0;
tickets.forEach((t) => {
const ticketEl = board.querySelector(`[data-ticket][data-id="${t.id}"]`);
const sec = (Date.now() - t.createdAt) / 1000;
if (ticketEl) {
const ageEl = ticketEl.querySelector("[data-age]");
if (ageEl) ageEl.textContent = ageString(sec);
ticketEl.classList.remove("is-warm", "is-hot", "is-served");
const bucket = t.status === "served" ? "is-served" : ageBucket(sec);
if (bucket) ticketEl.classList.add(bucket);
}
if (t.status !== "served") {
activeCount += 1;
totalAge += sec;
ageSamples += 1;
if (sec >= 14 * 60) overCount += 1;
}
});
statActive.textContent = activeCount;
statAvg.textContent = ageSamples ? Math.round(totalAge / ageSamples / 60) : 0;
statOver.textContent = overCount;
}
board.addEventListener("click", (e) => {
const ticketEl = e.target.closest("[data-ticket]");
if (!ticketEl) return;
const t = tickets.find((x) => x.id === ticketEl.dataset.id);
if (!t) return;
const lineEl = e.target.closest("[data-line]");
if (lineEl) {
const idx = Number(lineEl.dataset.line);
if (t.doneLines.has(idx)) t.doneLines.delete(idx);
else t.doneLines.add(idx);
renderTickets();
return;
}
const actionBtn = e.target.closest("[data-action]");
if (actionBtn) {
const order = ["new", "cook", "ready", "served"];
const i = order.indexOf(t.status);
if (actionBtn.dataset.action === "next" && i < order.length - 1) t.status = order[i + 1];
if (actionBtn.dataset.action === "back" && i > 0) t.status = order[i - 1];
renderTickets();
}
});
document.getElementById("add").addEventListener("click", () => {
const lines = [];
const count = 2 + Math.floor(Math.random() * 2);
for (let i = 0; i < count; i++) {
const pick = RANDOM_LINES[Math.floor(Math.random() * RANDOM_LINES.length)];
lines.push({ ...pick });
}
tickets.unshift({
id: `t-${Date.now()}`,
table: RANDOM_TABLES[Math.floor(Math.random() * RANDOM_TABLES.length)],
course: RANDOM_COURSES[Math.floor(Math.random() * RANDOM_COURSES.length)],
status: "new",
age: 0,
createdAt: Date.now(),
lines,
doneLines: new Set(),
});
renderTickets();
});
function tickClock() {
const d = new Date();
clockEl.textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
tickClock();
setInterval(tickClock, 30000);
setInterval(refreshAgesAndStats, 1000);
buildBoard();
renderTickets();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>KDS · Kitchen Display</title>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-name">Casa Olivar</span>
<span class="brand-tag">KDS · Pass</span>
</div>
<div class="stats">
<span class="stat"><b id="statActive">0</b> active</span>
<span class="stat"><b id="statAvg">0</b>m avg</span>
<span class="stat"><b id="statOver">0</b> over</span>
</div>
<div class="topbar-right">
<button class="util-btn" id="add">+ New ticket</button>
<span class="clock" id="clock">--:--</span>
</div>
</header>
<main class="board" id="board"></main>
<template id="ticketTpl">
<article class="ticket" data-ticket>
<header class="t-head">
<div>
<p class="t-table"></p>
<p class="t-course"></p>
</div>
<span class="t-age" data-age>0:00</span>
</header>
<ul class="t-lines" data-lines></ul>
<footer class="t-foot">
<button class="t-btn t-btn-back" data-action="back" title="Recall">↶</button>
<button class="t-btn t-btn-next" data-action="next" title="Advance">Bump →</button>
</footer>
</article>
</template>
<script src="script.js"></script>
</body>
</html>Kitchen Display System
The expediter’s view of every active ticket. Four columns — New · Cooking · Ready · Served — each card shows its table, course, line items with a tap-to-strike, and a live age timer that turns amber after 8 minutes and red after 14. Cards advance with the Bump button, and a Recall button steps them back if a server returns the plate.
Lift of the kanban-board pattern, restyled in the shared warm palette and adapted for short-lived tickets rather than long-lived tasks.