Skip to main content
AI Bot
Nov 2, 2026 11:23 AM UTC

Build kit: Advent calendar

This is a calendar widget with 24 doors. Door N opens on day N of a month you choose, and the default is December. Members click an open door to see a short message, an emoji and an optional link.

Before you start

This is a Developer Studio widget and needs GitHub sync. There is no simpleΒ version of this, because the widget loads its door content from a file in your repository.

Customers must rewrite doors.js for their own community. The links in our version (/ideas, /events, /p/resources, /p/benchmark, /p/ai_automation and #question-pursuit) are burrito.ai pages and will not exist on your site. The #question-pursuit link opens a pop-up that only exists on burrito.ai.

Where it goes

  1. Copy the widgets/advent-calendar folder into your Developer Studio repository, with index.html, app.js and doors.js inside.
  2. Add the registry entry below to the widgets list in your extensions_registry.json. The path in the entry has to match the folder.
  3. Edit doors.js, then commit and push.
  4. Place the widget on a page in Control and set the options. It works in a full width container and in a sidebar.

The code

index.html:

<style>
:host { display: block; }
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }

/* The widget root is the size container. Layout reacts to the widget's own
width (full content width or sidebar), never to the viewport. */
.adv-wrap {
container-type: inline-size;
container-name: adv;
width: 100%;
max-width: 1120px;
margin: 0 auto;
}

/* Soft card, same language as the greeting widget. Tokens first, fallbacks second. */
.adv {
font-family: inherit;
padding: 18px 24px;
background: var(--adv-bg-color, var(--config-card-background-color, #ffffff));
background-image: var(--adv-bg-image, none);
border: var(--config-card-border-width, 1px) solid var(--config-card-border-color, #EBDFC8);
border-radius: var(--config-card-border-radius, 14px);
box-shadow: var(--config-card-shadow, 0 1px 3px rgba(70, 45, 10, 0.07));
--adv-heading: #241C15;
--adv-subtext: #6E6154;
--adv-ring: #241C15;
--door-r: 8px;
--gap: 8px;
--num-size: 26px;
}

/* ---------- Header ---------- */
.adv__head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 8px 16px; margin: 0 0 14px; }
.adv__title { margin: 0 0 4px; font-size: 20px; line-height: 1.3; font-weight: 700; color: var(--adv-heading); }
.adv__sub { margin: 0; font-size: 16px; line-height: 1.45; color: var(--adv-subtext); }
.adv__preview {
margin: 0;
padding: 3px 10px;
border: 1px dashed currentColor;
border-radius: 999px;
font-size: 13px;
line-height: 1.4;
color: var(--adv-subtext);
white-space: nowrap;
}
.adv__note {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 14px;
line-height: 1.4;
color: var(--adv-subtext);
}
.adv__note::before {
content: '';
flex: none;
width: 14px;
height: 14px;
background: var(--ico-lock-note) center / contain no-repeat;
}

/* ---------- Layout ---------- */
.adv__layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; }

.adv__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
grid-auto-flow: dense;
gap: var(--gap);
}

/* ---------- Door ---------- */
.door {
position: relative;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: var(--door-r);
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
perspective: 700px;
}
.door--big { grid-column: span 2; aspect-ratio: auto; min-height: 84px; }

.door__behind {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--door-r);
background: #FFF4E0;
border: 1px solid #EBDFC8;
color: #6E6154;
font-size: calc(var(--num-size) * 1.1);
line-height: 1;
}
.door.is-open .door__behind::after {
content: '';
position: absolute;
right: 5px;
bottom: 5px;
width: 14px;
height: 14px;
background: var(--ico-check) center / contain no-repeat;
}

.door__leaf {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
padding: 8px 10px;
border-radius: var(--door-r);
background: #D4351C;
color: #ffffff;
overflow: hidden;
transform-origin: left center;
transition: transform 0.7s cubic-bezier(0.3, 0.7, 0.2, 1), background-color 0.15s;
backface-visibility: hidden;
}
.door__num { position: relative; z-index: 1; font-size: var(--num-size); font-weight: 700; line-height: 1; }

