Widget — Add-widget / customize panel
A self-contained Plotline Insights dashboard with an Add-widget flow — a button slides open a focus-trapped customize panel that lists every available widget as a card with an icon, description and an on/off toggle. Search and category chips filter the catalog, toggling a widget drops it straight into the live 12-column grid, and a remove control pulls it back out with a tidy empty state when nothing is left. Widgets carry KPI sparklines, an inline SVG line chart, bars, a donut, a ranked table and a live-ticking activity feed.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
p {
margin: 0;
}
button {
font-family: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* ---------- Layout shell ---------- */
.app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--white);
border-right: 1px solid var(--line);
padding: 22px 16px;
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
}
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
background: linear-gradient(135deg, var(--brand), var(--brand-700));
color: #fff;
font-size: 15px;
box-shadow: var(--sh-1);
}
.brand-name {
font-weight: 700;
font-size: 15px;
letter-spacing: -0.01em;
}
.brand-name em {
font-style: normal;
color: var(--muted);
font-weight: 500;
}
.nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 12px;
border-radius: var(--r-sm);
text-decoration: none;
color: var(--ink-2);
font-size: 14px;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: var(--bg);
color: var(--ink);
}
.nav-item.is-active {
background: var(--brand-50);
color: var(--brand-d);
font-weight: 600;
}
.nav-ico {
font-size: 14px;
width: 18px;
text-align: center;
opacity: 0.85;
}
.nav-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 11px;
padding: 10px;
border-radius: var(--r-md);
background: var(--bg);
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--accent), #0a8f86);
color: #fff;
font-size: 12px;
font-weight: 700;
}
.who {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.who strong {
font-size: 13px;
}
.who span {
font-size: 11.5px;
color: var(--muted);
}
.main-col {
display: flex;
flex-direction: column;
min-width: 0;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 28px;
background: rgba(246, 247, 251, 0.85);
backdrop-filter: saturate(1.4) blur(8px);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 20;
}
.head-text h1 {
font-size: 19px;
font-weight: 700;
letter-spacing: -0.02em;
}
.head-text p {
font-size: 13px;
color: var(--muted);
}
.head-tools {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.range {
position: relative;
display: inline-flex;
align-items: center;
}
.range-ico {
position: absolute;
right: 12px;
font-size: 10px;
color: var(--muted);
pointer-events: none;
}
.range select {
appearance: none;
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 8px 30px 8px 12px;
box-shadow: var(--sh-1);
}
.range select:hover {
border-color: var(--line-2);
}
.count-chip {
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 12px;
white-space: nowrap;
}
.icon-btn {
width: 38px;
height: 38px;
display: grid;
place-items: center;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--ink-2);
font-size: 15px;
transition: background 0.15s, border-color 0.15s;
}
.icon-btn:hover {
background: var(--bg);
border-color: var(--line-2);
}
.nav-toggle {
display: none;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13.5px;
font-weight: 600;
color: #fff;
background: var(--brand);
border: 1px solid var(--brand-d);
border-radius: var(--r-sm);
padding: 9px 15px;
box-shadow: var(--sh-1);
transition: background 0.15s, transform 0.05s;
}
.btn-primary:hover {
background: var(--brand-d);
}
.btn-primary:active {
transform: translateY(1px);
}
.btn-ghost {
font-size: 13.5px;
font-weight: 600;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 9px 16px;
transition: background 0.15s, border-color 0.15s;
}
.btn-ghost:hover {
background: var(--bg);
border-color: var(--line-2);
}
/* ---------- Board ---------- */
.board-wrap {
padding: 24px 28px 48px;
flex: 1;
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 18px;
}
/* widget cells */
.widget {
grid-column: span 4;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
padding: 16px 18px 18px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
animation: pop 0.28s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
.widget.span-6 {
grid-column: span 6;
}
.widget.span-8 {
grid-column: span 8;
}
.widget.removing {
animation: shrink 0.22s ease forwards;
pointer-events: none;
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes shrink {
to {
opacity: 0;
transform: scale(0.94);
}
}
.w-head {
display: flex;
align-items: flex-start;
gap: 8px;
}
.w-icn {
width: 30px;
height: 30px;
flex: 0 0 auto;
border-radius: 9px;
display: grid;
place-items: center;
font-size: 14px;
background: var(--brand-50);
color: var(--brand-d);
}
.w-icn.t-chart {
background: var(--accent-soft);
color: #07857c;
}
.w-icn.t-data {
background: #fff0e2;
color: var(--warn);
}
.w-title {
min-width: 0;
}
.w-title h3 {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.w-title p {
font-size: 12px;
color: var(--muted);
}
.w-remove {
margin-left: auto;
width: 28px;
height: 28px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid transparent;
background: transparent;
border-radius: var(--r-sm);
color: var(--muted);
font-size: 13px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.w-remove:hover {
background: #fdece9;
color: var(--danger);
border-color: rgba(212, 80, 62, 0.25);
}
/* KPI body */
.kpi-value {
font-size: 27px;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1;
display: flex;
align-items: baseline;
gap: 9px;
}
.delta {
font-size: 12px;
font-weight: 700;
padding: 2px 7px;
border-radius: 999px;
}
.delta.is-up {
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
}
.delta.is-down {
color: var(--danger);
background: rgba(212, 80, 62, 0.12);
}
.kpi-cap {
font-size: 12px;
color: var(--muted);
}
.spark {
display: block;
width: 100%;
height: 38px;
margin-top: auto;
}
/* chart bodies */
.chart-box {
width: 100%;
}
.chart-box svg {
display: block;
width: 100%;
height: auto;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
margin-top: 4px;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: var(--ink-2);
}
.legend i {
width: 9px;
height: 9px;
border-radius: 3px;
display: inline-block;
}
/* table body */
.w-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.w-table th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 600;
padding: 0 0 8px;
border-bottom: 1px solid var(--line);
}
.w-table td {
padding: 9px 0;
border-bottom: 1px solid var(--line);
color: var(--ink-2);
}
.w-table tr:last-child td {
border-bottom: 0;
}
.w-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--ink);
}
.pill {
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: 999px;
}
.pill.ok {
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
}
.pill.warn {
color: var(--warn);
background: rgba(217, 138, 43, 0.14);
}
/* activity body */
.feed {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.feed li {
display: flex;
gap: 11px;
align-items: flex-start;
}
.feed .fdot {
width: 9px;
height: 9px;
border-radius: 50%;
margin-top: 5px;
flex: 0 0 auto;
background: var(--brand);
}
.feed .fdot.ok {
background: var(--ok);
}
.feed .fdot.warn {
background: var(--warn);
}
.feed .ftxt {
font-size: 12.5px;
color: var(--ink-2);
line-height: 1.35;
}
.feed .ftxt strong {
color: var(--ink);
font-weight: 600;
}
.feed .ftime {
font-size: 11px;
color: var(--muted);
}
.w-foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: var(--muted);
margin-top: auto;
padding-top: 4px;
}
.w-foot .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
}
.w-foot .live {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--accent);
font-weight: 600;
}
.w-foot .live::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: blink 1.6s ease-in-out infinite;
}
@keyframes blink {
50% {
opacity: 0.25;
}
}
/* ---------- Empty state ---------- */
.empty {
text-align: center;
max-width: 460px;
margin: 8vh auto 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.empty-art svg {
margin-bottom: 8px;
}
.es-fill {
fill: var(--brand-50);
}
.es-fill2 {
fill: var(--accent-soft);
}
.es-line {
stroke: var(--line-2);
stroke-width: 1.5;
}
.es-plus {
stroke: var(--brand);
stroke-width: 2.4;
stroke-linecap: round;
}
.empty h2 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
}
.empty p {
font-size: 13.5px;
color: var(--muted);
margin-bottom: 8px;
}
/* ---------- Customize panel ---------- */
.scrim {
position: fixed;
inset: 0;
background: rgba(16, 19, 34, 0.42);
backdrop-filter: blur(2px);
z-index: 40;
opacity: 0;
animation: fade 0.2s ease forwards;
}
@keyframes fade {
to {
opacity: 1;
}
}
.panel {
position: fixed;
top: 0;
right: 0;
height: 100vh;
width: min(420px, 100vw);
background: var(--white);
border-left: 1px solid var(--line);
box-shadow: -16px 0 40px rgba(16, 19, 34, 0.14);
z-index: 50;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.3, 0.8, 0.3, 1);
}
.panel.is-open {
transform: none;
}
.panel-head {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 20px 20px 14px;
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.02em;
}
.panel-sub {
font-size: 12.5px;
color: var(--muted);
margin-top: 2px;
}
.panel-head .icon-btn {
margin-left: auto;
}
.panel-tools {
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 12px;
border-bottom: 1px solid var(--line);
}
.search {
position: relative;
display: flex;
align-items: center;
}
.search-ico {
position: absolute;
left: 12px;
color: var(--muted);
font-size: 15px;
pointer-events: none;
}
.search input {
width: 100%;
font-family: inherit;
font-size: 13.5px;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 9px 12px 9px 34px;
}
.search input:focus-visible {
background: var(--white);
border-color: var(--brand);
outline-offset: 0;
}
.chips {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.chip {
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 13px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover {
border-color: var(--line-2);
}
.chip.is-on {
background: var(--brand-50);
color: var(--brand-d);
border-color: transparent;
}
.catalog {
flex: 1;
overflow-y: auto;
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.cat-card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--white);
transition: border-color 0.15s, box-shadow 0.15s;
}
.cat-card:hover {
border-color: var(--line-2);
box-shadow: var(--sh-1);
}
.cat-card.is-added {
border-color: rgba(91, 91, 240, 0.45);
background: linear-gradient(0deg, var(--brand-50), var(--white));
}
.cat-icn {
width: 36px;
height: 36px;
flex: 0 0 auto;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 16px;
background: var(--brand-50);
color: var(--brand-d);
}
.cat-icn.t-chart {
background: var(--accent-soft);
color: #07857c;
}
.cat-icn.t-data {
background: #fff0e2;
color: var(--warn);
}
.cat-meta {
min-width: 0;
flex: 1;
}
.cat-meta h3 {
font-size: 13.5px;
font-weight: 600;
display: flex;
align-items: center;
gap: 7px;
}
.cat-tag {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
background: var(--bg);
border-radius: 4px;
padding: 1px 6px;
}
.cat-meta p {
font-size: 12px;
color: var(--muted);
margin-top: 3px;
}
/* toggle switch */
.toggle {
flex: 0 0 auto;
align-self: center;
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--line-2);
border: none;
transition: background 0.18s;
}
.toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: var(--sh-1);
transition: transform 0.18s;
}
.toggle[aria-pressed="true"] {
background: var(--brand);
}
.toggle[aria-pressed="true"]::after {
transform: translateX(18px);
}
.no-results {
padding: 28px 20px;
text-align: center;
font-size: 13px;
color: var(--muted);
}
.no-results strong {
color: var(--ink-2);
}
.panel-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-top: 1px solid var(--line);
}
.panel-foot #panelStatus {
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--ink);
color: #fff;
font-size: 13px;
font-weight: 500;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s, transform 0.22s;
z-index: 80;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---------- Responsive ---------- */
@media (max-width: 1040px) {
.widget {
grid-column: span 6;
}
.widget.span-8 {
grid-column: span 12;
}
}
@media (max-width: 720px) {
.app {
grid-template-columns: 1fr;
}
.sidebar {
position: fixed;
z-index: 60;
width: 248px;
transform: translateX(-100%);
transition: transform 0.26s ease;
}
.sidebar.is-open {
transform: none;
box-shadow: var(--sh-2);
}
.nav-toggle {
display: grid;
}
.topbar {
flex-wrap: wrap;
padding: 14px 18px;
}
.head-tools {
width: 100%;
margin-left: 0;
flex-wrap: wrap;
}
.head-tools .btn-primary {
margin-left: auto;
}
.board-wrap {
padding: 18px 16px 40px;
}
.widget,
.widget.span-6,
.widget.span-8 {
grid-column: span 12;
}
.panel {
width: 100vw;
}
}
@media (max-width: 360px) {
.count-chip {
display: none;
}
.kpi-value {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------- Widget catalog (fictional Plotline Insights) ---------- */
var CATALOG = [
{
id: "kpi-mrr",
cat: "metric",
icon: "$",
name: "Recurring revenue",
desc: "MRR with month-over-month delta and a 12-point sparkline.",
span: 4,
},
{
id: "kpi-active",
cat: "metric",
icon: "◉",
name: "Active users",
desc: "Weekly active accounts and trend versus the prior week.",
span: 4,
},
{
id: "kpi-churn",
cat: "metric",
icon: "↘",
name: "Churn rate",
desc: "Net logo churn — lower is better, color-coded accordingly.",
span: 4,
},
{
id: "chart-revenue",
cat: "chart",
icon: "∿",
name: "Revenue trend",
desc: "Inline SVG area + line chart of net revenue over time.",
span: 8,
},
{
id: "chart-channels",
cat: "chart",
icon: "▥",
name: "Signups by channel",
desc: "Grouped SVG bar chart breaking acquisition down by source.",
span: 4,
},
{
id: "chart-plan",
cat: "chart",
icon: "◕",
name: "Plan mix",
desc: "Donut chart of active subscriptions across pricing tiers.",
span: 4,
},
{
id: "data-pages",
cat: "data",
icon: "▦",
name: "Top pages",
desc: "Ranked table of the most-visited pages with status pills.",
span: 6,
},
{
id: "data-activity",
cat: "data",
icon: "◷",
name: "Activity feed",
desc: "Live stream of account events as they happen.",
span: 6,
},
];
/* ---------- Element refs ---------- */
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var catalogEl = document.getElementById("catalog");
var panel = document.getElementById("panel");
var scrim = document.getElementById("scrim");
var searchInput = document.getElementById("catalogSearch");
var noResults = document.getElementById("noResults");
var noResultsTerm = document.getElementById("noResultsTerm");
var countChip = document.getElementById("countChip");
var panelStatus = document.getElementById("panelStatus");
var toastEl = document.getElementById("toast");
var openBtn = document.getElementById("openPanel");
var sidebar = document.getElementById("sidebar");
var navToggle = document.getElementById("navToggle");
var added = {}; // id -> true
var activeCat = "all";
var lastFocused = null;
var toastTimer = null;
/* ---------- Toast ---------- */
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
/* ---------- Small SVG builders (no libs) ---------- */
function sparkline(points, up) {
var w = 240,
h = 38,
max = Math.max.apply(null, points),
min = Math.min.apply(null, points),
span = max - min || 1;
var step = w / (points.length - 1);
var d = points
.map(function (p, i) {
var x = i * step;
var y = h - 4 - ((p - min) / span) * (h - 8);
return (i === 0 ? "M" : "L") + x.toFixed(1) + " " + y.toFixed(1);
})
.join(" ");
var color = up ? "var(--ok)" : "var(--danger)";
var fillId = "sp" + Math.random().toString(36).slice(2, 7);
return (
'<svg class="spark" viewBox="0 0 ' +
w +
" " +
h +
'" preserveAspectRatio="none" aria-hidden="true">' +
'<defs><linearGradient id="' +
fillId +
'" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="' +
color +
'" stop-opacity="0.22"/>' +
'<stop offset="1" stop-color="' +
color +
'" stop-opacity="0"/></linearGradient></defs>' +
'<path d="' +
d +
" L" +
w +
" " +
h +
" L0 " +
h +
' Z" fill="url(#' +
fillId +
')"/>' +
'<path d="' +
d +
'" fill="none" stroke="' +
color +
'" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
"</svg>"
);
}
function lineChart(points) {
var w = 520,
h = 150,
pad = 8;
var max = Math.max.apply(null, points) * 1.1,
min = 0,
span = max - min || 1;
var step = (w - pad * 2) / (points.length - 1);
var coords = points.map(function (p, i) {
return {
x: pad + i * step,
y: h - pad - ((p - min) / span) * (h - pad * 2 - 12),
};
});
var d = coords
.map(function (c, i) {
return (i === 0 ? "M" : "L") + c.x.toFixed(1) + " " + c.y.toFixed(1);
})
.join(" ");
var area =
d +
" L" +
coords[coords.length - 1].x.toFixed(1) +
" " +
(h - pad) +
" L" +
coords[0].x.toFixed(1) +
" " +
(h - pad) +
" Z";
var grid = [];
for (var g = 1; g <= 3; g++) {
var gy = (h / 4) * g;
grid.push(
'<line x1="0" y1="' +
gy +
'" x2="' +
w +
'" y2="' +
gy +
'" stroke="var(--line)" stroke-width="1"/>'
);
}
return (
'<svg class="" viewBox="0 0 ' +
w +
" " +
h +
'" role="img" aria-label="Revenue trend line chart">' +
'<defs><linearGradient id="rev" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="var(--brand)" stop-opacity="0.24"/>' +
'<stop offset="1" stop-color="var(--brand)" stop-opacity="0"/></linearGradient></defs>' +
grid.join("") +
'<path d="' +
area +
'" fill="url(#rev)"/>' +
'<path d="' +
d +
'" fill="none" stroke="var(--brand)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>' +
coords
.map(function (c) {
return (
'<circle cx="' +
c.x.toFixed(1) +
'" cy="' +
c.y.toFixed(1) +
'" r="2.5" fill="var(--white)" stroke="var(--brand)" stroke-width="1.6"/>'
);
})
.join("") +
"</svg>"
);
}
function barChart(series) {
var w = 240,
h = 150,
pad = 8,
gap = 14;
var max = Math.max.apply(
null,
series.map(function (s) {
return s.v;
})
);
var bw = (w - pad * 2 - gap * (series.length - 1)) / series.length;
var bars = series
.map(function (s, i) {
var bh = ((h - pad * 2 - 16) * s.v) / max;
var x = pad + i * (bw + gap);
var y = h - pad - bh;
return (
'<rect x="' +
x.toFixed(1) +
'" y="' +
y.toFixed(1) +
'" width="' +
bw.toFixed(1) +
'" height="' +
bh.toFixed(1) +
'" rx="4" fill="' +
s.c +
'"/>' +
'<text x="' +
(x + bw / 2).toFixed(1) +
'" y="' +
(h - 1) +
'" font-size="9" fill="var(--muted)" text-anchor="middle">' +
s.label +
"</text>"
);
})
.join("");
return (
'<svg viewBox="0 0 ' +
w +
" " +
h +
'" role="img" aria-label="Signups by channel bar chart">' +
bars +
"</svg>"
);
}
function donut(segments) {
var size = 132,
r = 50,
cx = size / 2,
cy = size / 2,
circ = 2 * Math.PI * r;
var total = segments.reduce(function (a, s) {
return a + s.v;
}, 0);
var offset = 0;
var arcs = segments
.map(function (s) {
var len = (s.v / total) * circ;
var seg =
'<circle cx="' +
cx +
'" cy="' +
cy +
'" r="' +
r +
'" fill="none" stroke="' +
s.c +
'" stroke-width="16" stroke-dasharray="' +
len.toFixed(1) +
" " +
(circ - len).toFixed(1) +
'" stroke-dashoffset="' +
(-offset).toFixed(1) +
'" transform="rotate(-90 ' +
cx +
" " +
cy +
')"/>';
offset += len;
return seg;
})
.join("");
return (
'<svg viewBox="0 0 ' +
size +
" " +
size +
'" width="132" height="132" role="img" aria-label="Plan mix donut chart">' +
'<circle cx="' +
cx +
'" cy="' +
cy +
'" r="' +
r +
'" fill="none" stroke="var(--bg)" stroke-width="16"/>' +
arcs +
'<text x="' +
cx +
'" y="' +
(cy - 2) +
'" text-anchor="middle" font-size="20" font-weight="800" fill="var(--ink)">' +
total.toLocaleString() +
"</text>" +
'<text x="' +
cx +
'" y="' +
(cy + 14) +
'" text-anchor="middle" font-size="9" fill="var(--muted)">accounts</text>' +
"</svg>"
);
}
/* ---------- Widget body renderers ---------- */
function bodyFor(id) {
switch (id) {
case "kpi-mrr":
return (
'<div class="kpi-value">$184.2K<span class="delta is-up">▲ 8.4%</span></div>' +
'<p class="kpi-cap">vs. $169.9K last month</p>' +
sparkline([142, 150, 148, 156, 161, 159, 168, 172, 170, 178, 181, 184], true) +
footer("Synced just now", false)
);
case "kpi-active":
return (
'<div class="kpi-value">28,914<span class="delta is-up">▲ 3.6%</span></div>' +
'<p class="kpi-cap">weekly active accounts</p>' +
sparkline([255, 261, 258, 267, 270, 268, 276, 281, 279, 285, 287, 289], true) +
footer("Updated 6m ago", false)
);
case "kpi-churn":
return (
'<div class="kpi-value">2.1%<span class="delta is-down">▼ 0.4 pts</span></div>' +
'<p class="kpi-cap">net logo churn (lower is better)</p>' +
sparkline([31, 29, 30, 27, 28, 26, 25, 24, 24, 23, 22, 21], false) +
footer("Trending down — good", false)
);
case "chart-revenue":
return (
'<div class="kpi-value" style="font-size:21px">$1.41M<span class="delta is-up">▲ 11.2%</span></div>' +
'<div class="chart-box">' +
lineChart([62, 71, 68, 79, 84, 81, 92, 97, 95, 108, 116, 124]) +
"</div>" +
footer("12-month rolling net revenue", false)
);
case "chart-channels":
return (
'<div class="chart-box">' +
barChart([
{ label: "Organic", v: 4120, c: "var(--brand)" },
{ label: "Paid", v: 2680, c: "var(--accent)" },
{ label: "Referral", v: 1940, c: "var(--brand-700)" },
{ label: "Social", v: 1210, c: "var(--warn)" },
]) +
"</div>" +
footer("9,950 signups this period", false)
);
case "chart-plan":
return (
'<div class="chart-box" style="display:flex;justify-content:center">' +
donut([
{ v: 1820, c: "var(--brand)" },
{ v: 940, c: "var(--accent)" },
{ v: 450, c: "var(--warn)" },
]) +
"</div>" +
'<div class="legend">' +
'<span><i style="background:var(--brand)"></i>Starter</span>' +
'<span><i style="background:var(--accent)"></i>Growth</span>' +
'<span><i style="background:var(--warn)"></i>Scale</span>' +
"</div>"
);
case "data-pages":
return (
'<table class="w-table"><thead><tr><th>Page</th><th class="num">Views</th><th class="num">Conv.</th><th>Health</th></tr></thead><tbody>' +
row("/pricing", "18,402", "5.8%", "ok", "Healthy") +
row("/signup", "12,109", "9.1%", "ok", "Healthy") +
row("/blog/launch", "9,640", "1.2%", "warn", "Watch") +
row("/docs/api", "7,318", "3.4%", "ok", "Healthy") +
"</tbody></table>" +
footer("Last 30 days", false)
);
case "data-activity":
return (
'<ul class="feed" data-feed>' +
feedItem("ok", "Maya Lin", "upgraded to <strong>Scale</strong>", "2m ago") +
feedItem("", "Orbit Labs", "invited 4 teammates", "11m ago") +
feedItem("warn", "Kettle Co.", "payment failed — retrying", "26m ago") +
feedItem("ok", "Juno Park", "completed onboarding", "41m ago") +
"</ul>" +
footer("", true)
);
default:
return "";
}
}
function row(page, views, conv, cls, label) {
return (
"<tr><td>" +
page +
'</td><td class="num">' +
views +
'</td><td class="num">' +
conv +
'</td><td><span class="pill ' +
cls +
'">' +
label +
"</span></td></tr>"
);
}
function feedItem(cls, who, what, time) {
return (
'<li><span class="fdot ' +
cls +
'"></span><div><p class="ftxt"><strong>' +
who +
"</strong> " +
what +
'</p><span class="ftime">' +
time +
"</span></div></li>"
);
}
function footer(text, live) {
return (
'<div class="w-foot"><span class="dot"></span><span>' +
(text || "Up to date") +
"</span>" +
(live ? '<span class="live">Live</span>' : "") +
"</div>"
);
}
/* ---------- Grid render ---------- */
function meta(id) {
return CATALOG.filter(function (c) {
return c.id === id;
})[0];
}
function renderWidget(id) {
var m = meta(id);
var iconCls = m.cat === "chart" ? "t-chart" : m.cat === "data" ? "t-data" : "";
var el = document.createElement("article");
el.className = "widget" + (m.span === 6 ? " span-6" : m.span === 8 ? " span-8" : "");
el.setAttribute("role", "listitem");
el.dataset.id = id;
el.innerHTML =
'<div class="w-head">' +
'<span class="w-icn ' +
iconCls +
'" aria-hidden="true">' +
m.icon +
"</span>" +
'<div class="w-title"><h3>' +
m.name +
"</h3><p>" +
labelFor(m.cat) +
"</p></div>" +
'<button class="w-remove" type="button" aria-label="Remove ' +
m.name +
' widget" data-remove="' +
id +
'">✕</button>' +
"</div>" +
bodyFor(id);
return el;
}
function labelFor(cat) {
return cat === "metric" ? "Key metric" : cat === "chart" ? "Visualization" : "Live data";
}
function syncGrid() {
grid.innerHTML = "";
var ids = CATALOG.filter(function (c) {
return added[c.id];
}).map(function (c) {
return c.id;
});
ids.forEach(function (id) {
grid.appendChild(renderWidget(id));
});
var n = ids.length;
empty.hidden = n > 0;
countChip.textContent = n + (n === 1 ? " widget" : " widgets");
panelStatus.textContent = n + " added";
}
/* ---------- Catalog (panel) render ---------- */
function renderCatalog() {
var term = searchInput.value.trim().toLowerCase();
catalogEl.innerHTML = "";
var shown = 0;
CATALOG.forEach(function (c) {
var matchCat = activeCat === "all" || c.cat === activeCat;
var matchTerm =
!term ||
c.name.toLowerCase().indexOf(term) !== -1 ||
c.desc.toLowerCase().indexOf(term) !== -1;
if (!matchCat || !matchTerm) return;
shown++;
var iconCls = c.cat === "chart" ? "t-chart" : c.cat === "data" ? "t-data" : "";
var on = !!added[c.id];
var card = document.createElement("div");
card.className = "cat-card" + (on ? " is-added" : "");
card.innerHTML =
'<span class="cat-icn ' +
iconCls +
'" aria-hidden="true">' +
c.icon +
"</span>" +
'<div class="cat-meta"><h3>' +
c.name +
'<span class="cat-tag">' +
c.cat +
"</span></h3><p>" +
c.desc +
"</p></div>" +
'<button class="toggle" type="button" role="switch" aria-pressed="' +
on +
'" aria-label="' +
(on ? "Remove " : "Add ") +
c.name +
'" data-toggle="' +
c.id +
'"></button>';
catalogEl.appendChild(card);
});
var hasResults = shown > 0;
noResults.hidden = hasResults;
if (!hasResults) {
noResultsTerm.textContent = term ? '"' + searchInput.value.trim() + '"' : "this filter";
}
}
/* ---------- Add / remove ---------- */
function addWidget(id, quiet) {
if (added[id]) return;
added[id] = true;
syncGrid();
renderCatalog();
if (!quiet) toast(meta(id).name + " added");
}
function removeWidget(id) {
if (!added[id]) return;
var card = grid.querySelector('[data-id="' + id + '"]');
if (card) {
card.classList.add("removing");
setTimeout(function () {
delete added[id];
syncGrid();
renderCatalog();
}, 200);
} else {
delete added[id];
syncGrid();
renderCatalog();
}
toast(meta(id).name + " removed");
}
/* ---------- Panel open / close + focus trap ---------- */
function openPanel() {
lastFocused = document.activeElement;
scrim.hidden = false;
panel.classList.add("is-open");
panel.setAttribute("aria-hidden", "false");
renderCatalog();
setTimeout(function () {
searchInput.focus();
}, 60);
document.addEventListener("keydown", onPanelKey);
}
function closePanel() {
panel.classList.remove("is-open");
panel.setAttribute("aria-hidden", "true");
scrim.hidden = true;
document.removeEventListener("keydown", onPanelKey);
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function focusables() {
return Array.prototype.slice
.call(
panel.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
)
.filter(function (el) {
return el.offsetParent !== null && !el.disabled;
});
}
function onPanelKey(e) {
if (e.key === "Escape") {
e.preventDefault();
closePanel();
return;
}
if (e.key !== "Tab") return;
var f = focusables();
if (!f.length) return;
var first = f[0],
last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
/* ---------- Event wiring ---------- */
openBtn.addEventListener("click", openPanel);
document.getElementById("emptyAdd").addEventListener("click", openPanel);
document.getElementById("panelClose").addEventListener("click", closePanel);
document.getElementById("panelDone").addEventListener("click", closePanel);
scrim.addEventListener("click", closePanel);
searchInput.addEventListener("input", renderCatalog);
document.querySelectorAll(".chip").forEach(function (chip) {
chip.addEventListener("click", function () {
document.querySelectorAll(".chip").forEach(function (c) {
c.classList.remove("is-on");
c.setAttribute("aria-selected", "false");
});
chip.classList.add("is-on");
chip.setAttribute("aria-selected", "true");
activeCat = chip.dataset.cat;
renderCatalog();
});
});
catalogEl.addEventListener("click", function (e) {
var btn = e.target.closest("[data-toggle]");
if (!btn) return;
var id = btn.getAttribute("data-toggle");
if (added[id]) removeWidget(id);
else addWidget(id);
});
grid.addEventListener("click", function (e) {
var btn = e.target.closest("[data-remove]");
if (!btn) return;
removeWidget(btn.getAttribute("data-remove"));
});
/* date range tweaks KPI values for realism */
document.getElementById("rangeSelect").addEventListener("change", function (e) {
toast("Range set to " + e.target.options[e.target.selectedIndex].text);
});
/* sidebar drawer on mobile */
navToggle.addEventListener("click", function () {
var open = sidebar.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", String(open));
});
/* ---------- Live activity ticker ---------- */
var EVENTS = [
{ cls: "ok", who: "Aria Voss", what: "started a trial", },
{ cls: "", who: "Delta Forge", what: "added a payment method" },
{ cls: "ok", who: "Pixel Bros", what: "upgraded to <strong>Growth</strong>" },
{ cls: "warn", who: "Northwind", what: "hit an API rate limit" },
{ cls: "ok", who: "Sol Ortega", what: "completed onboarding" },
];
setInterval(function () {
var feed = grid.querySelector("[data-feed]");
if (!feed) return;
var ev = EVENTS[Math.floor(Math.random() * EVENTS.length)];
var li = document.createElement("li");
li.style.opacity = "0";
li.innerHTML =
'<span class="fdot ' +
ev.cls +
'"></span><div><p class="ftxt"><strong>' +
ev.who +
"</strong> " +
ev.what +
'</p><span class="ftime">just now</span></div>';
feed.insertBefore(li, feed.firstChild);
requestAnimationFrame(function () {
li.style.transition = "opacity 0.4s";
li.style.opacity = "1";
});
while (feed.children.length > 4) feed.removeChild(feed.lastChild);
}, 4200);
/* ---------- Seed a default dashboard ---------- */
["kpi-mrr", "kpi-active", "kpi-churn", "chart-revenue", "chart-channels", "data-activity"].forEach(
function (id) {
added[id] = true;
}
);
syncGrid();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widget — Add-widget / customize panel</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">
<!-- Sidebar -->
<nav class="sidebar" id="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◇</span>
<span class="brand-name">Plotline <em>Insights</em></span>
</div>
<ul class="nav-list">
<li><a href="#" class="nav-item is-active" aria-current="page"><span class="nav-ico" aria-hidden="true">▦</span>Overview</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">▥</span>Funnels</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">◷</span>Activity</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">◫</span>Cohorts</a></li>
<li><a href="#" class="nav-item"><span class="nav-ico" aria-hidden="true">⚙</span>Settings</a></li>
</ul>
<div class="nav-foot">
<div class="avatar" aria-hidden="true">DV</div>
<div class="who">
<strong>Dana Voss</strong>
<span>Workspace admin</span>
</div>
</div>
</nav>
<!-- Main -->
<div class="main-col">
<header class="topbar">
<button class="icon-btn nav-toggle" id="navToggle" aria-label="Toggle navigation" aria-expanded="false">☰</button>
<div class="head-text">
<h1>My dashboard</h1>
<p>Customize the layout with the widgets you care about</p>
</div>
<div class="head-tools">
<label class="range" aria-label="Date range">
<span class="range-ico" aria-hidden="true">▾</span>
<select id="rangeSelect">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last quarter</option>
</select>
</label>
<span class="count-chip" id="countChip" aria-live="polite">0 widgets</span>
<button class="btn-primary" id="openPanel" aria-haspopup="dialog">
<span aria-hidden="true">+</span> Add widget
</button>
</div>
</header>
<main class="board-wrap" aria-label="Dashboard">
<!-- Live grid of added widgets -->
<div class="grid" id="grid" role="list" aria-label="Active widgets"></div>
<!-- Empty state -->
<div class="empty" id="empty" hidden>
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 120 84" width="120" height="84" role="img" aria-hidden="true">
<rect x="2" y="2" width="52" height="34" rx="6" class="es-fill" />
<rect x="62" y="2" width="56" height="34" rx="6" class="es-line" fill="none" stroke-dasharray="5 5" />
<rect x="2" y="44" width="56" height="38" rx="6" class="es-line" fill="none" stroke-dasharray="5 5" />
<rect x="66" y="44" width="52" height="38" rx="6" class="es-fill2" />
<line x1="90" y1="55" x2="90" y2="71" class="es-plus" />
<line x1="82" y1="63" x2="98" y2="63" class="es-plus" />
</svg>
</div>
<h2>Your dashboard is empty</h2>
<p>Pick from KPI tiles, charts, tables and activity feeds to build a view that fits your workflow.</p>
<button class="btn-primary" id="emptyAdd"><span aria-hidden="true">+</span> Add your first widget</button>
</div>
</main>
</div>
</div>
<!-- Customize panel (off-canvas dialog) -->
<div class="scrim" id="scrim" hidden></div>
<aside class="panel" id="panel" role="dialog" aria-modal="true" aria-labelledby="panelTitle" aria-hidden="true">
<header class="panel-head">
<div>
<h2 id="panelTitle">Add widgets</h2>
<p class="panel-sub">Toggle widgets on to drop them into your dashboard.</p>
</div>
<button class="icon-btn" id="panelClose" aria-label="Close panel">✕</button>
</header>
<div class="panel-tools">
<div class="search">
<span class="search-ico" aria-hidden="true">⌕</span>
<input type="search" id="catalogSearch" placeholder="Search widgets…" aria-label="Search widgets" autocomplete="off" />
</div>
<div class="chips" role="tablist" aria-label="Filter by category">
<button class="chip is-on" role="tab" aria-selected="true" data-cat="all">All</button>
<button class="chip" role="tab" aria-selected="false" data-cat="metric">KPIs</button>
<button class="chip" role="tab" aria-selected="false" data-cat="chart">Charts</button>
<button class="chip" role="tab" aria-selected="false" data-cat="data">Data</button>
</div>
</div>
<div class="catalog" id="catalog" aria-label="Available widgets"></div>
<p class="no-results" id="noResults" hidden>No widgets match <strong id="noResultsTerm"></strong>.</p>
<footer class="panel-foot">
<span id="panelStatus" aria-live="polite">0 added</span>
<button class="btn-ghost" id="panelDone">Done</button>
</footer>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Add-widget / customize panel
A “Plotline Insights” analytics dashboard built around an add-widget / customize flow. The shell has a sidebar nav, a page header with a date-range control and a live widget counter, and a responsive 12-column grid of widget cards. Each card shows a typed icon, a title, a remove (x) button, and a real data visualization — KPI tiles with up/down deltas and inline SVG sparklines, an area + line revenue chart, a grouped bar chart of signups by channel, a plan-mix donut, a ranked top-pages table, and a live activity feed that ticks new events every few seconds.
Pressing Add widget slides open a focus-trapped side panel listing the full catalog as cards, each with an icon, description and an accessible on/off switch. A search box filters the catalog as you type and category chips (KPIs, Charts, Data) narrow it further, with a friendly “no results” message when nothing matches. Toggling a card on drops the widget straight into the live grid; toggling it off — or hitting the card’s remove button — animates it out and re-syncs the catalog so the two views never disagree.
When every widget is removed the grid swaps to an illustrated empty state inviting you to add your
first widget. Everything is vanilla JS with no libraries: the panel traps Tab focus and closes on
Escape or scrim click, the sidebar collapses to an off-canvas drawer below 720px, the grid reflows
to a single column on small screens, and all motion respects prefers-reduced-motion.