Game — Quest Log / Objectives Tracker
A dark sci-fi RPG quest log with Main, Side, and Completed tabs, neon-accented quest cards showing type icons and animated progress bars, and a rich detail pane with lore description, clickable objective checklist, XP/gold/item reward chips, and a pulsing Track toggle. A compact on-screen HUD tracker widget mirrors the tracked quest in real time, and finishing every objective auto-completes the quest with a glowing success toast. Built with Orbitron/Inter typography, clipped-corner panels, vanilla JS, full keyboard support, and a responsive layout down to 360px.
MCP
Code
:root {
--bg: #0a0b10;
--bg-2: #12131c;
--panel: #171926;
--panel-2: #1f2233;
--text: #e7e9f3;
--muted: #9aa0bf;
--line: rgba(231, 233, 243, 0.10);
--line-2: rgba(231, 233, 243, 0.18);
--accent: #00e5ff;
--accent-2: #7c4dff;
--accent-3: #ff3d71;
--success: #36e27a;
--warn: #ffc857;
--danger: #ff4d4d;
--glow: 0 0 18px rgba(0, 229, 255, 0.45);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
font-family: "Inter", system-ui, sans-serif;
background:
radial-gradient(1100px 600px at 88% -10%, rgba(124, 77, 255, 0.16), transparent 60%),
radial-gradient(900px 520px at -5% 110%, rgba(0, 229, 255, 0.12), transparent 60%),
var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: relative;
}
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 5;
background: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.012) 0 1px,
transparent 1px 3px
);
mix-blend-mode: overlay;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 22px clamp(14px, 3vw, 30px) 120px;
}
/* ---------- topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
padding: 16px 20px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 20px 50px -30px rgba(0, 0, 0, 0.8);
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand__mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
font-size: 22px;
color: var(--accent);
background: radial-gradient(circle at 30% 30%, rgba(0, 229, 255, 0.25), rgba(124, 77, 255, 0.12));
border: 1px solid var(--line-2);
border-radius: var(--r-md);
text-shadow: var(--glow);
}
.brand__txt { display: flex; flex-direction: column; line-height: 1.15; }
.brand__txt strong {
font-family: "Orbitron", sans-serif;
font-weight: 900;
letter-spacing: 0.12em;
font-size: 0.96rem;
}
.brand__txt span { color: var(--muted); font-size: 0.72rem; letter-spacing: 0.06em; }
.hero-stats { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.hero-stat { display: flex; flex-direction: column; gap: 3px; min-width: 56px; }
.hero-stat--bar { min-width: 180px; }
.hero-stat__label {
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.hero-stat__val {
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 1.1rem;
}
.hero-stat__val.small { font-size: 0.74rem; color: var(--muted); font-weight: 500; }
.hero-stat__val.accent2 { color: var(--warn); }
.xpbar {
height: 8px;
width: 100%;
border-radius: 99px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--line);
overflow: hidden;
}
.xpbar > i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 12px rgba(0, 229, 255, 0.6);
border-radius: 99px;
}
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 18px;
margin-top: 18px;
}
.quests, .detail {
background: linear-gradient(180deg, var(--panel), var(--bg-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: 0 30px 70px -40px rgba(0, 0, 0, 0.9);
}
/* ---------- quest list ---------- */
.quests { padding: 16px; display: flex; flex-direction: column; min-height: 520px; }
.quests__head { display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px; }
.panel-title {
margin: 0;
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 1.02rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.tabs {
display: flex;
gap: 6px;
padding: 4px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 6px;
font-family: "Orbitron", sans-serif;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
cursor: pointer;
transition: color 0.18s, background 0.18s, box-shadow 0.18s;
}
.tab:hover { color: var(--text); }
.tab.is-active {
color: var(--bg);
background: linear-gradient(180deg, var(--accent), #06c3da);
box-shadow: var(--glow);
}
.tab__count {
display: inline-grid;
place-items: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-size: 0.64rem;
border-radius: 99px;
background: rgba(255, 255, 255, 0.12);
color: inherit;
}
.tab.is-active .tab__count { background: rgba(10, 11, 16, 0.25); }
.qlist {
list-style: none;
margin: 0;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 9px;
overflow-y: auto;
flex: 1;
}
.qlist__empty { color: var(--muted); text-align: center; padding: 40px 10px; font-size: 0.85rem; }
.qcard {
position: relative;
text-align: left;
width: 100%;
display: grid;
grid-template-columns: 40px 1fr;
gap: 12px;
padding: 12px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-left: 3px solid var(--qcol, var(--accent));
border-radius: var(--r-md);
color: var(--text);
cursor: pointer;
transition: border-color 0.18s, transform 0.12s, box-shadow 0.18s;
}
.qcard:hover { transform: translateX(2px); border-color: var(--line-2); box-shadow: 0 0 0 1px var(--line-2); }
.qcard.is-active {
border-color: var(--qcol, var(--accent));
box-shadow: 0 0 0 1px var(--qcol, var(--accent)), 0 0 22px -6px var(--qcol, var(--accent));
}
.qcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.qcard__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
font-size: 18px;
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--qcol, var(--accent)) 16%, transparent);
color: var(--qcol, var(--accent));
border: 1px solid var(--line);
}
.qcard__body { min-width: 0; }
.qcard__type {
font-family: "Orbitron", sans-serif;
font-size: 0.56rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--qcol, var(--accent));
}
.qcard__title {
font-weight: 700;
font-size: 0.9rem;
margin: 2px 0 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qcard__prog { display: flex; align-items: center; gap: 8px; }
.qcard__bar {
flex: 1;
height: 5px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.qcard__bar > i {
display: block;
height: 100%;
border-radius: 99px;
background: var(--qcol, var(--accent));
transition: width 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.qcard__count { font-size: 0.66rem; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.qcard__track-flag {
position: absolute;
top: 8px;
right: 8px;
font-size: 12px;
color: var(--accent);
text-shadow: var(--glow);
}
.qcard.is-done .qcard__title { color: var(--muted); }
.qcard.is-done .qcard__icon { opacity: 0.7; }
/* ---------- detail ---------- */
.detail { padding: 22px clamp(16px, 2.4vw, 28px); min-height: 520px; }
.detail__empty {
height: 100%;
display: grid;
place-items: center;
text-align: center;
color: var(--muted);
}
.detail__empty span { font-size: 40px; display: block; margin-bottom: 10px; color: var(--accent); opacity: 0.6; }
.d-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.d-eyebrow {
font-family: "Orbitron", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--qcol, var(--accent));
}
.d-title {
font-family: "Orbitron", sans-serif;
font-weight: 900;
font-size: clamp(1.3rem, 3vw, 1.85rem);
letter-spacing: 0.02em;
margin: 4px 0 0;
line-height: 1.15;
}
.d-meta { display: flex; gap: 14px; margin-top: 8px; flex-wrap: wrap; }
.d-meta span { font-size: 0.74rem; color: var(--muted); }
.d-meta b { color: var(--text); font-weight: 600; }
.track-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 18px;
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 0.74rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text);
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
border-radius: var(--r-md);
cursor: pointer;
transition: all 0.18s;
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
}
.track-btn:hover { border-color: var(--accent); color: var(--accent); }
.track-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.track-btn.is-on {
color: var(--bg);
background: linear-gradient(180deg, var(--accent), #06c3da);
border-color: transparent;
box-shadow: var(--glow);
animation: pulse 2.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 16px rgba(0, 229, 255, 0.4); }
50% { box-shadow: 0 0 26px rgba(0, 229, 255, 0.75); }
}
.d-desc { color: var(--muted); margin: 18px 0 22px; max-width: 62ch; font-size: 0.94rem; }
.d-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--line);
border-radius: var(--r-md);
margin-bottom: 20px;
}
.d-progress__label { font-family: "Orbitron", sans-serif; font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
.d-progress__bar { flex: 1; height: 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
.d-progress__bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--qcol, var(--accent)), var(--accent-2)); transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1); box-shadow: 0 0 12px var(--qcol, var(--accent)); }
.d-progress__pct { font-family: "Orbitron", sans-serif; font-weight: 700; font-size: 0.92rem; min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; }
.section-h {
font-family: "Orbitron", sans-serif;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-h::after { content: ""; flex: 1; height: 1px; background: var(--line); }
/* objectives */
.objs { list-style: none; margin: 0 0 24px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.obj {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 13px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: var(--r-md);
cursor: pointer;
transition: border-color 0.18s, background 0.18s;
}
.obj:hover { border-color: var(--line-2); }
.obj__box {
flex-shrink: 0;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: 1.5px solid var(--line-2);
border-radius: 6px;
color: transparent;
font-size: 13px;
font-weight: 800;
transition: all 0.18s;
}
.obj__text { font-size: 0.9rem; }
.obj.is-done .obj__box {
background: linear-gradient(180deg, var(--success), #1fae5a);
border-color: transparent;
color: var(--bg);
box-shadow: 0 0 12px rgba(54, 226, 122, 0.5);
}
.obj.is-done .obj__text { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--line-2); }
.obj:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* rewards */
.rewards { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; }
.reward {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line);
border-radius: var(--r-md);
clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
}
.reward__icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
font-size: 18px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: color-mix(in srgb, var(--rcol, var(--accent)) 16%, transparent);
color: var(--rcol, var(--accent));
}
.reward__txt { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.reward__val { font-family: "Orbitron", sans-serif; font-weight: 700; font-size: 0.92rem; }
.reward__name { font-size: 0.68rem; color: var(--muted); letter-spacing: 0.04em; }
/* ---------- tracker widget ---------- */
.tracker {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
width: 280px;
max-width: calc(100vw - 36px);
padding: 14px;
background: linear-gradient(180deg, rgba(31, 34, 51, 0.96), rgba(18, 19, 28, 0.97));
backdrop-filter: blur(8px);
border: 1px solid var(--line-2);
border-left: 3px solid var(--accent);
border-radius: var(--r-md);
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.15), 0 24px 60px -20px rgba(0, 0, 0, 0.85), var(--glow);
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
animation: trackerIn 0.25s ease;
}
@keyframes trackerIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
.tracker__head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.tracker__pin { color: var(--accent); font-size: 13px; text-shadow: var(--glow); }
.tracker__title {
flex: 1;
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tracker__close {
width: 22px; height: 22px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--muted);
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: all 0.18s;
}
.tracker__close:hover { color: var(--danger); border-color: var(--danger); }
.tracker__close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tracker__objs { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.tracker__obj { display: flex; align-items: center; gap: 8px; font-size: 0.78rem; color: var(--muted); }
.tracker__obj::before { content: "○"; color: var(--muted); font-size: 0.7rem; }
.tracker__obj.is-done { color: var(--text); }
.tracker__obj.is-done::before { content: "✓"; color: var(--success); }
.tracker__obj.is-done span { text-decoration: line-through; opacity: 0.7; }
.tracker__foot { display: flex; align-items: center; gap: 8px; }
.tracker__bar { flex: 1; height: 6px; border-radius: 99px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
.tracker__bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width 0.4s ease; box-shadow: 0 0 10px rgba(0, 229, 255, 0.6); }
.tracker__pct { font-family: "Orbitron", sans-serif; font-weight: 700; font-size: 0.72rem; min-width: 36px; text-align: right; }
/* ---------- toast ---------- */
.toast-wrap {
position: fixed;
top: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 40;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
font-family: "Orbitron", sans-serif;
font-weight: 700;
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--text);
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
border-left: 3px solid var(--success);
border-radius: var(--r-md);
box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.9), 0 0 22px -6px rgba(54, 226, 122, 0.5);
animation: toastIn 0.3s ease, toastOut 0.35s ease 2.4s forwards;
}
.toast__icon { color: var(--success); font-size: 15px; }
@keyframes toastIn { from { opacity: 0; transform: translateY(-14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(-14px); } }
/* ---------- responsive ---------- */
@media (max-width: 880px) {
.layout { grid-template-columns: 1fr; }
.quests { min-height: auto; }
.qlist { max-height: 320px; }
.detail { min-height: auto; }
}
@media (max-width: 520px) {
.app { padding: 14px 12px 130px; }
.topbar { padding: 12px 14px; gap: 12px; }
.hero-stats { gap: 12px; width: 100%; }
.hero-stat--bar { min-width: 140px; flex: 1; }
.tabs { flex-wrap: wrap; }
.tab { font-size: 0.6rem; padding: 8px 4px; }
.d-head { flex-direction: column; }
.track-btn { width: 100%; justify-content: center; }
.rewards { grid-template-columns: 1fr 1fr; }
.tracker { right: 10px; left: 10px; bottom: 10px; width: auto; }
.toast-wrap { left: 12px; right: 12px; transform: none; }
.toast { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}(function () {
"use strict";
/* ---------------- data ---------------- */
var TYPE_META = {
main: { label: "Main Quest", icon: "❖", color: "#00e5ff" },
side: { label: "Side Quest", icon: "✦", color: "#7c4dff" },
bounty: { label: "Bounty", icon: "☠", color: "#ff3d71" },
};
var quests = [
{
id: "q-ashfall",
kind: "main",
tab: "main",
title: "The Ashfall Reckoning",
zone: "Cinderhold Keep",
giver: "Warden Sela Voryn",
desc:
"The Ashen Vanguard has fallen to the Hollow Reign. Breach Cinderhold Keep, recover the Emberglass Sigil, and confront the renegade commander before the rift fully opens over Nullspire.",
tracked: true,
objectives: [
{ text: "Infiltrate the outer ramparts", done: true },
{ text: "Disable the three rift-anchors", done: true },
{ text: "Recover the Emberglass Sigil", done: false },
{ text: "Defeat Commander Drael", done: false },
],
rewards: [
{ type: "xp", val: "4,500", name: "Experience" },
{ type: "gold", val: "1,200", name: "Gold" },
{ type: "item", val: "Emberglass Blade", name: "Legendary" },
],
},
{
id: "q-tidewatch",
kind: "main",
tab: "main",
title: "Echoes of the Tidewatch",
zone: "Drowned Causeway",
giver: "Oracle Myrren",
desc:
"Strange signals pulse beneath the Drowned Causeway. Restore the ancient Tidewatch beacons and uncover what the deep is trying to warn the Vanguard about.",
tracked: false,
objectives: [
{ text: "Speak with Oracle Myrren", done: true },
{ text: "Reignite the western beacon", done: false },
{ text: "Reignite the eastern beacon", done: false },
{ text: "Descend into the Tidewatch vault", done: false },
],
rewards: [
{ type: "xp", val: "3,200", name: "Experience" },
{ type: "gold", val: "800", name: "Gold" },
{ type: "item", val: "Tidewoven Cloak", name: "Epic" },
],
},
{
id: "q-neondrift",
kind: "side",
tab: "side",
title: "Neon Drift Circuit",
zone: "Lower Nullspire",
giver: "Racer 'Vex' Okonel",
desc:
"The Neon Drift street league is recruiting. Win three sanctioned races through the Lower Nullspire grid to earn the trust of the Drift crew.",
tracked: false,
objectives: [
{ text: "Win the Coolant Run", done: true },
{ text: "Win the Spire Loop", done: true },
{ text: "Win the Blackout Sprint", done: false },
],
rewards: [
{ type: "xp", val: "1,600", name: "Experience" },
{ type: "gold", val: "650", name: "Gold" },
{ type: "item", val: "Pulse Engine Mk II", name: "Rare" },
],
},
{
id: "q-herbalist",
kind: "side",
tab: "side",
title: "The Glasswort Gatherer",
zone: "Verdant Hollows",
giver: "Herbalist Tomik",
desc:
"Tomik needs rare glasswort blossoms that only bloom under moonlight in the Verdant Hollows. Gather them before the frost takes the last of the season.",
tracked: false,
objectives: [
{ text: "Collect 5 glasswort blossoms", done: false },
{ text: "Return to Herbalist Tomik", done: false },
],
rewards: [
{ type: "xp", val: "900", name: "Experience" },
{ type: "gold", val: "300", name: "Gold" },
{ type: "item", val: "Healing Draughts ×3", name: "Common" },
],
},
{
id: "q-bounty-grell",
kind: "bounty",
tab: "side",
title: "Bounty: The Grell Marauder",
zone: "Rustwild Expanse",
giver: "Bounty Board",
desc:
"A Grell marauder has been raiding caravans along the Rustwild trade road. The Vanguard has posted a bounty for its head — dead, preferably.",
tracked: false,
objectives: [
{ text: "Track the marauder's lair", done: true },
{ text: "Slay the Grell Marauder", done: false },
{ text: "Claim the bounty", done: false },
],
rewards: [
{ type: "gold", val: "1,500", name: "Gold" },
{ type: "item", val: "Marauder Trophy", name: "Rare" },
],
},
{
id: "q-firstlight",
kind: "main",
tab: "completed",
title: "First Light at Nullspire",
zone: "Nullspire Gates",
giver: "Warden Sela Voryn",
desc:
"Your arrival at Nullspire marked the dawn of the Vanguard's last stand. The gates are secured and the watch is set.",
tracked: false,
objectives: [
{ text: "Reach the Nullspire Gates", done: true },
{ text: "Light the signal brazier", done: true },
{ text: "Report to Warden Voryn", done: true },
],
rewards: [
{ type: "xp", val: "1,200", name: "Experience" },
{ type: "gold", val: "400", name: "Gold" },
],
},
];
var REWARD_META = {
xp: { icon: "✸", color: "#00e5ff" },
gold: { icon: "◉", color: "#ffc857" },
item: { icon: "▣", color: "#7c4dff" },
};
/* ---------------- state ---------------- */
var activeTab = "main";
var selectedId = null;
/* ---------------- dom ---------------- */
var qlistEl = document.getElementById("qlist");
var qlistEmpty = document.getElementById("qlistEmpty");
var detailEl = document.getElementById("detail");
var tabsEl = document.querySelectorAll(".tab");
var trackerEl = document.getElementById("tracker");
var trackerTitle = document.getElementById("trackerTitle");
var trackerObjs = document.getElementById("trackerObjs");
var trackerFill = document.getElementById("trackerFill");
var trackerPct = document.getElementById("trackerPct");
var trackerClose = document.getElementById("trackerClose");
var toastWrap = document.getElementById("toastWrap");
/* ---------------- helpers ---------------- */
function byId(id) {
for (var i = 0; i < quests.length; i++) if (quests[i].id === id) return quests[i];
return null;
}
function progress(q) {
var done = 0;
for (var i = 0; i < q.objectives.length; i++) if (q.objectives[i].done) done++;
return { done: done, total: q.objectives.length, pct: Math.round((done / q.objectives.length) * 100) };
}
function trackedQuest() {
for (var i = 0; i < quests.length; i++) if (quests[i].tracked && quests[i].tab !== "completed") return quests[i];
return null;
}
function esc(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast";
var icon = kind === "track" ? "◈" : "✓";
el.innerHTML = '<span class="toast__icon">' + icon + "</span><span>" + esc(msg) + "</span>";
toastWrap.appendChild(el);
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 2900);
}
/* ---------------- render: list ---------------- */
function renderTabCounts() {
var counts = { main: 0, side: 0, completed: 0 };
quests.forEach(function (q) {
if (counts[q.tab] !== undefined) counts[q.tab]++;
});
document.querySelectorAll(".tab__count").forEach(function (c) {
c.textContent = counts[c.getAttribute("data-count")] || 0;
});
}
function renderList() {
qlistEl.innerHTML = "";
var rows = quests.filter(function (q) {
return q.tab === activeTab;
});
qlistEmpty.hidden = rows.length > 0;
rows.forEach(function (q) {
var meta = TYPE_META[q.kind];
var p = progress(q);
var li = document.createElement("li");
var btn = document.createElement("button");
btn.className = "qcard" + (q.id === selectedId ? " is-active" : "") + (q.tab === "completed" ? " is-done" : "");
btn.setAttribute("role", "option");
btn.setAttribute("aria-selected", q.id === selectedId ? "true" : "false");
btn.style.setProperty("--qcol", meta.color);
btn.dataset.id = q.id;
btn.innerHTML =
(q.tracked && q.tab !== "completed" ? '<span class="qcard__track-flag" title="Tracked">◈</span>' : "") +
'<span class="qcard__icon">' + meta.icon + "</span>" +
'<span class="qcard__body">' +
'<span class="qcard__type">' + meta.label + "</span>" +
'<span class="qcard__title">' + esc(q.title) + "</span>" +
'<span class="qcard__prog">' +
'<span class="qcard__bar"><i style="width:' + p.pct + '%"></i></span>' +
'<span class="qcard__count">' + p.done + "/" + p.total + "</span>" +
"</span>" +
"</span>";
btn.addEventListener("click", function () {
selectQuest(q.id);
});
li.appendChild(btn);
qlistEl.appendChild(li);
});
renderTabCounts();
}
/* ---------------- render: detail ---------------- */
function renderDetail() {
var q = selectedId ? byId(selectedId) : null;
if (!q) {
detailEl.innerHTML =
'<div class="detail__empty"><div><span>❖</span>Select a quest from the log to view its objectives and rewards.</div></div>';
return;
}
var meta = TYPE_META[q.kind];
var p = progress(q);
var objsHtml = q.objectives
.map(function (o, i) {
return (
'<li class="obj' +
(o.done ? " is-done" : "") +
'" role="checkbox" tabindex="0" aria-checked="' +
(o.done ? "true" : "false") +
'" data-idx="' +
i +
'">' +
'<span class="obj__box">✓</span>' +
'<span class="obj__text">' +
esc(o.text) +
"</span></li>"
);
})
.join("");
var rewardsHtml = q.rewards
.map(function (r) {
var rm = REWARD_META[r.type] || REWARD_META.item;
return (
'<div class="reward" style="--rcol:' +
rm.color +
'"><span class="reward__icon">' +
rm.icon +
'</span><span class="reward__txt"><span class="reward__val">' +
esc(r.val) +
'</span><span class="reward__name">' +
esc(r.name) +
"</span></span></div>"
);
})
.join("");
var isCompleted = q.tab === "completed";
var trackBtn = isCompleted
? ""
: '<button class="track-btn' +
(q.tracked ? " is-on" : "") +
'" id="trackBtn" aria-pressed="' +
(q.tracked ? "true" : "false") +
'">◈ ' +
(q.tracked ? "Tracking" : "Track Quest") +
"</button>";
detailEl.style.setProperty("--qcol", meta.color);
detailEl.innerHTML =
'<div class="d-head"><div>' +
'<div class="d-eyebrow">' + meta.icon + " " + meta.label + (isCompleted ? " · Completed" : "") + "</div>" +
'<h2 class="d-title">' + esc(q.title) + "</h2>" +
'<div class="d-meta"><span>Zone: <b>' + esc(q.zone) + "</b></span><span>Given by: <b>" + esc(q.giver) + "</b></span></div>" +
"</div>" + trackBtn + "</div>" +
'<p class="d-desc">' + esc(q.desc) + "</p>" +
'<div class="d-progress"><span class="d-progress__label">Progress</span>' +
'<div class="d-progress__bar"><i style="width:' + p.pct + '%"></i></div>' +
'<span class="d-progress__pct">' + p.pct + "%</span></div>" +
'<h3 class="section-h">Objectives</h3><ul class="objs">' + objsHtml + "</ul>" +
'<h3 class="section-h">Rewards</h3><div class="rewards">' + rewardsHtml + "</div>";
// wire objectives
if (!isCompleted) {
detailEl.querySelectorAll(".obj").forEach(function (el) {
function toggle() {
toggleObjective(q.id, parseInt(el.dataset.idx, 10));
}
el.addEventListener("click", toggle);
el.addEventListener("keydown", function (e) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
toggle();
}
});
});
var tb = document.getElementById("trackBtn");
if (tb) tb.addEventListener("click", function () { toggleTrack(q.id); });
}
}
/* ---------------- render: tracker ---------------- */
function renderTracker() {
var q = trackedQuest();
if (!q) {
trackerEl.hidden = true;
return;
}
trackerEl.hidden = false;
var meta = TYPE_META[q.kind];
trackerEl.style.borderLeftColor = meta.color;
trackerTitle.textContent = q.title;
trackerObjs.innerHTML = q.objectives
.map(function (o) {
return '<li class="tracker__obj' + (o.done ? " is-done" : "") + '"><span>' + esc(o.text) + "</span></li>";
})
.join("");
var p = progress(q);
trackerFill.style.width = p.pct + "%";
trackerPct.textContent = p.pct + "%";
}
/* ---------------- actions ---------------- */
function selectQuest(id) {
selectedId = id;
renderList();
renderDetail();
}
function toggleTrack(id) {
var q = byId(id);
if (!q) return;
if (q.tracked) {
q.tracked = false;
toast("Quest untracked", "track");
} else {
quests.forEach(function (x) { x.tracked = false; });
q.tracked = true;
toast("Now tracking: " + q.title, "track");
}
renderList();
renderDetail();
renderTracker();
}
function toggleObjective(id, idx) {
var q = byId(id);
if (!q || q.tab === "completed") return;
q.objectives[idx].done = !q.objectives[idx].done;
var p = progress(q);
if (p.done === p.total) {
// complete the quest
q.tab = "completed";
var wasTracked = q.tracked;
q.tracked = false;
toast("Quest Complete: " + q.title);
if (wasTracked) {
// re-render keeps tracker hidden since completed quests aren't tracked
}
}
renderList();
renderDetail();
renderTracker();
}
/* ---------------- tabs ---------------- */
tabsEl.forEach(function (tab) {
tab.addEventListener("click", function () {
activeTab = tab.dataset.tab;
tabsEl.forEach(function (t) {
var on = t === tab;
t.classList.toggle("is-active", on);
t.setAttribute("aria-selected", on ? "true" : "false");
});
// keep selection visible only if it belongs to this tab
var sel = selectedId ? byId(selectedId) : null;
if (!sel || sel.tab !== activeTab) selectedId = null;
renderList();
renderDetail();
});
});
trackerClose.addEventListener("click", function () {
var q = trackedQuest();
if (q) toggleTrack(q.id);
});
/* ---------------- init ---------------- */
var firstTracked = trackedQuest();
selectedId = firstTracked ? firstTracked.id : (quests[0] ? quests[0].id : null);
renderList();
renderDetail();
renderTracker();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quest Log — Ashen Vanguard</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=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<main class="app" role="main">
<header class="topbar">
<div class="brand">
<span class="brand__mark" aria-hidden="true">◣</span>
<div class="brand__txt">
<strong>ASHEN VANGUARD</strong>
<span>Nullforge Studios</span>
</div>
</div>
<div class="hero-stats" aria-label="Player status">
<div class="hero-stat">
<span class="hero-stat__label">Level</span>
<span class="hero-stat__val">27</span>
</div>
<div class="hero-stat hero-stat--bar">
<span class="hero-stat__label">XP</span>
<div class="xpbar"><i style="width:62%"></i></div>
<span class="hero-stat__val small">8,420 / 13,500</span>
</div>
<div class="hero-stat">
<span class="hero-stat__label">Gold</span>
<span class="hero-stat__val accent2">4,180</span>
</div>
</div>
</header>
<section class="layout">
<!-- LEFT: quest list -->
<aside class="quests" aria-label="Quest list">
<div class="quests__head">
<h1 class="panel-title">Quest Log</h1>
<div class="tabs" role="tablist" aria-label="Quest categories">
<button class="tab is-active" role="tab" aria-selected="true" data-tab="main">
Main <span class="tab__count" data-count="main">0</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-tab="side">
Side <span class="tab__count" data-count="side">0</span>
</button>
<button class="tab" role="tab" aria-selected="false" data-tab="completed">
Done <span class="tab__count" data-count="completed">0</span>
</button>
</div>
</div>
<ul class="qlist" id="qlist" role="listbox" aria-label="Quests"></ul>
<p class="qlist__empty" id="qlistEmpty" hidden>No quests here yet, vanguard.</p>
</aside>
<!-- RIGHT: detail -->
<section class="detail" id="detail" aria-live="polite">
<!-- populated by JS -->
</section>
</section>
</main>
<!-- on-screen tracker widget -->
<div class="tracker" id="tracker" hidden aria-label="Tracked quest">
<div class="tracker__head">
<span class="tracker__pin" aria-hidden="true">◈</span>
<span class="tracker__title" id="trackerTitle">—</span>
<button class="tracker__close" id="trackerClose" aria-label="Untrack quest">×</button>
</div>
<ul class="tracker__objs" id="trackerObjs"></ul>
<div class="tracker__foot">
<div class="tracker__bar"><i id="trackerFill" style="width:0%"></i></div>
<span class="tracker__pct" id="trackerPct">0%</span>
</div>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="assertive" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Quest Log / Objectives Tracker
A full in-game quest journal for the fictional RPG Ashen Vanguard by Nullforge Studios. The left pane lists quests under Main, Side, and Completed tabs — each card shows a type icon (main quest, side quest, or bounty), title, and an animated progress bar with an objective count. The right pane renders the selected quest in full: zone, quest giver, lore description, a clickable objective checklist, reward chips (XP, gold, and rarity-tagged items), and a glowing Track toggle.
Everything is wired with vanilla JS. Switching tabs filters the list and keeps counts live, selecting a quest repaints the detail pane in its accent color, and toggling objectives animates both progress bars in sync. Tracking a quest pins a compact HUD widget to the corner of the screen that mirrors its objectives and completion percentage in real time — exactly like an on-screen tracker in a AAA RPG.
Complete every objective and the quest auto-moves to the Completed tab with a neon success toast, untracking itself along the way. Objectives are keyboard-operable checkboxes with aria-checked, tabs use proper role="tab" semantics, and the layout collapses cleanly to a single column on small screens.
Illustrative UI only — fictional games, studios, characters, and data. Not engine integrations.