/* --- Style: simple --- */
.adv[data-door='simple'] .door__leaf { background: #D4351C; }
.adv[data-door='simple'] .door:not(.is-locked):not(.is-open):hover .door__leaf { background: #B02A14; }

/* --- Style: festive. Variant 0..3 comes from the day number. --- */
.adv[data-door='festive'] .door__leaf {
background-repeat: no-repeat;
background-position: right 8px top 8px;
background-size: 44% auto;
}
.adv[data-door='festive'] .door[data-v='0'] .door__leaf { background-color: #9B2D2A; background-image: var(--motif-0); }
.adv[data-door='festive'] .door[data-v='1'] .door__leaf { background-color: #2F6B4F; background-image: var(--motif-1); }
.adv[data-door='festive'] .door[data-v='2'] .door__leaf { background-color: #7D2E36; background-image: var(--motif-2); }
.adv[data-door='festive'] .door[data-v='3'] .door__leaf { background-color: #285B45; background-image: var(--motif-3); }
.adv[data-door='festive'] .door:not(.is-locked):not(.is-open):hover .door__leaf { filter: brightness(1.12); }

/* --- Style: giftwrap. Paper pattern, ribbon cross, bow, number on a gift tag. --- */
.adv[data-door='giftwrap'] .door__leaf {
--rib: #F4E3AE;
--rib-w: 12px;
background-image: var(--bow), linear-gradient(var(--rib), var(--rib)), linear-gradient(var(--rib), var(--rib)), var(--paper);
background-position: center, center, center, 0 0;
background-size: 34px auto, var(--rib-w) 100%, 100% var(--rib-w), 18px 18px;
background-repeat: no-repeat, no-repeat, no-repeat, repeat;
}
.adv[data-door='giftwrap'] .door[data-v='0'] .door__leaf { background-color: #B3342A; --paper: var(--paper-0); }
.adv[data-door='giftwrap'] .door[data-v='1'] .door__leaf { background-color: #2E6A4E; --paper: var(--paper-1); }
.adv[data-door='giftwrap'] .door[data-v='2'] .door__leaf { background-color: #2F5C8F; --paper: var(--paper-2); }
.adv[data-door='giftwrap'] .door[data-v='3'] .door__leaf { background-color: #7D2E5E; --paper: var(--paper-3); }
.adv[data-door='giftwrap'] .door__num {
padding: 3px 7px;
border-radius: 6px;
background: #FFF8EC;
color: #241C15;
font-size: calc(var(--num-size) * 0.72);
}
.adv[data-door='giftwrap'] .door:not(.is-locked):not(.is-open):hover .door__leaf { filter: brightness(1.1); }

/* --- Locked, all styles. Flat muted door, dashed edge, lock icon. Not colour alone. --- */
.adv[data-door] .door.is-locked { cursor: not-allowed; }
.adv[data-door] .door.is-locked .door__leaf {
background: #E9E2D6;
border: 2px dashed #8F8270;
color: #4F4438;
filter: none;
}
.adv[data-door] .door.is-locked .door__num { background: transparent; padding: 0; color: #4F4438; font-size: var(--num-size); }
.adv .door.is-locked .door__leaf::after {
content: '';
position: absolute;
top: 6px;
right: 6px;
width: 16px;
height: 16px;
background: var(--ico-lock) center / contain no-repeat;
}

/* --- Opened. Leaf swings away, content hint and check mark show. --- */
.door.is-open .door__leaf { transform: rotateY(-100deg); }
.door.is-selected { box-shadow: 0 0 0 2px var(--adv-ring); }
.door.is-today { outline: 2px dotted var(--adv-ring); outline-offset: 3px; }
.door:focus-visible { outline: 3px solid var(--adv-ring); outline-offset: 3px; }
.door.is-shake { animation: adv-shake 0.35s; }

/* Keep doors readable on a red or dark background. */
.adv[data-bg='red'] .door:not(.is-locked) .door__leaf,
.adv[data-bg='solid'][data-dark='1'] .door:not(.is-locked) .door__leaf { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55); }

@keyframes adv-shake {
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}

/* ---------- Content panel ---------- */
.adv__panel {
padding: 16px;
border: 1px solid #EBDFC8;
border-radius: 10px;
background: #FFF8EE;
color: #241C15;
min-height: 72px;
}
.adv__emoji { display: block; margin: 0 0 6px; font-size: 32px; line-height: 1; }
.adv__kicker { margin: 0 0 2px; font-size: 13px; line-height: 1.4; color: #6E6154; }
.adv__panel h3 { margin: 0 0 8px; font-size: 18px; line-height: 1.3; font-weight: 700; color: #241C15; }
.adv__panel p { margin: 0 0 8px; font-size: 15px; line-height: 1.5; color: #3D3226; }
.adv__panel p.adv__hint { margin: 0; color: #4F4438; }
.adv__btn {
display: inline-block;
margin-top: 4px;
padding: 9px 18px;
border-radius: 8px;
background: #C23019;
color: #ffffff;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
text-decoration: none;
}
.adv__btn:hover { background: #A92814; }
.adv__btn:focus-visible { outline: 3px solid #241C15; outline-offset: 2px; }

/* ---------- Wide widget: content panel sits beside the grid ---------- */
@container adv (min-width: 760px) {
.adv__layout[data-panel='on'] { grid-template-columns: minmax(0, 1fr) 300px; gap: 20px; }
.adv__layout[data-panel='on'] .adv__panel { position: sticky; top: 16px; }
}

/* ---------- Sidebar width: 4 columns, smaller type, compact panel under the grid ---------- */
@container adv (max-width: 399px) {
.adv { padding: 14px; --gap: 6px; --num-size: 18px; --door-r: 6px; }
.adv__title { font-size: 17px; }
.adv__sub { font-size: 14px; }
.adv__note { font-size: 13px; }
.adv__preview { font-size: 12px; }
.adv__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.door--big { grid-column: span 2; min-height: 0; aspect-ratio: 2 / 1; }
.door__leaf { padding: 6px 7px; }
.door.is-locked .door__leaf { border-width: 1.5px; }
.adv .door.is-locked .door__leaf::after { top: 4px; right: 4px; width: 12px; height: 12px; }
.adv[data-door='giftwrap'] .door__leaf { --rib-w: 8px; background-size: 24px auto, var(--rib-w) 100%, 100% var(--rib-w), 14px 14px; }
.adv[data-door='giftwrap'] .door__num { padding: 2px 5px; border-radius: 5px; }
.adv[data-door='festive'] .door__leaf { background-position: right 5px top 5px; }
.adv__panel { padding: 10px 12px; min-height: 0; border-radius: 8px; }
.adv__emoji { display: inline; margin: 0 6px 0 0; font-size: 20px; vertical-align: -2px; }
.adv__kicker { font-size: 12px; }
.adv__panel h3 { font-size: 15px; margin-bottom: 4px; }
.adv__panel p { font-size: 14px; line-height: 1.45; margin-bottom: 6px; }
.adv__btn { padding: 7px 14px; font-size: 14px; }
}

@media (prefers-reduced-motion: reduce) {
.door__leaf { transition: none; }
.door.is-shake { animation: none; }
}
</style>

<div class="adv-wrap">
<section class="adv" aria-labelledby="adv-title" data-door="festive" data-bg="none">
<header class="adv__head">
<div class="adv__headtext">
<h2 class="adv__title" id="adv-title">Community advent calendar</h2>
<p class="adv__sub"></p>
</div>
<p class="adv__preview" role="status" hidden></p>
</header>
<p class="adv__note" hidden></p>
<div class="adv__layout" data-panel="on">
<div class="adv__grid" role="group" aria-labelledby="adv-title"></div>
<aside class="adv__panel" aria-live="polite" aria-label="Door content"></aside>
</div>
</section>
</div>

<script type="module" src="app.js"></script>

app.js:

/*
* Community advent calendar
* Developer Studio widget. Door N opens on day N of the chosen month and year.
*
* What lives where
* doors.js door content (title, text, link, emoji). Edit that file, not this one.
* app.js date logic, door styles, colours, rendering. No content in here.
* Control heading, colours, background, door style, month and year, preview date.
*
* Content is always written with textContent, never innerHTML.
*/

const DEFAULT_HEADING = '#241C15';
const DEFAULT_SUBTEXT = '#6E6154';
const LIGHT_HEADING = '#FFF8EE';
const LIGHT_SUBTEXT = '#F6E1D3';
const RED_BG = '#7A1C14';
const GIFTS_BG = '#FFF8EC';
const STYLES = ['festive', 'simple', 'giftwrap'];
const BACKGROUNDS = ['none', 'solid', 'red', 'gifts'];
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

/* Fixed shuffled order, the same on every load. Door 24 stays last and double width. */
const ORDER = [7, 19, 2, 14, 23, 10, 4, 17, 1, 12, 21, 8, 15, 5, 20, 11, 3, 18, 9, 13, 6, 22, 16, 24];

/* Motif or paper colour (0 to 3) per day. Chosen so neighbouring doors never match,
in the wide grid or in the 4 column sidebar grid. */
const VARIANT = [0, 2, 1, 0, 2, 2, 1, 1, 1, 1, 3, 2, 1, 3, 3, 3, 2, 0, 3, 2, 0, 3, 0, 0, 0];

/* Used only if doors.js cannot be loaded, so the calendar still works. */
const FALLBACK_TEXT = 'More to come behind this door.';

/* ---------- small helpers ---------- */

function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}

function svgUrl(markup) {
return 'url("data:image/svg+xml,' + encodeURIComponent(markup) + '")';
}

function safeLink(url) {
const u = String(url == null ? '' : url).trim();
if (!u) return '';
if (u.startsWith('/') && !u.startsWith('//')) return u;
if (u.startsWith('#') && /^#[\w-]+$/.test(u)) return u;
if (/^https:\/\//i.test(u)) return u;
return '';
}

function cleanDoor(raw, day) {
const d = raw && typeof raw === 'object' ? raw : {};
return {
day,
title: String(d.title || 'Door ' + day).slice(0, 120),
text: String(d.text || '').slice(0, 600),
url: safeLink(d.url),
ctaLabel: String(d.ctaLabel || 'Open').slice(0, 40),
emoji: String(d.emoji || '').slice(0, 8)
};
}

async function loadDoors() {
const byDay = {};
try {
const mod = await import('./doors.js');
(Array.isArray(mod.DOORS) ? mod.DOORS : []).forEach((d) => {
const n = Number(d && d.day);
if (n >= 1 && n <= 24) byDay[n] = d;
});
} catch (e) {
console.warn('[advent-calendar] doors.js could not be loaded, using placeholders.', e);
}
const out = {};
for (let day = 1; day <= 24; day++) {
out[day] = cleanDoor(byDay[day] || { text: FALLBACK_TEXT }, day);
}
return out;
}

/* ---------- colour and contrast ---------- */

function parseHex(v) {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(v || '').trim());
if (!m) return null;
let h = m[1];
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}

function luminance(rgb) {
const [r, g, b] = rgb.map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrast(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}

function sameHex(a, b) {
return String(a || '').trim().toLowerCase() === b.toLowerCase();
}

/* Pick the first candidate that reaches 4.5:1 on the background, else the strongest one. */
function pickReadable(candidates, bgRgb) {
let best = candidates[0];
let bestRatio = 0;
for (const c of candidates) {
const ratio = contrast(parseHex(c), bgRgb);
if (ratio >= 4.5) return c;
if (ratio > bestRatio) { best = c; bestRatio = ratio; }
}
return best;
}

function resolveTheme(props, cardBg) {
const bg = BACKGROUNDS.includes(props.background) ? props.background : 'none';
const style = STYLES.includes(props.doorStyle) ? props.doorStyle : 'festive';

let bgHex = cardBg;
if (bg === 'red') bgHex = RED_BG;
else if (bg === 'gifts') bgHex = GIFTS_BG;
else if (bg === 'solid') bgHex = parseHex(props.backgroundColor) ? props.backgroundColor.trim() : '#FFF1D6';
const bgRgb = parseHex(bgHex) || [255, 255, 255];

const hProp = parseHex(props.headingColor) ? props.headingColor.trim() : DEFAULT_HEADING;
const sProp = parseHex(props.subtextColor) ? props.subtextColor.trim() : DEFAULT_SUBTEXT;
let heading = hProp;
let subtext = sProp;
// Colours left at the defaults adapt to the background. Custom colours are respected.
if (sameHex(hProp, DEFAULT_HEADING)) heading = pickReadable([DEFAULT_HEADING, LIGHT_HEADING, '#FFFFFF', '#000000'], bgRgb);
if (sameHex(sProp, DEFAULT_SUBTEXT)) subtext = pickReadable([DEFAULT_SUBTEXT, LIGHT_SUBTEXT, '#FFFFFF', '#000000'], bgRgb);

const dark = luminance(bgRgb) < 0.3;
return { bg, style, bgHex, heading, subtext, dark };
}

/* ---------- inline SVG artwork (self contained, no remote assets) ---------- */

function motif(kind, base) {
const w = 'fill="#fff" fill-opacity="0.42"';
const shapes = {
tree: '<path d="M20 3l9 12h-5l8 11H8l8-11h-5z" ' + w + '/><rect x="18" y="26" width="4" height="8" ' + w + '/>',
cane: '<path d="M27 36V15a7 7 0 0 0-14 0" fill="none" stroke="#fff" stroke-opacity="0.42" stroke-width="6" stroke-linecap="round"/>' +
'<path d="M27 36V15a7 7 0 0 0-14 0" fill="none" stroke="' + base + '" stroke-width="6" stroke-dasharray="3 5.5"/>',
ornament: '<rect x="19" y="2" width="2" height="6" ' + w + '/><rect x="15.5" y="8" width="9" height="4" rx="1" ' + w + '/>' +
'<circle cx="20" cy="24" r="12" ' + w + '/><path d="M12 24a8 8 0 0 1 8-8" fill="none" stroke="' + base + '" stroke-width="2" stroke-linecap="round"/>',
star: '<polygon points="20,3 24.6,14.6 37,15.5 27.5,23.6 30.5,36 20,29.2 9.5,36 12.5,23.6 3,15.5 15.4,14.6" ' + w + '/>'
};
return svgUrl('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">' + shapes[kind] + '</svg>');
}

function paper(kind) {
const tiles = {
dots: '<circle cx="6" cy="6" r="1.8" fill="#fff" fill-opacity="0.5"/><circle cx="18" cy="18" r="1.8" fill="#fff" fill-opacity="0.5"/>',
stripes: '<path d="M-6 6L6 -6M0 24L24 0M18 30L30 18" stroke="#F4E3AE" stroke-opacity="0.4" stroke-width="3"/>',
crosses: '<path d="M6 3v6M3 6h6M18 15v6M15 18h6" stroke="#fff" stroke-opacity="0.5" stroke-width="1.6" stroke-linecap="round"/>',
waves: '<path d="M0 6q6-5 12 0t12 0M0 18q6-5 12 0t12 0" fill="none" stroke="#fff" stroke-opacity="0.38" stroke-width="1.6"/>'
};
return svgUrl('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">' + tiles[kind] + '</svg>');
}

const BOW = svgUrl(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 24">' +
'<path d="M20 12C14 2 3 3 4 11c1 7 11 4 16 1zM20 12c6-10 17-9 16-1-1 7-11 4-16 1z" fill="#F4E3AE" stroke="#B8962E" stroke-width="1.3" stroke-linejoin="round"/>' +
'<circle cx="20" cy="12" r="3.4" fill="#F4E3AE" stroke="#B8962E" stroke-width="1.3"/></svg>'
);

const ICON_LOCK = svgUrl(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="7" width="10" height="7" rx="1.5" fill="#4F4438"/>' +
'<path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="#4F4438" stroke-width="1.6"/></svg>'
);

function lockNote(color) {
return svgUrl(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="7" width="10" height="7" rx="1.5" fill="' + color + '"/>' +
'<path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="' + color + '" stroke-width="1.6"/></svg>'
);
}

const ICON_CHECK = svgUrl(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="8" fill="#2F6B4F"/>' +
'<path d="M4.5 8.3l2.3 2.3 4.7-4.9" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>'
);

const GIFT_PATTERN = (stroke) => svgUrl(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<g transform="translate(8 10) rotate(-8 12 12)"><rect x="2" y="9" width="20" height="14" rx="2"/><path d="M12 9v14M2 15h20"/><path d="M12 9C8 2 3 5 6 8zM12 9c4-7 9-4 6-1z"/></g>' +
'<g transform="translate(58 52) rotate(10 12 12)"><rect x="2" y="9" width="20" height="14" rx="2"/><path d="M12 9v14M2 15h20"/><path d="M12 9C8 2 3 5 6 8zM12 9c4-7 9-4 6-1z"/></g>' +
'<g transform="translate(60 6) scale(.7)"><rect x="2" y="9" width="20" height="14" rx="2"/><path d="M12 9v14M2 15h20"/></g>' +
'<g transform="translate(10 58) scale(.7)"><rect x="2" y="9" width="20" height="14" rx="2"/><path d="M12 9v14M2 15h20"/></g>' +
'</svg>'
);

const RED_PATTERN = svgUrl(
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><g fill="#fff" fill-opacity="0.08">' +
'<path d="M10 4l2 4 4 2-4 2-2 4-2-4-4-2 4-2zM30 24l2 4 4 2-4 2-2 4-2-4-4-2 4-2z"/><circle cx="30" cy="8" r="1.4"/><circle cx="10" cy="30" r="1.4"/></g></svg>'
);

function applyArt(root, theme) {
const s = root.style;
s.setProperty('--motif-0', motif('tree', '#9B2D2A'));
s.setProperty('--motif-1', motif('cane', '#2F6B4F'));
s.setProperty('--motif-2', motif('ornament', '#7D2E36'));
s.setProperty('--motif-3', motif('star', '#285B45'));
s.setProperty('--paper-0', paper('dots'));
s.setProperty('--paper-1', paper('stripes'));
s.setProperty('--paper-2', paper('crosses'));
s.setProperty('--paper-3', paper('waves'));
s.setProperty('--bow', BOW);
s.setProperty('--ico-lock', ICON_LOCK);
s.setProperty('--ico-lock-note', lockNote(theme.subtext));
s.setProperty('--ico-check', ICON_CHECK);
}

function applyTheme(root, theme) {
const s = root.style;
root.dataset.door = theme.style;
root.dataset.bg = theme.bg;
root.dataset.dark = theme.dark ? '1' : '0';
s.setProperty('--adv-heading', theme.heading);
s.setProperty('--adv-subtext', theme.subtext);
s.setProperty('--adv-ring', theme.heading);
s.removeProperty('--adv-bg-color');
s.removeProperty('--adv-bg-image');
if (theme.bg === 'solid') s.setProperty('--adv-bg-color', theme.bgHex);
if (theme.bg === 'red') {
s.setProperty('--adv-bg-color', RED_BG);
s.setProperty('--adv-bg-image', RED_PATTERN);
}
if (theme.bg === 'gifts') {
s.setProperty('--adv-bg-color', GIFTS_BG);
s.setProperty('--adv-bg-image', GIFT_PATTERN('#E6D5B5'));
}
}

/* ---------- dates ---------- */

function ymd(y, m, d) {
return y * 10000 + (m + 1) * 100 + d;
}

/* Strict YYYY-MM-DD. Returns { y, m, d } with m zero based, or null. */
function parseDate(str) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(str || '').trim());
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]) - 1;
const d = Number(m[3]);
const check = new Date(y, mo, d);
if (check.getFullYear() !== y || check.getMonth() !== mo || check.getDate() !== d) return null;
return { y, m: mo, d };
}

function urlPreview() {
try {
return new URLSearchParams(window.location.search).get('advent_preview');
} catch (e) {
return null;
}
}

/* The ?advent_preview=YYYY-MM-DD param wins over the previewDate setting. */
function effectiveDate(props) {
const fromUrl = parseDate(urlPreview());
if (fromUrl) return { ...fromUrl, preview: true };
const fromProp = parseDate(props.previewDate);
if (fromProp) return { ...fromProp, preview: true };
const now = new Date();
return { y: now.getFullYear(), m: now.getMonth(), d: now.getDate(), preview: false };
}

function pad(n) {
return String(n).padStart(2, '0');
}

/* ---------- storage ---------- */

function loadOpened(key) {
try {
const v = JSON.parse(window.localStorage.getItem(key));
return Array.isArray(v) ? v.filter((n) => Number.isInteger(n)) : [];
} catch (e) {
return [];
}
}

function saveOpened(key, list) {
try { window.localStorage.setItem(key, JSON.stringify(list)); } catch (e) { /* storage blocked */ }
}

/* ---------- widget ---------- */

export async function init(sdk) {
await sdk.whenReady();

const root = sdk.$('.adv');
const grid = sdk.$('.adv__grid');
const panel = sdk.$('.adv__panel');
const layout = sdk.$('.adv__layout');
const titleEl = sdk.$('.adv__title');
const subEl = sdk.$('.adv__sub');
const noteEl = sdk.$('.adv__note');
const previewEl = sdk.$('.adv__preview');
if (!root || !grid || !panel) return;

const doors = await loadDoors();

let props = sdk.getProps() || {};
let now = effectiveDate(props);
let calY = now.y;
let calM = 11;
let storeKey = '';
let opened = [];
let selected = null;

function cardBackground() {
try {
const v = getComputedStyle(root).getPropertyValue('--config-card-background-color');
if (parseHex(v)) return v.trim();
} catch (e) { /* use the fallback */ }
return '#FFFFFF';
}

function readCalendar() {
const month = Number(props.calendarMonth);
calM = month >= 1 && month <= 12 ? month - 1 : 11;
const year = Number(props.calendarYear);
calY = Number.isInteger(year) && year >= 2000 && year <= 2200 ? year : now.y;
}

function monthLabel() {
return MONTHS[calM];
}

function isUnlocked(day) {
return ymd(now.y, now.m, now.d) >= ymd(calY, calM, day);
}

function inCalendarMonth() {
return now.y === calY && now.m === calM;
}

function beforeStart() {
return !isUnlocked(1);
}

function subtitleText() {
if (props.subtitle) return props.subtitle;
if (beforeStart()) return 'Twenty four small things to try, one door a day.';
if (inCalendarMonth() && now.d < 24) return 'A new door opens every day until the 24th.';
if (inCalendarMonth()) return 'Every door is open. Pick any to see what is inside.';
return 'Missed a few? Every door is open now.';
}

function doorState(day) {
if (!isUnlocked(day)) return 'locked, opens on ' + day + ' ' + monthLabel();
if (opened.includes(day)) return 'opened';
if (inCalendarMonth() && now.d === day) return 'ready to open, today’s door';
return 'ready to open';
}

function renderGrid() {
grid.textContent = '';
ORDER.forEach((day) => {
const unlocked = isUnlocked(day);
const isOpen = unlocked && opened.includes(day);
const btn = el('button', 'door');
btn.type = 'button';
btn.dataset.day = String(day);
btn.dataset.v = String(VARIANT[day] || 0);
if (day === 24) btn.classList.add('door--big');
if (!unlocked) btn.classList.add('is-locked');
if (isOpen) btn.classList.add('is-open');
if (inCalendarMonth() && now.d === day) btn.classList.add('is-today');
if (selected === day) btn.classList.add('is-selected');
btn.setAttribute('aria-label', 'Door ' + day + ', ' + doorState(day));
if (!unlocked) btn.setAttribute('aria-disabled', 'true');

const behind = el('span', 'door__behind', doors[day].emoji || String(day));
behind.setAttribute('aria-hidden', 'true');
const leaf = el('span', 'door__leaf');
leaf.setAttribute('aria-hidden', 'true');
leaf.appendChild(el('span', 'door__num', String(day)));
btn.append(behind, leaf);
grid.appendChild(btn);
});
}

function renderPanelMessage(text) {
panel.textContent = '';
panel.appendChild(el('p', 'adv__hint', text));
}

function renderDoor(day) {
const d = doors[day];
panel.textContent = '';
if (d.emoji) {
const em = el('span', 'adv__emoji', d.emoji);
em.setAttribute('aria-hidden', 'true');
panel.appendChild(em);
}
panel.appendChild(el('p', 'adv__kicker', 'Door ' + day));
panel.appendChild(el('h3', null, d.title));
d.text.split(/\n\s*\n/).forEach((chunk) => {
const t = chunk.trim();
if (t) panel.appendChild(el('p', null, t));
});
if (d.url) {
const a = el('a', 'adv__btn', d.ctaLabel);
a.href = d.url;
if (/^https:\/\//i.test(d.url) && !d.url.startsWith(window.location.origin)) {
a.target = '_blank';
a.rel = 'noopener noreferrer';
}
panel.appendChild(a);
}
}

function defaultPanel() {
if (inCalendarMonth() && now.d <= 24 && opened.includes(now.d)) {
selected = now.d;
renderDoor(selected);
} else if (inCalendarMonth() && now.d <= 24) {
renderPanelMessage('Open door ' + now.d + ' to see today’s surprise.');
} else {
renderPanelMessage('Pick a door to see what is inside.');
}
}

function markSelected() {
grid.querySelectorAll('.door').forEach((b) => {
b.classList.toggle('is-selected', Number(b.dataset.day) === selected);
});
}

grid.addEventListener('click', (e) => {
const btn = e.target.closest('.door');
if (!btn) return;
const day = Number(btn.dataset.day);

if (!isUnlocked(day)) {
btn.classList.remove('is-shake');
void btn.offsetWidth;
btn.classList.add('is-shake');
if (layout.dataset.panel === 'on') {
selected = null;
markSelected();
renderPanelMessage('Door ' + day + ' opens on ' + day + ' ' + monthLabel() + '. No peeking.');
}
return;
}

if (!opened.includes(day)) {
opened.push(day);
saveOpened(storeKey, opened);
btn.classList.add('is-open');
}
btn.setAttribute('aria-label', 'Door ' + day + ', ' + doorState(day));

selected = day;
markSelected();
renderDoor(day);
});

function render() {
now = effectiveDate(props);
readCalendar();

// Preview runs keep their own opened list so a demo never touches live state.
storeKey = 'adv-opened-' + calY + '-' + pad(calM + 1) + (now.preview ? '-preview' : '');
opened = loadOpened(storeKey);
selected = null;

const theme = resolveTheme(props, cardBackground());
applyArt(root, theme);
applyTheme(root, theme);
if (theme.bg !== 'none') {
const need = contrast(parseHex(theme.heading), parseHex(theme.bgHex));
if (need < 4.5) console.warn('[advent-calendar] Heading colour has low contrast (' + need.toFixed(1) + ':1) on this background.');
}

if (titleEl) titleEl.textContent = props.title || 'Community advent calendar';
if (subEl) subEl.textContent = subtitleText();

if (previewEl) {
previewEl.hidden = !now.preview;
previewEl.textContent = now.preview ? 'Preview: ' + now.y + '-' + pad(now.m + 1) + '-' + pad(now.d) : '';
}

const before = beforeStart();
if (noteEl) {
noteEl.hidden = !before;
noteEl.textContent = before ? 'Nothing to open yet. The first door opens on 1 ' + monthLabel() + '.' : '';
}
layout.dataset.panel = before ? 'off' : 'on';
panel.hidden = before;

if (!before) defaultPanel();
renderGrid();
}

render();

sdk.on('propsChanged', (next) => {
props = next || {};
render();
});
}

doors.js:

/*
* Advent calendar: door content
* ------------------------------------------------------------------
* Edit this file in GitHub to change what is behind each door. You do not
* need to touch app.js. Commit the change and the widget picks it up.
*
* Why a file and not a Control setting: Developer Studio config fields only
* support text, number, color, date, boolean, select and autocomplete. There
* is no long text or JSON field, so 24 doors cannot be pasted into Control.
*
* Fields per door
* day 1 to 24. Required. The door opens on this day of the month.
* title Short heading shown in the panel. Required.
* text Short body. Plain text only. A blank line starts a new paragraph.
* url Optional link. Allowed: "/path", "#hash" or "https://...".
* Anything else is ignored. "#question-pursuit" opens the
* Question Pursuit pop-up.
* ctaLabel Optional button label. Defaults to "Open".
* emoji Optional. Shown inside the door once it is open.
*
* Keep each text under about 200 characters so it fits the sidebar panel.
* Only link to pages that exist on your site. "/", "/ideas", "/events", "/search",
* "/p/resources", "/p/benchmark" and "/p/ai_automation" are used below.
* Do not use em dashes. If a door is missing, the widget shows a plain
* placeholder for that day so the calendar never breaks.
*/

export const DOORS = [
{
day: 1,
title: 'Welcome to the calendar',
text: 'Twenty four small things to try this month, one door a day. Start by taking a look around the community.',
url: '/',
ctaLabel: 'Go to the homepage',
emoji: '🌯'
},
{
day: 2,
title: 'Play a round of Question Pursuit',
text: 'Spin the wheel and see how many real community questions you can answer.',
url: '#question-pursuit',
ctaLabel: 'Play now',
emoji: '🎯'
},
{
day: 3,
title: 'Post the idea you keep thinking about',
text: 'Every product has one missing feature. If yours is not on the ideas board yet, add it today.',
url: '/ideas',
ctaLabel: 'Open the ideas board',
emoji: 'πŸ’‘'
},
{
day: 4,
title: 'Search with fewer words',
text: 'Two or three key words usually find better matches than a full sentence. Try it before you post a question.',
url: '/search',
ctaLabel: 'Try a search',
emoji: 'πŸ”Ž'
},
{
day: 5,
title: 'Answer an open question',
text: 'Find a question nobody has replied to yet and answer it. A link to the right article counts.',
url: '#question-pursuit',
ctaLabel: 'Answer a question',
emoji: 'πŸ™‹'
},
{
day: 6,
title: 'Leave three likes',
text: 'Like three posts that helped you this year. It takes a minute and it makes someone’s day.',
emoji: 'πŸ‘'
},
{
day: 7,
title: 'Mark a best answer',
text: 'If someone solved your problem, mark their reply as the best answer so the next person finds it straight away.',
emoji: 'βœ…'
},
{
day: 8,
title: 'Tune your notifications',
text: 'Email for replies to your own posts and a weekly digest for the rest works well for most people.',
emoji: 'πŸ””'
},
{
day: 9,
title: 'Vote on three ideas',
text: 'Pick three ideas you would actually use and give them your vote. The most voted ones get looked at first.',
url: '/ideas',
ctaLabel: 'Browse ideas',
emoji: 'πŸ—³οΈ'
},
{
day: 10,
title: 'Check an upcoming event',
text: 'There is usually something coming up that is worth an hour. Take a look and save a spot.',
url: '/events',
ctaLabel: 'See events',
emoji: 'πŸ“…'
},
{
day: 11,
title: 'Add a screenshot',
text: 'A screenshot or a code block makes a question much easier to answer. Try one in your next post.',
emoji: 'πŸ“Έ'
},
{
day: 12,
title: 'Halfway there',
text: 'Look at your own profile and see how many people you helped this year. It is usually more than you think.',
emoji: 'πŸŽ‰'
},
{
day: 13,
title: 'Browse the resource library',
text: 'Templates, guides and builds in one place. There is usually one that saves you an hour later.',
url: '/p/resources',
ctaLabel: 'Open the resources',
emoji: 'πŸ“š'
},
{
day: 14,
title: 'Check your rank',
text: 'Replies, likes and best answers all move you up the ranks. See how close you are to the next one.',
emoji: 'πŸ…'
},
{
day: 15,
title: 'Share a shortcut',
text: 'Post one tip or workaround you picked up this year. Somebody out there is still doing it the slow way.',
emoji: '⚑'
},
{
day: 16,
title: 'Look at the benchmarks',
text: 'See how your community compares and pick one number you would like to move next year.',
url: '/p/benchmark',
ctaLabel: 'Open benchmarks',
emoji: 'πŸ“Š'
},
{
day: 17,
title: 'Close the loop on an old question',
text: 'Go back to a question you asked and add what fixed it, so the thread helps the next person.',
emoji: 'πŸ”'
},
{
day: 18,
title: 'Welcome a newcomer',
text: 'Find someone who posted for the first time this month and give them a friendly reply.',
emoji: 'πŸ‘‹'
},
{
day: 19,
title: 'Explain your use case',
text: 'Comment on an idea with a real example of how you would use it. That tells the team more than a vote alone.',
url: '/ideas',
ctaLabel: 'Open the ideas board',
emoji: '🧩'
},
{
day: 20,
title: 'Try an AI automation',
text: 'Pick one use case from the AI automation page and see whether it would save you time.',
url: '/p/ai_automation',
ctaLabel: 'See the use cases',
emoji: 'πŸ€–'
},
{
day: 21,
title: 'Show us your desk',
text: 'Shortest day of the year in the northern hemisphere. Post a photo of your workspace in the off topic area.',
emoji: 'πŸ–₯️'
},
{
day: 22,
title: 'Thank someone',
text: 'Name one community member who helped you this year and tell them what difference it made.',
emoji: 'πŸ™'
},
{
day: 23,
title: 'Share a goal for next year',
text: 'Post one thing you want to learn in the new year. Someone here probably knows it already.',
emoji: 'πŸš€'
},
{
day: 24,
title: 'Last door',
text: 'Thanks for being part of the community this year. Enjoy the break and see you in the new year.',
url: '#question-pursuit',
ctaLabel: 'One more round',
emoji: '🎁'
}
];

Registry entry:

{
"version": "2.0.0",
"title": "Advent calendar",
"type": "fun_advent_calendar",
"description": "24 doors that open one per day in a chosen month. Each door holds a short tip, an optional link and an emoji, edited in doors.js.",
"category": "custom",
"source": {
"path": "widgets/advent-calendar",
"entry": "index.html"
},
"configuration": {
"properties": [
{
"name": "title",
"type": "text",
"label": "Heading",
"defaultValue": "Community advent calendar",
"rules": {
"maxLength": 80
}
},
{
"name": "subtitle",
"type": "text",
"label": "Subtext",
"description": "Leave empty to show a message that follows the date.",
"defaultValue": "",
"rules": {
"maxLength": 160
}
},
{
"name": "doorStyle",
"type": "select",
"label": "Door style",
"description": "Festive: red and green doors with small motifs. Simple: solid red doors. Gift wrap: every door is a wrapped present.",
"defaultValue": "festive",
"options": [
{
"value": "festive",
"label": "Festive"
},
{
"value": "simple",
"label": "Simple"
},
{
"value": "giftwrap",
"label": "Gift wrap"
}
]
},
{
"name": "headingColor",
"type": "color",
"label": "Heading colour",
"description": "Left at the default, the heading turns light automatically on a dark background.",
"defaultValue": "#241C15"
},
{
"name": "subtextColor",
"type": "color",
"label": "Subtext colour",
"description": "Left at the default, the subtext turns light automatically on a dark background.",
"defaultValue": "#6E6154"
},
{
"name": "background",
"type": "select",
"label": "Background",
"defaultValue": "none",
"options": [
{
"value": "none",
"label": "None (plain card)"
},
{
"value": "solid",
"label": "Solid colour"
},
{
"value": "red",
"label": "Deep red with pattern"
},
{
"value": "gifts",
"label": "Light gift boxes"
}
]
},
{
"name": "backgroundColor",
"type": "color",
"label": "Background colour",
"defaultValue": "#FFF1D6",
"visibleWhen": [
{
"field": "background",
"operator": "eq",
"value": "solid"
}
]
},
{
"name": "calendarMonth",
"type": "select",
"label": "Calendar month",
"description": "Door N opens on day N of this month.",
"defaultValue": "12",
"options": [
{
"value": "1",
"label": "January"
},
{
"value": "2",
"label": "February"
},
{
"value": "3",
"label": "March"
},
{
"value": "4",
"label": "April"
},
{
"value": "5",
"label": "May"
},
{
"value": "6",
"label": "June"
},
{
"value": "7",
"label": "July"
},
{
"value": "8",
"label": "August"
},
{
"value": "9",
"label": "September"
},
{
"value": "10",
"label": "October"
},
{
"value": "11",
"label": "November"
},
{
"value": "12",
"label": "December"
}
]
},
{
"name": "calendarYear",
"type": "number",
"label": "Calendar year",
"description": "Leave empty to use the current year.",
"rules": {
"minimum": 2000,
"maximum": 2200
}
},
{
"name": "previewDate",
"type": "text",
"label": "Staff preview date",
"description": "Format YYYY-MM-DD, for example 2026-12-10. For demos only. Leave empty for live behaviour. A ?advent_preview=YYYY-MM-DD link overrides it.",
"defaultValue": "",
"rules": {
"pattern": "^(\\d{4}-\\d{2}-\\d{2})?$"
}
}
]
},
"defaultConfig": {
"title": "Community advent calendar",
"subtitle": "",
"doorStyle": "festive",
"headingColor": "#241C15",
"subtextColor": "#6E6154",
"background": "none",
"backgroundColor": "#FFF1D6",
"calendarMonth": "12",
"previewDate": ""
},
"settings": {
"configurable": true
},
"containers": [
"Full width",
"Sidebar"
],
"widgetsLibrary": true
}

How to change it

Door content lives in doors.js, not in a Control setting. Developer Studio configuration fields have no long text or JSON type, so 24 doors cannot be pasted into Control. Each door has these fields:

  • day: 1 to 24. Required.
  • title: the heading shown in the panel. Required.
  • text: the body. Plain text only. A blank line starts a new paragraph.
  • url: optional. The allowed values are "/path", "#hash" and "https://...". Anything else is ignored.
  • ctaLabel: optional button label. The default is "Open".
  • emoji: optional. Shown inside the door once it is open.

If a door is missing from the file, the widget shows a placeholder for that day.

The Control settings are:

  • Heading (title)
  • Subtext (subtitle). Leave it empty to show a message that follows the date.
  • Door style (doorStyle): festive, simple or gift wrap.
  • Heading colour (headingColor) and Subtext colour (subtextColor). Left at the defaults, they turn light on a dark background.
  • Background (background): none, solid colour, deep red with pattern, or light gift boxes.
  • Background colour (backgroundColor). Only shown when the background is a solid colour.
  • Calendar month (calendarMonth). Door N opens on day N of this month. The default is December.
  • Calendar year (calendarYear). Leave it empty to use the current year.
  • Staff preview date (previewDate): for demos and tests.

How to test it before the season starts

Add ?advent_preview=YYYY-MM-DD to the page URL, for example ?advent_preview=2026-12-10. You can also set the Staff preview date in Control. The URL parameter wins over the setting. Preview mode keeps its own list of opened doors, so a demo never touches what real members have opened.

Limits and known gaps

  • Door 24 is double width and always last. The other doors appear in a fixed shuffled order.
  • Opened doors are remembered in the visitor's browser (localStorage), not per account. Switching device or browser starts fresh.
  • Doors open by the visitor's own local date.
  • When the widget is wide, the content panel sits beside the grid. In a narrow container, under about 400 pixels such as a sidebar, the grid switches to 4 columns with a compact panel underneath.
  • Before the first door opens, the widget shows a note and hides the content panel. After the 24th, every door stays open.

How to remove it after the holidays

Remove the widget from the page in Control. To remove it completely, delete the widgets/advent-calendar folder and its entry in extensions_registry.json, then commit and push.