Eight polished canvas widgets: counter, checklist, stopwatch, timer, clock, progress tracker, date countdown, and color palette.
Try it live
Real, working widgets — running right here.
Resource content
This is the actual file you get when you install — read it here, or copy it to use anywhere.
extension.js
// Canvas Essentials - a single-file widget bundle for the Undra canvas.
//
// Registers 8 widgets via the canvas-widget contract:
//
// ctx.registerCanvasWidgets([{ widgetKind, title, defaultData, defaultSize, component }])
//
// Each component receives `{ data, setData, width, height }`. `data` is the
// node's persisted state; `setData(next)` persists a full replacement object.
// Pure ES module - NO imports; React comes through `ctx.runtime`.
//
// Widgets + persisted data shapes:
// 1. Counter community.counter.tally { count:number }
// 2. Checklist community.essentials.checklist { items:[{ id, text, done }] }
// 3. Stopwatch community.essentials.stopwatch { running:boolean, accumulatedMs:number, startedAtMs:number|null }
// 4. Timer community.essentials.timer { durationMin:number, endsAtMs:number|null }
// 5. Clock community.essentials.clock { h24:boolean }
// 6. Progress community.essentials.progress { label:string, current:number, target:number }
// 7. Days Until community.essentials.days-until { label:string, dateIso:string|null }
// 8. Palette community.essentials.palette { colors:string[] }
//
// Engineering notes:
// • Clocks/timers tick on LOCAL state only; setData is never called from an
// interval - only on user actions (start/stop/reset/toggle/etc).
// • All intervals/timeouts are cleaned up in effect cleanups, so multiple
// instances coexist and unmount cleanly on reload/disable.
// • Every data field is read defensively with type checks + fallbacks.
// • Inputs stop pointer/key events from propagating so typing or picking a
// date/color never drags the canvas node underneath.
// • Shared visuals live in ONE injected stylesheet (namespaced `ce-`
// classes, id-guarded so it's inserted once and refreshed on reload);
// inline styles carry only per-instance values (swatch color, bar width,
// per-card layout). Classes give us :hover/:active/:focus-visible and a
// `prefers-reduced-motion` escape hatch that inline styles can't express.
export const manifest = {
id: 'canvas-essentials',
version: '0.1.0',
displayName: 'Canvas Essentials',
description:
'Eight handy canvas widgets in one bundle: counter, checklist, stopwatch, timer, clock, progress bar, days-until countdown, and a color palette. Drop them on any canvas from the right-click Widgets menu.',
author: 'Undra',
homepage: 'https://www.undra.com',
license: 'MIT',
}
// ---------------------------------------------------------------------------
// Injected stylesheet - one card language for all 8 widgets.
// ---------------------------------------------------------------------------
const STYLE_ID = 'ce-essentials-style'
const CSS = `
.ce-card {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 12px;
gap: 8px;
border-radius: 10px;
background: var(--u-surface, #1e1e22);
border: 1px solid var(--u-border, rgba(255,255,255,0.12));
color: var(--u-text, #e5e7eb);
font-family: system-ui, sans-serif;
user-select: none;
overflow: hidden;
color-scheme: dark;
}
.ce-card--alert {
border-color: var(--u-accent, #6366f1);
animation: ce-pulse 1.1s ease-in-out infinite;
}
@keyframes ce-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(99,102,241,0); }
50% { box-shadow: 0 0 0 4px rgba(99,102,241,0.35); }
}
.ce-num { font-variant-numeric: tabular-nums; }
.ce-muted { font-size: 11px; color: rgba(229,231,235,0.55); }
/* --- Buttons --------------------------------------------------------- */
.ce-btn {
cursor: pointer;
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
line-height: 1.2;
transition: background 130ms ease, border-color 130ms ease, color 130ms ease,
filter 130ms ease, transform 120ms ease;
}
.ce-btn:active { transform: translateY(1px); }
.ce-btn:focus-visible {
outline: 2px solid var(--u-accent, #6366f1);
outline-offset: 1px;
}
.ce-btn--primary {
border: 1px solid var(--u-accent, #6366f1);
background: var(--u-accent, #6366f1);
color: #fff;
}
.ce-btn--primary:hover { filter: brightness(1.12); }
.ce-btn--ghost {
border: 1px solid var(--u-border, rgba(255,255,255,0.12));
background: transparent;
color: var(--u-text, #e5e7eb);
}
.ce-btn--ghost:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(255,255,255,0.22);
}
.ce-btn--subtle {
border: none;
background: transparent;
color: rgba(229,231,235,0.45);
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 5px;
}
.ce-btn--subtle:hover {
color: rgba(229,231,235,0.85);
background: rgba(255,255,255,0.06);
}
/* --- Inputs ----------------------------------------------------------- */
.ce-input {
box-sizing: border-box;
width: 100%;
padding: 5px 8px;
border-radius: 6px;
border: 1px solid var(--u-border, rgba(255,255,255,0.12));
background: rgba(255,255,255,0.05);
color: var(--u-text, #e5e7eb);
font-size: 12px;
font-family: inherit;
outline: none;
transition: border-color 130ms ease, background 130ms ease;
}
.ce-input:hover { background: rgba(255,255,255,0.07); }
.ce-input:focus-visible {
border-color: var(--u-accent, #6366f1);
background: rgba(255,255,255,0.07);
}
.ce-input[type='date'] { cursor: pointer; }
.ce-input[type='date']::-webkit-calendar-picker-indicator {
opacity: 0.6;
cursor: pointer;
}
.ce-input--bare {
border: none;
background: transparent;
width: 38px;
padding: 1px 3px;
border-radius: 4px;
text-align: center;
-moz-appearance: textfield;
appearance: textfield;
}
.ce-input--bare::-webkit-outer-spin-button,
.ce-input--bare::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ce-input--bare:hover { background: rgba(255,255,255,0.08); }
.ce-color {
flex: none;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--u-border, rgba(255,255,255,0.12));
border-radius: 7px;
background: transparent;
cursor: pointer;
transition: border-color 130ms ease, transform 120ms ease;
}
.ce-color:hover { border-color: rgba(255,255,255,0.3); }
.ce-color:focus-visible { outline: 2px solid var(--u-accent, #6366f1); outline-offset: 1px; }
.ce-color::-webkit-color-swatch-wrapper { padding: 0; }
.ce-color::-webkit-color-swatch { border: none; border-radius: 6px; }
.ce-color::-moz-color-swatch { border: none; border-radius: 6px; }
/* --- Editable title --------------------------------------------------- */
.ce-title {
font-size: 12px;
font-weight: 600;
cursor: text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background 130ms ease;
}
.ce-title:hover { background: rgba(255,255,255,0.06); }
/* --- Checklist rows ---------------------------------------------------- */
.ce-row {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 6px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: background 130ms ease;
}
.ce-row:hover { background: rgba(255,255,255,0.06); }
.ce-check {
flex: none;
box-sizing: border-box;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid rgba(229,231,235,0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
line-height: 1;
color: transparent;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.ce-row:hover .ce-check { border-color: var(--u-accent, #6366f1); }
.ce-check--done {
background: var(--u-accent, #6366f1);
border-color: var(--u-accent, #6366f1);
color: #fff;
}
.ce-row-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 140ms ease;
}
.ce-row-text--done {
text-decoration: line-through;
color: rgba(229,231,235,0.4);
}
.ce-row-x {
flex: none;
cursor: pointer;
border: none;
background: transparent;
color: rgba(229,231,235,0.45);
font-size: 12px;
line-height: 1;
padding: 0 4px;
border-radius: 4px;
opacity: 0;
transition: opacity 130ms ease, color 130ms ease;
}
.ce-row:hover .ce-row-x { opacity: 1; }
.ce-row-x:hover { color: #fff; }
.ce-row-x:focus-visible {
opacity: 1;
outline: 2px solid var(--u-accent, #6366f1);
outline-offset: 1px;
}
/* --- Timer preset chips (segmented row) -------------------------------- */
.ce-seg {
display: flex;
border: 1px solid var(--u-border, rgba(255,255,255,0.12));
border-radius: 999px;
overflow: hidden;
}
.ce-chip {
cursor: pointer;
border: none;
background: transparent;
color: rgba(229,231,235,0.55);
font-size: 11px;
font-weight: 600;
font-family: inherit;
line-height: 1.2;
padding: 3px 10px;
transition: background 130ms ease, color 130ms ease;
}
.ce-chip + .ce-chip { border-left: 1px solid var(--u-border, rgba(255,255,255,0.12)); }
.ce-chip:hover { background: rgba(255,255,255,0.08); color: var(--u-text, #e5e7eb); }
.ce-chip:focus-visible {
outline: 2px solid var(--u-accent, #6366f1);
outline-offset: -2px;
}
.ce-chip--on {
background: var(--u-accent, #6366f1);
color: #fff;
}
.ce-chip--on:hover { background: var(--u-accent, #6366f1); }
/* --- Progress bar ------------------------------------------------------ */
.ce-bar {
height: 10px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.ce-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--u-accent, #6366f1);
transition: width 220ms ease;
}
/* --- Palette swatches --------------------------------------------------- */
.ce-swatch {
position: relative;
width: 34px;
height: 34px;
border-radius: 7px;
border: 1px solid var(--u-border, rgba(255,255,255,0.12));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 130ms ease, box-shadow 130ms ease;
}
.ce-swatch:hover {
transform: scale(1.06);
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.ce-swatch-x {
position: absolute;
top: -5px;
right: -5px;
width: 14px;
height: 14px;
padding: 0;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.75);
color: #fff;
font-size: 10px;
line-height: 14px;
cursor: pointer;
opacity: 0;
transition: opacity 130ms ease;
}
.ce-swatch:hover .ce-swatch-x { opacity: 1; }
.ce-swatch-x:focus-visible {
opacity: 1;
outline: 2px solid var(--u-accent, #6366f1);
outline-offset: 1px;
}
.ce-badge {
font-size: 9px;
font-weight: 700;
color: #fff;
background: rgba(0,0,0,0.65);
border-radius: 4px;
padding: 1px 4px;
pointer-events: none;
}
/* --- Clock ------------------------------------------------------------- */
.ce-clock-time {
cursor: pointer;
border-radius: 6px;
padding: 2px 6px;
margin: -2px -6px;
transition: background 130ms ease;
}
.ce-clock-time:hover { background: rgba(255,255,255,0.06); }
/* --- Reduced motion ------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
.ce-card,
.ce-card * {
transition: none !important;
animation: none !important;
}
.ce-btn:active { transform: none !important; }
.ce-swatch:hover { transform: none !important; }
}
`
function injectStyles() {
if (typeof document === 'undefined') return
let el = document.getElementById(STYLE_ID)
if (!el) {
el = document.createElement('style')
el.id = STYLE_ID
document.head.appendChild(el)
}
// Refresh content on reload so style edits take effect without duplicates.
if (el.textContent !== CSS) el.textContent = CSS
}
// ---------------------------------------------------------------------------
// Activation - defines all 8 components and registers them in one call.
// ---------------------------------------------------------------------------
export function activate(ctx) {
const { createElement: h, useState, useEffect, useRef } = ctx.runtime
injectStyles()
// Keep pointer/key events on interactive controls from reaching the canvas
// node (which would otherwise start a drag or trigger canvas shortcuts).
const stop = (e) => e.stopPropagation()
const guard = { onPointerDown: stop, onMouseDown: stop, onKeyDown: stop }
const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
const str = (v, fallback) => (typeof v === 'string' ? v : fallback)
const pad2 = (n) => String(n).padStart(2, '0')
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
// -------------------------------------------------------------------------
// 1. Counter: { count }
// -------------------------------------------------------------------------
function Counter(props) {
const data = props.data || {}
const count = num(data.count, 0)
const set = (c) => props.setData({ ...data, count: c })
return h(
'div',
{ className: 'ce-card', style: { alignItems: 'center', justifyContent: 'center' } },
h(
'div',
{ className: 'ce-num', style: { fontSize: '34px', fontWeight: 700, lineHeight: 1 } },
String(count),
),
h(
'div',
{ style: { display: 'flex', gap: '6px', alignItems: 'center' } },
h('button', { type: 'button', className: 'ce-btn ce-btn--primary', onClick: () => set(count + 1) }, '+1'),
h('button', { type: 'button', className: 'ce-btn ce-btn--ghost', onClick: () => set(count - 1) }, '−1'),
h('button', { type: 'button', className: 'ce-btn ce-btn--subtle', onClick: () => set(0) }, 'reset'),
),
)
}
// -------------------------------------------------------------------------
// 2. Checklist: { items: [{ id, text, done }] }
// -------------------------------------------------------------------------
function Checklist(props) {
const data = props.data || {}
const items = Array.isArray(data.items)
? data.items.filter((it) => it && typeof it === 'object')
: []
const [draft, setDraft] = useState('')
const doneCount = items.filter((it) => it.done === true).length
const commit = (next) => props.setData({ ...data, items: next })
const addItem = () => {
const text = draft.trim()
if (!text) return
commit(items.concat([{ id: newId(), text, done: false }]))
setDraft('')
}
const row = (it) => {
const done = it.done === true
return h(
'div',
{
key: str(it.id, Math.random().toString(36)),
className: 'ce-row',
onClick: () =>
commit(items.map((x) => (x.id === it.id ? { ...x, done: x.done !== true } : x))),
},
h('span', { className: done ? 'ce-check ce-check--done' : 'ce-check' }, '✓'),
h(
'span',
{ className: done ? 'ce-row-text ce-row-text--done' : 'ce-row-text' },
str(it.text, ''),
),
h(
'button',
{
type: 'button',
title: 'Remove',
className: 'ce-row-x',
onClick: (e) => {
e.stopPropagation()
commit(items.filter((x) => x.id !== it.id))
},
},
'×',
),
)
}
return h(
'div',
{ className: 'ce-card', style: { gap: '6px' } },
h('input', {
...guard,
type: 'text',
placeholder: 'Add item…',
value: draft,
onChange: (e) => setDraft(e.target.value),
onKeyDown: (e) => {
e.stopPropagation()
if (e.key === 'Enter') addItem()
},
className: 'ce-input',
}),
h(
'div',
{ style: { flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1px' } },
items.length === 0
? h('div', { className: 'ce-muted', style: { padding: '4px' } }, 'Nothing yet.')
: items.map(row),
),
h('div', { className: 'ce-muted ce-num' }, doneCount + '/' + items.length + ' done'),
)
}
// -------------------------------------------------------------------------
// 3. Stopwatch: { running, accumulatedMs, startedAtMs }
// Persists only on start/stop/reset; display ticks on local state.
// -------------------------------------------------------------------------
function Stopwatch(props) {
const data = props.data || {}
const running = data.running === true
const accumulatedMs = num(data.accumulatedMs, 0)
const startedAtMs = num(data.startedAtMs, null)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!running) return undefined
const id = setInterval(() => setNow(Date.now()), 100)
return () => clearInterval(id)
}, [running])
const elapsed =
accumulatedMs + (running && typeof startedAtMs === 'number' ? Math.max(0, now - startedAtMs) : 0)
const mins = Math.floor(elapsed / 60000)
const secs = Math.floor((elapsed % 60000) / 1000)
const tenths = Math.floor((elapsed % 1000) / 100)
const start = () =>
props.setData({ ...data, running: true, accumulatedMs, startedAtMs: Date.now() })
const stopWatch = () =>
props.setData({
...data,
running: false,
accumulatedMs:
accumulatedMs + (typeof startedAtMs === 'number' ? Math.max(0, Date.now() - startedAtMs) : 0),
startedAtMs: null,
})
const reset = () =>
props.setData({ ...data, running: false, accumulatedMs: 0, startedAtMs: null })
return h(
'div',
{ className: 'ce-card', style: { alignItems: 'center', justifyContent: 'center' } },
h(
'div',
{ className: 'ce-num', style: { fontSize: '28px', fontWeight: 700, lineHeight: 1 } },
pad2(mins) + ':' + pad2(secs) + '.' + String(tenths),
),
h(
'div',
{ style: { display: 'flex', gap: '6px', alignItems: 'center' } },
running
? h('button', { type: 'button', className: 'ce-btn ce-btn--ghost', onClick: stopWatch }, 'Stop')
: h('button', { type: 'button', className: 'ce-btn ce-btn--primary', onClick: start }, 'Start'),
h('button', { type: 'button', className: 'ce-btn ce-btn--subtle', onClick: reset }, 'reset'),
),
)
}
// -------------------------------------------------------------------------
// 4. Timer: { durationMin, endsAtMs }
// Preset chips pick a duration; Start persists endsAtMs; local ticking.
// -------------------------------------------------------------------------
function Timer(props) {
const data = props.data || {}
const durationMin = num(data.durationMin, 25)
const endsAtMs = num(data.endsAtMs, null)
const armed = typeof endsAtMs === 'number'
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!armed) return undefined
const id = setInterval(() => setNow(Date.now()), 250)
return () => clearInterval(id)
}, [armed])
const remainingMs = armed ? Math.max(0, endsAtMs - now) : durationMin * 60000
const isDone = armed && remainingMs <= 0
const mins = Math.floor(remainingMs / 60000)
const secs = Math.ceil((remainingMs % 60000) / 1000) % 60
const chip = (m) =>
h(
'button',
{
type: 'button',
key: 'chip' + m,
className: durationMin === m && !armed ? 'ce-chip ce-chip--on' : 'ce-chip',
onClick: () => props.setData({ ...data, durationMin: m, endsAtMs: null }),
},
m + 'm',
)
return h(
'div',
{
className: isDone ? 'ce-card ce-card--alert' : 'ce-card',
style: { alignItems: 'center', justifyContent: 'center', gap: '7px' },
},
isDone
? h('div', { style: { fontSize: '22px', fontWeight: 700, color: 'var(--u-accent, #6366f1)' } }, 'Done')
: h(
'div',
{ className: 'ce-num', style: { fontSize: '28px', fontWeight: 700, lineHeight: 1 } },
pad2(mins) + ':' + pad2(secs),
),
h('div', { className: 'ce-seg' }, [5, 15, 25].map(chip)),
h(
'div',
{ style: { display: 'flex', gap: '6px' } },
armed
? h(
'button',
{
type: 'button',
className: isDone ? 'ce-btn ce-btn--primary' : 'ce-btn ce-btn--ghost',
onClick: () => props.setData({ ...data, endsAtMs: null }),
},
isDone ? 'Reset' : 'Cancel',
)
: h(
'button',
{
type: 'button',
className: 'ce-btn ce-btn--primary',
onClick: () =>
props.setData({ ...data, durationMin, endsAtMs: Date.now() + durationMin * 60000 }),
},
'Start',
),
),
)
}
// -------------------------------------------------------------------------
// 5. Clock: { h24 }. Pure local ticking; persists only the format toggle.
// -------------------------------------------------------------------------
function Clock(props) {
const data = props.data || {}
const h24 = data.h24 === true
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 250)
return () => clearInterval(id)
}, [])
let hours = now.getHours()
let suffix = ''
if (!h24) {
suffix = hours >= 12 ? ' PM' : ' AM'
hours = hours % 12 || 12
}
const time = pad2(hours) + ':' + pad2(now.getMinutes()) + ':' + pad2(now.getSeconds())
const dateLine = now.toLocaleDateString(undefined, {
weekday: 'long',
month: 'short',
day: 'numeric',
})
return h(
'div',
{ className: 'ce-card', style: { alignItems: 'center', justifyContent: 'center', gap: '4px' } },
h(
'div',
{
title: 'Toggle 12/24h',
className: 'ce-num ce-clock-time',
onClick: () => props.setData({ ...data, h24: !h24 }),
style: { fontSize: '26px', fontWeight: 700, lineHeight: 1 },
},
time + suffix,
),
h('div', { className: 'ce-muted' }, dateLine),
)
}
// -------------------------------------------------------------------------
// 6. Progress: { label, current, target }
// Two calm rows: label + "current / target" up top, the bar in the
// middle, − / + and the percentage below.
// -------------------------------------------------------------------------
function Progress(props) {
const data = props.data || {}
const label = str(data.label, 'Goal')
const current = Math.max(0, num(data.current, 0))
const target = Math.max(1, num(data.target, 10))
const pct = Math.min(100, Math.round((current / target) * 100))
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(label)
const saveLabel = () => {
setEditing(false)
const next = draft.trim()
if (next && next !== label) props.setData({ ...data, label: next })
}
return h(
'div',
{ className: 'ce-card', style: { justifyContent: 'center', gap: '9px' } },
h(
'div',
{ style: { display: 'flex', alignItems: 'center', gap: '8px' } },
editing
? h('input', {
...guard,
type: 'text',
autoFocus: true,
value: draft,
onChange: (e) => setDraft(e.target.value),
onBlur: saveLabel,
onKeyDown: (e) => {
e.stopPropagation()
if (e.key === 'Enter') saveLabel()
if (e.key === 'Escape') {
setDraft(label)
setEditing(false)
}
},
className: 'ce-input',
style: { flex: 1, minWidth: 0 },
})
: h(
'div',
{
title: 'Click to rename',
className: 'ce-title',
onClick: () => {
setDraft(label)
setEditing(true)
},
style: { flex: 1, minWidth: 0 },
},
label,
),
h(
'div',
{
className: 'ce-muted ce-num',
style: { flex: 'none', display: 'flex', alignItems: 'center', gap: '2px' },
},
String(current) + ' /',
h('input', {
...guard,
type: 'number',
min: 1,
title: 'Target',
value: String(target),
onChange: (e) => {
const v = parseInt(e.target.value, 10)
if (Number.isFinite(v) && v >= 1) props.setData({ ...data, target: v })
},
className: 'ce-input ce-input--bare ce-num',
style: { fontSize: '11px', color: 'inherit' },
}),
),
),
h(
'div',
{ className: 'ce-bar' },
h('div', { className: 'ce-bar-fill', style: { width: pct + '%' } }),
),
h(
'div',
{ style: { display: 'flex', alignItems: 'center', gap: '6px' } },
h(
'button',
{
type: 'button',
className: 'ce-btn ce-btn--ghost',
style: { padding: '3px 11px' },
onClick: () => props.setData({ ...data, current: Math.max(0, current - 1) }),
},
'−',
),
h(
'button',
{
type: 'button',
className: 'ce-btn ce-btn--primary',
style: { padding: '3px 11px' },
onClick: () => props.setData({ ...data, current: current + 1 }),
},
'+',
),
h(
'div',
{ className: 'ce-muted ce-num', style: { flex: 1, textAlign: 'right', fontSize: '12px' } },
pct + '%',
),
),
)
}
// -------------------------------------------------------------------------
// 7. Days Until: { label, dateIso }
// -------------------------------------------------------------------------
function DaysUntil(props) {
const data = props.data || {}
const label = str(data.label, '')
const dateIso = str(data.dateIso, null)
let big = '-'
let sub = 'Pick a date'
if (typeof dateIso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateIso)) {
const target = new Date(dateIso + 'T00:00:00')
if (!Number.isNaN(target.getTime())) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const diff = Math.round((target.getTime() - today.getTime()) / 86400000)
if (diff === 0) {
big = 'Today!'
sub = ''
} else if (diff > 0) {
big = diff + (diff === 1 ? ' day' : ' days')
sub = 'to go'
} else {
big = -diff + (diff === -1 ? ' day' : ' days')
sub = 'ago'
}
}
}
return h(
'div',
{ className: 'ce-card', style: { alignItems: 'center', justifyContent: 'center', gap: '5px' } },
h(
'div',
{
className: 'ce-num',
style: {
fontSize: big === 'Today!' ? '22px' : '24px',
fontWeight: 700,
lineHeight: 1,
color: big === 'Today!' ? 'var(--u-accent, #6366f1)' : 'inherit',
},
},
big,
),
sub ? h('div', { className: 'ce-muted' }, sub) : null,
h(
'div',
{ style: { display: 'flex', gap: '5px', width: '100%' } },
h('input', {
...guard,
type: 'text',
placeholder: 'Label…',
value: label,
onChange: (e) => props.setData({ ...data, label: e.target.value }),
className: 'ce-input',
style: { flex: 1, minWidth: 0, fontSize: '11px', padding: '3px 6px' },
}),
h('input', {
...guard,
type: 'date',
value: typeof dateIso === 'string' ? dateIso : '',
onChange: (e) => props.setData({ ...data, dateIso: e.target.value || null }),
className: 'ce-input ce-num',
style: { width: 'auto', fontSize: '11px', padding: '3px 6px' },
}),
),
label
? h(
'div',
{
className: 'ce-muted',
style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' },
},
label,
)
: null,
)
}
// -------------------------------------------------------------------------
// 8. Palette: { colors: string[] }. Click a swatch to copy its hex.
// -------------------------------------------------------------------------
function Palette(props) {
const data = props.data || {}
const colors = Array.isArray(data.colors)
? data.colors.filter((c) => typeof c === 'string')
: []
const [pick, setPick] = useState('#6366f1')
const [copiedIdx, setCopiedIdx] = useState(-1)
const copyTimer = useRef(null)
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current)
}, [])
const copySwatch = (hex, idx) => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(hex).catch(() => {})
}
} catch (_) {
/* clipboard unavailable - still show the flash */
}
setCopiedIdx(idx)
if (copyTimer.current) clearTimeout(copyTimer.current)
copyTimer.current = setTimeout(() => setCopiedIdx(-1), 900)
}
const swatch = (hex, idx) =>
h(
'div',
{
key: idx + hex,
title: hex,
className: 'ce-swatch',
onClick: () => copySwatch(hex, idx),
style: { background: hex },
},
copiedIdx === idx ? h('span', { className: 'ce-badge' }, 'copied') : null,
copiedIdx !== idx
? h(
'button',
{
type: 'button',
title: 'Remove',
className: 'ce-swatch-x',
onClick: (e) => {
e.stopPropagation()
props.setData({ ...data, colors: colors.filter((_, i) => i !== idx) })
},
},
'×',
)
: null,
)
return h(
'div',
{ className: 'ce-card', style: { gap: '7px' } },
h(
'div',
{ style: { display: 'flex', gap: '6px', alignItems: 'center' } },
h('input', {
...guard,
type: 'color',
value: pick,
onChange: (e) => setPick(e.target.value),
className: 'ce-color',
}),
h(
'button',
{
type: 'button',
className: 'ce-btn ce-btn--primary',
style: { padding: '4px 10px' },
onClick: () => props.setData({ ...data, colors: colors.concat([pick]) }),
},
'Add',
),
h(
'div',
{ className: 'ce-muted ce-num', style: { flex: 1, textAlign: 'right' } },
colors.length + ' color' + (colors.length === 1 ? '' : 's'),
),
),
h(
'div',
{ style: { flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: '8px', alignContent: 'flex-start', padding: '3px' } },
colors.length === 0
? h('div', { className: 'ce-muted' }, 'No swatches yet. Pick a color and Add.')
: colors.map(swatch),
),
)
}
// -------------------------------------------------------------------------
// Registration
// -------------------------------------------------------------------------
ctx.registerCanvasWidgets([
{
widgetKind: 'community.counter.tally',
title: 'Counter',
defaultData: { count: 0 },
defaultSize: { width: 200, height: 120 },
component: Counter,
},
{
widgetKind: 'community.essentials.checklist',
title: 'Checklist',
defaultData: { items: [] },
defaultSize: { width: 240, height: 220 },
component: Checklist,
},
{
widgetKind: 'community.essentials.stopwatch',
title: 'Stopwatch',
defaultData: { running: false, accumulatedMs: 0, startedAtMs: null },
defaultSize: { width: 200, height: 130 },
component: Stopwatch,
},
{
widgetKind: 'community.essentials.timer',
title: 'Timer',
defaultData: { durationMin: 25, endsAtMs: null },
defaultSize: { width: 200, height: 150 },
component: Timer,
},
{
widgetKind: 'community.essentials.clock',
title: 'Clock',
defaultData: { h24: true },
defaultSize: { width: 210, height: 110 },
component: Clock,
},
{
widgetKind: 'community.essentials.progress',
title: 'Progress',
defaultData: { label: 'Goal', current: 0, target: 10 },
defaultSize: { width: 240, height: 140 },
component: Progress,
},
{
widgetKind: 'community.essentials.days-until',
title: 'Days Until',
defaultData: { label: '', dateIso: null },
defaultSize: { width: 210, height: 140 },
component: DaysUntil,
},
{
widgetKind: 'community.essentials.palette',
title: 'Palette',
defaultData: { colors: ['#6366f1', '#22c55e', '#f59e0b'] },
defaultSize: { width: 240, height: 160 },
component: Palette,
},
])
return function deactivate() {
// eslint-disable-next-line no-console
console.info('[canvas-essentials] deactivated')
}
}
How to use this
- Undra (only)
- Extensions are Undra-specific — they use Undra’s sandboxed script-worker runtime. Installs today are file-only; runtime activation is on the roadmap.