Canvas Essentials hero image

Eight polished canvas widgets: counter, checklist, stopwatch, timer, clock, progress tracker, date countdown, and color palette.

  • canvas
  • widgets
  • productivity

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.

← Back to the full catalog