<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PicoDesk — Extensible HTML/JS Dashboard</title>
<style>
/* ------------------------------------------------------
PicoDesk — minimal, fast, no-build dashboard you can
extend by dropping in small JS "plugins" (below).
Single file; works fully offline; no frameworks.
------------------------------------------------------ */:root {
--bg: #0b0e11; /* dark theme default */
--panel: #11151a;
--panel-2: #161b22;
--text: #e6edf3;
--muted: #9fb0c0;
--accent: #7aa2f7;
--ok: #3fb950;
--warn: #d29922;
--danger: #f85149;
--shadow: 0 6px 24px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.25);
--radius: 16px;
--radius-sm: 12px;
}
:root[data-theme="light"] {
--bg: #f6f8fa;
--panel: #ffffff;
--panel-2: #f0f2f4;
--text: #0b0e11;
--muted: #495a6a;
--accent: #2563eb;
--ok: #16a34a;
--warn: #b45309;
--danger: #dc2626;
--shadow: 0 6px 24px rgba(2,8,23,.12), 0 2px 8px rgba(2,8,23,.08);
}
* { box-sizing: border-box }
html, body { height: 100% }
body {
margin: 0;
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Noto Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: var(--bg);
color: var(--text);
}
/* Top bar */
header {
position: sticky; top: 0; z-index: 5;
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
padding: 14px clamp(12px, 3vw, 24px);
background: linear-gradient(0deg, transparent, rgba(0,0,0,.1));
backdrop-filter: blur(6px);
}
.brand {
display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: .2px;
}
.brand svg { width: 22px; height: 22px }
.brand .sub { opacity: .7; font-weight: 500; margin-left: 8px; font-size: 12px }
.search {
display: flex; align-items: center; gap: 8px; max-width: 680px; width: 100%; margin: 0 auto;
background: var(--panel);
border: 1px solid var(--panel-2); border-radius: 999px; box-shadow: var(--shadow);
padding: 8px 12px 8px 12px;
}
.search svg { width: 16px; height: 16px; opacity: .7 }
.search input { border: 0; outline: none; background: transparent; color: var(--text); width: 100% }
.search kbd { background: var(--panel-2); padding: 2px 6px; border-radius: 6px; font-size: 11px; opacity: .8 }
.top-actions { display: flex; gap: 8px }
.btn {
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
background: var(--panel); color: var(--text);
border: 1px solid var(--panel-2); border-radius: 10px; padding: 10px 12px; box-shadow: var(--shadow);
transition: transform .06s ease, background .2s ease, border-color .2s ease;
}
.btn:hover { transform: translateY(-1px) }
.btn svg { width: 16px; height: 16px }
/* Grid */
main { padding: 10px clamp(12px, 3vw, 24px) 40px }
.grid {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-auto-rows: minmax(160px, auto);
}
/* Cards / widgets */
.card {
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: clip;
display: flex; flex-direction: column; min-height: 160px;
}
.card.dragging { opacity: .6 }
.card header { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; background: transparent; box-shadow: none }
.title { display: flex; align-items: center; gap: 8px; font-weight: 700 }
.title svg { width: 18px; height: 18px; opacity: .85 }
.card .tools { display: flex; gap: 6px }
.icon-btn { background: var(--panel-2); border: 1px solid transparent; color: var(--text); border-radius: 8px; padding: 6px; display: inline-flex; align-items: center; cursor: pointer }
.icon-btn:hover { border-color: rgba(255,255,255,.12) }
.icon-btn svg { width: 16px; height: 16px; opacity: .9 }
.content { padding: 12px; flex: 1; min-height: 120px }
.content.scroll { overflow: auto }
.pill { display:inline-flex; align-items:center; gap:6px; border-radius:999px; padding:4px 8px; font-size:12px; background: var(--panel-2); border: 1px solid rgba(255,255,255,.08) }
/* Modal / overlay */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; place-items: center; padding: 20px; z-index: 50 }
.overlay.show { display: grid }
.modal { width: min(900px, 96vw); max-height: 86vh; overflow: auto; background: var(--panel); border:1px solid var(--panel-2); border-radius: 18px; box-shadow: var(--shadow) }
.modal header { padding: 14px 16px; border-bottom: 1px solid var(--panel-2) }
.modal .body { padding: 16px }
.modal .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) }
.plugin-card { padding: 12px; border-radius: 12px; border: 1px solid var(--panel-2); background: var(--panel) }
.plugin-card h4 { margin: 6px 0 }
.plugin-card p { margin: 6px 0 12px; color: var(--muted) }
/* Settings form */
.form { display: grid; gap: 10px }
.form label { display: grid; gap: 6px; font-size: 13px }
.form input[type="text"], .form input[type="number"], .form input[type="time"], .form textarea, .form select {
background: var(--panel-2); color: var(--text); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 8px 10px; outline: none
}
/* Command palette */
.palette { position: fixed; inset: 0; display: none; place-items: start center; padding-top: 12vh; background: rgba(0,0,0,.45); z-index: 60 }
.palette.show { display: grid }
.palette .box { width: min(720px, 96vw); background: var(--panel); border: 1px solid var(--panel-2); border-radius: 16px; box-shadow: var(--shadow); overflow: clip }
.palette input { width: 100%; border: 0; outline: none; background: transparent; color: var(--text); padding: 12px 14px; font-size: 15px }
.palette ul { list-style: none; margin: 0; padding: 0; max-height: 50vh; overflow: auto }
.palette li { padding: 10px 14px; border-top: 1px solid var(--panel-2); display: flex; align-items: center; gap: 10px; cursor: pointer }
.palette li:hover { background: var(--panel-2) }
/* Small helpers */
.row { display: flex; gap: 8px; align-items: center }
.spacer { flex: 1 }
.muted { color: var(--muted) }
.hint { color: var(--muted); font-size: 12px }
.danger { color: var(--danger) }
.ok { color: var(--ok) }
.warn { color: var(--warn) }
.hidden { display: none }
/* Checkboxes */
.check { display: inline-flex; align-items: center; gap: 8px; user-select: none; cursor: pointer }
.check input { width: 16px; height: 16px }
/* Tables */
table { width: 100%; border-collapse: collapse }
th, td { padding: 8px 6px; border-bottom: 1px solid var(--panel-2) }
/* Drag handle cursor */
.drag-handle { cursor: grab }
/* Toasts */
#toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 70 }
.toast { background: var(--panel); border: 1px solid var(--panel-2); color: var(--text); box-shadow: var(--shadow); padding: 10px 12px; border-radius: 10px }
/* Links */
a { color: var(--accent); text-decoration: none }
/* ---------- Visual polish upgrades ---------- */
body {
/* layered glow background for depth */
background:
radial-gradient(1200px 600px at 20% -10%, rgba(122,162,247,.12), transparent 60%),
radial-gradient(1000px 500px at 90% 10%, rgba(63,185,80,.08), transparent 60%),
var(--bg);
}
.card { transition: transform .12s ease, box-shadow .2s ease, border-color .2s ease }
.card:hover { transform: translateY(-2px); box-shadow: 0 16px 40px rgba(0,0,0,.35) }
.btn { position: relative; overflow: hidden }
.btn::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,.12), transparent); transform: translateX(-120%); transition: transform .4s ease }
.btn:hover::after { transform: translateX(120%) }
.pill { border: 1px solid rgba(255,255,255,.12) }
.title span { text-shadow: 0 1px 0 rgba(0,0,0,.25) }
/* Kanban visuals */
.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap: 10px }
.kanban .col { background: var(--panel-2); border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius-sm); padding: 8px; min-height: 180px; display: flex; flex-direction: column; gap: 8px }
.kanban .col.dragover { outline: 2px dashed var(--accent) }
.kanban .task { background: var(--panel); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 8px; cursor: grab; user-select: none; box-shadow: var(--shadow) }
/* Sketch canvas */
.sketch-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 8px }
.sketch-canvas { width: 100%; height: 320px; border-radius: 12px; border: 1px solid var(--panel-2); background: var(--panel-2); box-shadow: inset 0 0 0 1px rgba(255,255,255,.04) }
/* ---------- Mobile & compact polish (v2) ---------- */
.desktop-only { display: inline-flex }
.row-wrap { flex-wrap: wrap }
@media (max-width: 720px) {
header { grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px }
.brand .sub { display: none }
.search.desktop-only { display: none }
.desktop-only { display: none }
main { padding: 8px 12px 28px }
.grid { gap: 8px; grid-template-columns: 1fr; }
.card { min-height: 140px }
.btn { padding: 8px 10px }
.kanban .col { min-height: 160px }
}
:root.compact .grid { gap: 8px }
:root.compact .card { border-radius: 12px }
:root.compact .card header { padding: 8px 10px }
:root.compact .content { padding: 10px }
:root.compact .btn { padding: 8px 10px; border-radius: 8px }
:root.compact .pill { padding: 3px 6px; font-size: 11px }
:root.compact .kanban .task { padding: 6px; font-size: 13px }
/* Tasks mobile layout */
.tasks-add > * { flex: 1 1 120px; min-width: 120px }
@media (max-width: 720px) {
.tasks-add input[type="date"], .tasks-add select { flex: 0 1 140px }
}
</style>
</head>
<body>
<header>
<div class="brand">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 12a8 8 0 1 0 16 0"/><path d="M12 4a8 8 0 0 0 0 16"/></svg>
PicoDesk
<span class="sub">extensible HTML/JS dashboard</span>
</div><div class="search" title="Search data or press Ctrl/Cmd+K for the command palette">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="searchInput" placeholder="Search notes, tasks & widgets…" />
<kbd>Ctrl</kbd><kbd>K</kbd>
</div>
<div class="top-actions">
<button id="addBtn" class="btn" title="Add widget">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 5v14M5 12h14"/></svg>
Add Widget
</button>
<button id="themeBtn" class="btn" title="Toggle theme">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z"/></svg>
Theme
</button>
</div>
</header> <main>
<div id="grid" class="grid"></div>
</main> <!-- Widgets catalog modal --> <div id="catalog" class="overlay" role="dialog" aria-modal="true">
<div class="modal">
<header>
<div class="row">
<strong style="font-size:16px">Add a widget</strong>
<span class="spacer"></span>
<button class="icon-btn" data-close-catalog title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 6l12 12M18 6l-12 12"/></svg>
</button>
</div>
</header>
<div class="body">
<div class="grid" id="catalogGrid"></div>
</div>
</div>
</div> <!-- Settings modal (reused by widgets) --> <div id="settings" class="overlay" role="dialog" aria-modal="true">
<div class="modal" style="width:min(720px,96vw)">
<header>
<div class="row">
<strong style="font-size:16px">Widget settings</strong>
<span id="settingsTitle" class="muted"></span>
<span class="spacer"></span>
<button class="icon-btn" data-close-settings title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 6l12 12M18 6l-12 12"/></svg>
</button>
</div>
</header>
<div class="body">
<form id="settingsForm" class="form"></form>
<div class="row" style="margin-top:8px">
<span class="spacer"></span>
<button id="settingsSave" class="btn">Save</button>
</div>
</div>
</div>
</div> <!-- Command palette --> <div id="palette" class="palette" role="dialog" aria-modal="true">
<div class="box">
<input id="paletteInput" placeholder="Type a command or search…" autofocus />
<ul id="paletteList"></ul>
</div>
</div> <div id="toasts"></div> <script>
// ==========================================================
// PicoDesk Core — state, registry, layout, persistence
// ==========================================================
(function() {
const el = sel => document.querySelector(sel);
const els = sel => Array.from(document.querySelectorAll(sel));
// ---------- Persistence ----------
const STORE_KEY = 'picodesk.v1';
const THEME_KEY = 'picodesk.theme';
function uid() { return Math.random().toString(36).slice(2, 10) }
function save(state) {
localStorage.setItem(STORE_KEY, JSON.stringify(state));
}
function load() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) } catch(e) { return null }
}
// ---------- Theme ----------
const setTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
el('#themeIcon').innerHTML = theme === 'light'
? '<circle cx="12" cy="12" r="5"/><path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>'
: '<path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z"/>';
}
const toggleTheme = () => setTheme((document.documentElement.getAttribute('data-theme') === 'light') ? 'dark' : 'light');
// ---------- Toasts ----------
function toast(msg, ms = 1800) {
const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; el('#toasts').appendChild(t);
setTimeout(() => t.remove(), ms);
}
// ---------- Registry ----------
const Registry = {
plugins: {},
register(p) {
if (!p || !p.id) throw new Error('Plugin missing id');
if (this.plugins[p.id]) throw new Error('Duplicate plugin id: ' + p.id);
this.plugins[p.id] = p;
},
list() { return Object.values(this.plugins) }
};
// ---------- App State ----------
const App = {
state: {
widgets: [] // {id, pluginId, title, settings, internalState}
},
addWidget(pluginId, opts = {}) {
const plugin = Registry.plugins[pluginId];
if (!plugin) return toast('Unknown plugin: ' + pluginId);
const w = {
id: uid(),
pluginId,
title: opts.title || plugin.name,
settings: JSON.parse(JSON.stringify(plugin.defaultSettings || {})),
internalState: plugin.createState ? plugin.createState() : {}
};
this.state.widgets.push(w);
save(this.state); renderGrid(); toast(`Added “${plugin.name}”`);
},
removeWidget(id) {
const i = this.state.widgets.findIndex(w => w.id === id);
if (i >= 0) {
const plugin = Registry.plugins[this.state.widgets[i].pluginId];
this.state.widgets.splice(i, 1);
save(this.state); renderGrid(); toast(`Removed “${plugin?.name || 'widget'}”`);
}
},
moveWidget(fromIdx, toIdx) {
const a = this.state.widgets; const [w] = a.splice(fromIdx, 1); a.splice(toIdx, 0, w);
save(this.state); renderGrid();
}
};
// ---------- Drag & Drop reorder ----------
function enableDrag(card, idx) {
card.setAttribute('draggable', 'true');
const handle = card.querySelector('.drag-handle');
let startIdx = idx;
card.addEventListener('dragstart', (e) => {
if (e.target !== card && !handle.contains(e.target)) { e.preventDefault(); return; }
card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move';
startIdx = els('.card').indexOf(card);
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
card.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' });
card.addEventListener('drop', (e) => {
e.preventDefault();
const cards = els('.card');
const toIdx = cards.indexOf(card);
if (startIdx !== toIdx && startIdx >= 0 && toIdx >= 0) App.moveWidget(startIdx, toIdx);
});
}
// ---------- Rendering ----------
function renderGrid() {
const grid = el('#grid'); grid.innerHTML = '';
App.state.widgets.forEach((w, idx) => {
const plugin = Registry.plugins[w.pluginId];
const card = document.createElement('div'); card.className = 'card'; card.dataset.id = w.id;
card.innerHTML = `
<header>
<div class="title drag-handle" title="Drag to reorder">
${svg(plugin.icon || defaultIcon)}
<span>${w.title}</span>
</div>
<div class="tools">
<button class="icon-btn" data-action="settings" title="Settings">${svg(icons.cog)}</button>
<button class="icon-btn" data-action="remove" title="Remove">${svg(icons.trash)}</button>
</div>
</header>
<div class="content scroll"></div>
`;
enableDrag(card, idx);
grid.appendChild(card);
// Bind actions
card.querySelector('[data-action="remove"]').onclick = () => App.removeWidget(w.id);
card.querySelector('[data-action="settings"]').onclick = () => openSettings(w, plugin);
// Render plugin UI
const container = card.querySelector('.content');
const ctx = makeContext(w, plugin, container);
plugin.render({ el: container, state: w.internalState, setState: ctx.setState, settings: w.settings, setSettings: ctx.setSettings, context: ctx });
});
}
function makeContext(w, plugin, container) {
return {
widgetId: w.id,
pluginId: w.pluginId,
find: (sel) => container.querySelector(sel),
el: container,
setState(patch) {
Object.assign(w.internalState, (typeof patch === 'function') ? patch(w.internalState) : patch);
save(App.state);
},
setSettings(patch) {
Object.assign(w.settings, (typeof patch === 'function') ? patch(w.settings) : patch);
save(App.state);
},
rerender() { renderGrid() },
toast,
actions: [], // plugins can push actions, collected for palette
};
}
// ---------- Settings ----------
function openSettings(widget, plugin) {
const overlay = el('#settings');
const form = el('#settingsForm');
el('#settingsTitle').textContent = '— ' + (widget.title || plugin.name);
form.innerHTML = '';
// Title field
form.appendChild(labelWrap('Widget title', input('text', widget.title, v => widget.title = v)));
// Plugin-defined settings schema (very small DSL)
// Each entry: { key, type: 'text'|'number'|'select'|'checkbox'|'textarea', label, options?, min?, max?, step? }
(plugin.settingsSchema || []).forEach(s => {
const current = widget.settings[s.key];
let control;
if (s.type === 'text') control = input('text', current, v => widget.settings[s.key] = v);
if (s.type === 'number') control = input('number', current, v => widget.settings[s.key] = Number(v), { min: s.min, max: s.max, step: s.step || 1 });
if (s.type === 'textarea') control = textarea(current, v => widget.settings[s.key] = v);
if (s.type === 'select') control = select(s.options || [], current, v => widget.settings[s.key] = v);
if (s.type === 'checkbox') control = checkbox(Boolean(current), v => widget.settings[s.key] = v);
form.appendChild(labelWrap(s.label || s.key, control, s.hint));
});
// Save
el('#settingsSave').onclick = (e) => {
e.preventDefault();
save(App.state);
overlay.classList.remove('show');
renderGrid();
toast('Settings saved');
};
// Open
overlay.classList.add('show');
}
function input(type, value, oninput, attrs={}) {
const i = document.createElement('input'); i.type = type; i.value = value;
Object.entries(attrs).forEach(([k,v]) => v!=null && i.setAttribute(k, v));
i.oninput = () => oninput(i.value); return i;
}
function textarea(value, oninput) { const t = document.createElement('textarea'); t.rows = 6; t.value = value || ''; t.oninput = () => oninput(t.value); return t }
function select(options, value, onchange) {
const s = document.createElement('select');
options.forEach(o => { const opt = document.createElement('option');
const txt = (typeof o === 'string') ? o : o.label; const val = (typeof o === 'string') ? o : o.value; opt.textContent = txt; opt.value = val; if (val === value) opt.selected = true; s.appendChild(opt)
});
s.onchange = () => onchange(s.value); return s;
}
function checkbox(checked, onchange) {
const wrap = document.createElement('label'); wrap.className = 'check';
const c = document.createElement('input'); c.type = 'checkbox'; c.checked = checked; c.onchange = () => onchange(c.checked);
wrap.appendChild(c); wrap.appendChild(document.createTextNode('Enabled')); return wrap;
}
function labelWrap(label, control, hint) {
const l = document.createElement('label'); l.innerHTML = `<span>${label}</span>`; l.appendChild(control); if (hint) { const small = document.createElement('div'); small.className = 'hint'; small.textContent = hint; l.appendChild(small) } return l;
}
// ---------- Catalog ----------
function openCatalog() {
const grid = el('#catalogGrid'); grid.innerHTML = '';
Registry.list().forEach(p => {
const card = document.createElement('div'); card.className = 'plugin-card';
card.innerHTML = `
<div class="row"><span class="pill">${svg(p.icon || defaultIcon)} ${p.name}</span><span class="spacer"></span><span class="muted">${p.size || 'M'}</span></div>
<h4>${p.name}</h4>
<p>${p.description || ''}</p>
<div class="row">
<button class="btn">Add</button>
<span class="spacer"></span>
<span class="hint">id: <code>${p.id}</code></span>
</div>
`;
card.querySelector('.btn').onclick = () => { App.addWidget(p.id); };
grid.appendChild(card);
});
el('#catalog').classList.add('show');
}
// ---------- Search ----------
function searchAll(query) {
query = query.trim().toLowerCase(); if (!query) return [];
const results = [];
App.state.widgets.forEach(w => {
const plugin = Registry.plugins[w.pluginId];
if (plugin.searchIndex) {
const items = plugin.searchIndex({ state: w.internalState, settings: w.settings }) || [];
items.forEach(({ text, href }) => {
if ((text || '').toLowerCase().includes(query)) results.push({ widget: w, plugin, text, href });
});
}
});
return results;
}
// ---------- Command Palette ----------
const Palette = {
open() { el('#palette').classList.add('show'); el('#paletteInput').value = ''; this.refresh(); },
close() { el('#palette').classList.remove('show') },
refresh() {
const q = el('#paletteInput').value.trim().toLowerCase();
const actions = this.collectActions().filter(a => a.label.toLowerCase().includes(q));
const ul = el('#paletteList'); ul.innerHTML = '';
actions.forEach(a => {
const li = document.createElement('li'); li.innerHTML = `${svg(a.icon || icons.bolt)}<div><div>${a.label}</div><div class="hint">${a.hint || ''}</div></div>`;
li.onclick = () => { this.close(); a.run() };
ul.appendChild(li);
});
},
collectActions() {
const actions = [
{ label: 'Add widget…', icon: icons.plus, hint: 'Open catalog', run: openCatalog },
{ label: 'Toggle theme', icon: icons.sun, hint: 'Light/Dark', run: toggleTheme },
{ label: 'Export data (JSON)', icon: icons.download, hint: 'Download your PicoDesk data', run: exportData },
{ label: 'Import data (JSON)…', icon: icons.upload, hint: 'Load data from a file', run: importData },
];
// plugin actions
App.state.widgets.forEach(w => {
const plugin = Registry.plugins[w.pluginId];
(plugin.actions || []).forEach(a => {
actions.push({ label: `${plugin.name}: ${a.label}`, icon: a.icon, hint: a.hint, run: () => a.run({ widget: w, plugin, app: App }) });
});
});
return actions;
}
};
function exportData() {
const blob = new Blob([JSON.stringify(App.state, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'picodesk-data.json'; a.click(); URL.revokeObjectURL(url);
}
function importData() {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json';
input.onchange = () => {
const file = input.files[0]; if (!file) return;
const reader = new FileReader(); reader.onload = () => {
try { const data = JSON.parse(reader.result); if (Array.isArray(data.widgets)) { App.state = data; save(App.state); renderGrid(); toast('Imported data'); } else toast('Invalid file'); }
catch(e) { toast('Invalid JSON') }
}; reader.readAsText(file);
};
input.click();
}
// ---------- Icons ----------
function svg(path) { return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">${path}</svg>` }
const defaultIcon = '<circle cx="12" cy="12" r="8"/>';
const icons = {
cog:'<path d="M9.5 2.8 8.8 5.3a6.8 6.8 0 0 0-1.9 1.1L4.5 6.1l-1.6 2.8 1.7 1.3a6.8 6.8 0 0 0 0 2.4L2.9 14l1.6 2.8 2.4-.3a6.8 6.8 0 0 0 1.9 1.1l.7 2.5h3.2l.7-2.5a6.8 6.8 0 0 0 1.9-1.1l2.4.3 1.6-2.8-1.7-1.3a6.8 6.8 0 0 0 0-2.4l1.7-1.3-1.6-2.8-2.4.3a6.8 6.8 0 0 0-1.9-1.1l-.7-2.5H9.5Zm2.5 6.2a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z"/>',
trash:'<path d="M4 7h16M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v3"/>',
plus:'<path d="M12 5v14M5 12h14"/>',
sun:'<circle cx="12" cy="12" r="5"/><path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>',
bolt:'<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/>',
note:'<path d="M3 6a2 2 0 0 1 2-2h9l5 5v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M14 4v4a2 2 0 0 0 2 2h4"/>',
list:'<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
timer:'<path d="M10 2h4M12 14V8"/><circle cx="12" cy="14" r="8"/>',
habit:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8v8M12 8v8M17 8v8"/>',
link:'<path d="M10 13a5 5 0 0 1 7 0l2 2a5 5 0 0 1-7 7l-2-2M14 11a5 5 0 0 1-7 0l-2-2a5 5 0 0 1 7-7l2 2"/>',
download:'<path d="M12 3v12m0 0 4-4m-4 4-4-4M3 21h18"/>',
upload:'<path d="M12 21V9m0 0-4 4m4-4 4 4M3 3h18"/>',
bookmark:'<path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1z"/>',
};
// ==========================================================
// Built-in Plugins
// Each plugin registers with Registry.register({ id, name, icon,
// description, defaultSettings, settingsSchema, createState, render,
// searchIndex, actions })
// ==========================================================
// --- Notes Plugin ------------------------------------------------
Registry.register({
id: 'notes', name: 'Notes', icon: icons.note, size: 'M',
description: 'A simple, fast note card with autosave & search.',
defaultSettings: { title: 'Notes', monospace: false, placeholder: 'Type notes here…' },
settingsSchema: [
{ key: 'monospace', type: 'checkbox', label: 'Monospace font' },
{ key: 'placeholder', type: 'text', label: 'Placeholder' }
],
createState: () => ({ text: '' }),
render({ el, state, setState, settings }) {
el.innerHTML = '';
const ta = document.createElement('textarea');
ta.value = state.text || '';
ta.placeholder = settings.placeholder || 'Type…';
ta.style.width = '100%'; ta.style.minHeight = '220px'; ta.style.resize = 'vertical';
ta.style.fontFamily = settings.monospace ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' : 'inherit';
ta.oninput = () => setState({ text: ta.value });
el.appendChild(ta);
},
searchIndex({ state }) { return [{ text: state.text }] },
actions: [
{ label: 'Create new note card', icon: icons.plus, run: ({ app }) => app.addWidget('notes') }
]
});
// --- Tasks Plugin ------------------------------------------------
Registry.register({
id: 'tasks', name: 'Tasks', icon: icons.list, size: 'M',
description: 'Small but mighty task list with priorities & due dates.',
defaultSettings: { title: 'Tasks', showCompleted: true },
settingsSchema: [ { key: 'showCompleted', type: 'checkbox', label: 'Show completed tasks' } ],
createState: () => ({ items: [] }),
render({ el, state, setState, settings }) {
el.innerHTML = '';
// Add row — responsive, wraps nicely on mobile
const addRow = document.createElement('div'); addRow.className = 'row row-wrap tasks-add';
const input = document.createElement('input'); input.placeholder = 'Add a task'; input.style.flex = '1';
const due = document.createElement('input'); due.type = 'date'; due.title = 'Due date';
const pri = document.createElement('select'); ['None','Low','Med','High'].forEach(p => { const o = document.createElement('option'); o.textContent = p; pri.appendChild(o) });
const addBtn = document.createElement('button'); addBtn.className = 'btn'; addBtn.textContent = 'Add';
addRow.append(input, due, pri, addBtn); el.appendChild(addRow);
// List — mobile friendly (no wide table)
const list = document.createElement('div'); list.className = 'tasks-list'; el.appendChild(list);
function draw(){
list.innerHTML = '';
const items = state.items.slice().sort((a,b) => Number(b.priority||0)-Number(a.priority||0));
items.forEach((t, idx) => {
if (t.done && !settings.showCompleted) return;
const row = document.createElement('div'); row.className = 'taskrow row';
const chk = document.createElement('input'); chk.type='checkbox'; chk.checked=!!t.done; chk.onchange=()=>{ t.done=!t.done; setState({ items: state.items }); draw(); };
const text = document.createElement('div'); text.className='tasktext'; text.style.flex='1'; text.textContent=t.text; text.title='Tap to edit';
text.onclick = () => { const v = prompt('Edit task', t.text); if (v!=null) { t.text = v; setState({ items: state.items }); draw(); } };
const meta = document.createElement('div'); meta.className='hint'; meta.textContent = `${t.due||''}${t.due && t.priority? ' · ':''}${['','Low','Med','High'][t.priority||0]||''}`;
const del = document.createElement('button'); del.className='icon-btn'; del.title='Delete'; del.textContent='🗑'; del.onclick=()=>{ state.items.splice(state.items.indexOf(t),1); setState({ items: state.items }); draw(); };
row.append(chk, text, meta, del); list.appendChild(row);
});
}
draw();
addBtn.onclick = () => {
const text = input.value.trim(); if (!text) return; const item = { text, done: false, due: due.value || '', priority: pri.selectedIndex };
state.items.push(item); setState({ items: state.items }); input.value=''; due.value=''; pri.selectedIndex=0; draw();
};
},
searchIndex({ state }) { return state.items.map(t => ({ text: t.text })) },
actions: [ { label: 'New task…', icon: icons.plus, run: ({ app }) => app.addWidget('tasks') } ]
});
}
renderRows();
addBtn.onclick = () => {
const text = input.value.trim(); if (!text) return; const item = { text, done: false, due: due.value || '', priority: pri.selectedIndex };
state.items.push(item); setState({ items: state.items }); input.value=''; due.value=''; pri.selectedIndex=0; renderRows();
};
},
searchIndex({ state }) { return state.items.map(t => ({ text: t.text })) },
actions: [
{ label: 'New task…', icon: icons.plus, run: ({ app }) => app.addWidget('tasks') }
]
});
// --- Pomodoro Plugin --------------------------------------------
Registry.register({
id: 'pomodoro', name: 'Pomodoro', icon: icons.timer, size: 'S',
description: 'Focused timer with work/break cycles.',
defaultSettings: { title: 'Focus Timer', workMins: 25, breakMins: 5, longBreakMins: 15, cycles: 4, autoStart: false },
settingsSchema: [
{ key: 'workMins', type: 'number', label: 'Work minutes', min: 5, max: 120 },
{ key: 'breakMins', type: 'number', label: 'Short break minutes', min: 1, max: 60 },
{ key: 'longBreakMins', type: 'number', label: 'Long break minutes', min: 5, max: 60 },
{ key: 'cycles', type: 'number', label: 'Work blocks per set', min: 1, max: 12 },
{ key: 'autoStart', type: 'checkbox', label: 'Auto-start next block' }
],
createState: () => ({ mode: 'work', secondsLeft: 25*60, running: false, cycle: 1, int: null }),
render({ el, state, setState, settings, context }) {
el.innerHTML = '';
const title = document.createElement('div'); title.className = 'row';
title.innerHTML = `<span class="pill">Mode: <strong>${state.mode}</strong></span><span class="spacer"></span><span class="muted">Cycle ${state.cycle}/${settings.cycles}</span>`;
const big = document.createElement('div'); big.style.fontSize='42px'; big.style.fontWeight='800'; big.style.letterSpacing='.5px'; big.style.margin='6px 0 12px';
const controls = document.createElement('div'); controls.className='row';
const start = btn('Start'); const stop = btn('Stop'); const reset = btn('Reset'); controls.append(start, stop, reset);
const next = btn('Next Block'); next.style.marginLeft='auto'; controls.append(next);
el.append(title, big, controls);
function fmt(s){ const m=Math.floor(s/60).toString().padStart(2,'0'); const ss=(s%60).toString().padStart(2,'0'); return `${m}:${ss}` }
function update(){ big.textContent = fmt(state.secondsLeft) }
function setTimer(seconds) { state.secondsLeft = seconds; setState({ secondsLeft: state.secondsLeft }); update() }
function tick(){ if (!state.running) return; if (state.secondsLeft>0) { state.secondsLeft--; update() } else { ring(); nextBlock() } }
function startTick(){ if(state.int) clearInterval(state.int); state.running=true; state.int=setInterval(tick,1000); setState({ running:true, int: state.int }); }
function stopTick(){ if(state.int) clearInterval(state.int); state.running=false; setState({ running:false, int:null }); }
function ring(){ try { new AudioContext().resume(); } catch(e){} toast('⏱️ Time!'); }
function nextBlock(){
if (state.mode === 'work') {
if (state.cycle >= settings.cycles) { state.mode = 'long'; setTimer((settings.longBreakMins||15)*60); state.cycle = 1 }
else { state.mode = 'break'; setTimer((settings.breakMins||5)*60); state.cycle++ }
} else { state.mode = 'work'; setTimer((settings.workMins||25)*60) }
title.innerHTML = `<span class="pill">Mode: <strong>${state.mode}</strong></span><span class="spacer"></span><span class="muted">Cycle ${state.cycle}/${settings.cycles}</span>`;
if (settings.autoStart) startTick(); else stopTick();
}
// Initialize
if (!state._init) { setTimer((settings.workMins||25)*60); setState({ _init: true }) }
update();
start.onclick = startTick; stop.onclick = stopTick; reset.onclick = () => { stopTick(); state.mode='work'; state.cycle=1; setTimer((settings.workMins||25)*60) };
next.onclick = nextBlock;
function btn(label){ const b=document.createElement('button'); b.className='btn'; b.textContent=label; return b }
},
searchIndex(){ return [] },
actions: [
{ label: 'Start timer', icon: icons.timer, run: ({ app }) => toast('Open the Pomodoro widget to start.') }
]
});
// --- Habits Plugin ----------------------------------------------
Registry.register({
id: 'habits', name: 'Habits', icon: icons.habit, size: 'M',
description: 'Tiny weekly habit tracker with streaks.',
defaultSettings: { title: 'Habits', habits: 'Water,Walk,Read', weekStartsOn: 1 },
settingsSchema: [
{ key: 'habits', type: 'text', label: 'Habit names (comma-separated)' },
{ key: 'weekStartsOn', type: 'select', label: 'Week starts on', options: [{label:'Sunday', value:0},{label:'Monday', value:1}] }
],
createState: () => ({ marks: {} }),
render({ el, state, setState, settings }) {
el.innerHTML='';
const habits = settings.habits.split(',').map(s=>s.trim()).filter(Boolean);
const header = document.createElement('div'); header.className='row';
const prev = btn('◀'); const next = btn('▶'); const label = document.createElement('div'); label.className='pill';
header.append(prev, label, next); el.appendChild(header);
const table = document.createElement('table'); el.appendChild(table);
let monday = startOfWeek(new Date(), Number(settings.weekStartsOn)||1);
function render(){
const days = Array.from({length:7}, (_,i)=>addDays(monday, i));
label.textContent = `${fmt(days[0])} – ${fmt(days[6])}`;
table.innerHTML = '';
const thead=document.createElement('thead'); const trh=document.createElement('tr'); trh.innerHTML = `<th>Habit</th>${days.map(d=>`<th>${wday(d)}</th>`).join('')}`; thead.appendChild(trh); table.appendChild(thead);
const tbody=document.createElement('tbody'); table.appendChild(tbody);
habits.forEach(h => {
const tr=document.createElement('tr'); tr.innerHTML = `<td><strong>${escapeHtml(h)}</strong></td>` + days.map(d=>{
const key = keyFor(h,d); const checked = !!state.marks[key]; return `<td><input type="checkbox" ${checked?'checked':''} data-k="${key}"></td>`
}).join('');
tbody.appendChild(tr);
});
tbody.querySelectorAll('input[type="checkbox"]').forEach(c => c.onchange = () => { const k=c.dataset.k; if (c.checked) state.marks[k]=true; else delete state.marks[k]; setState({ marks: state.marks }); });
}
render();
prev.onclick = () => { monday = addDays(monday, -7); render() };
next.onclick = () => { monday = addDays(monday, +7); render() };
function btn(t){ const b=document.createElement('button'); b.className='btn'; b.textContent=t; return b }
function fmt(d){ return d.toLocaleDateString(undefined, { month:'short', day:'numeric' }) }
function wday(d){ return d.toLocaleDateString(undefined, { weekday:'short' }).slice(0,3) }
function keyFor(h,d){ return `${h}::${d.toISOString().slice(0,10)}` }
function startOfWeek(d, first=1){ const dd=new Date(d); const day=(dd.getDay()+7-first)%7; dd.setDate(dd.getDate()-day); dd.setHours(0,0,0,0); return dd }
function addDays(d,n){ const x=new Date(d); x.setDate(x.getDate()+n); return x }
},
searchIndex(){ return [] },
});
// --- Bookmarks Plugin -------------------------------------------
Registry.register({
id: 'bookmarks', name: 'Bookmarks', icon: icons.link, size: 'S',
description: 'Quick links with tags & search.',
defaultSettings: { title: 'Bookmarks' },
settingsSchema: [],
createState: () => ({ items: [] }),
render({ el, state, setState }) {
el.innerHTML='';
const row=document.createElement('div'); row.className='row';
const url=inputEl('text','https://', 'URL'); const label=inputEl('text','', 'Label'); const tag=inputEl('text','', 'tag');
const add=button('Add'); row.append(url,label,tag,add); el.appendChild(row);
const list=document.createElement('div'); list.style.display='grid'; list.style.gap='8px'; list.style.marginTop='8px'; el.appendChild(list);
function draw(){ list.innerHTML=''; state.items.forEach((it, idx)=>{
const a=document.createElement('a'); a.href=it.url; a.target='_blank'; a.rel='noopener'; a.className='pill'; a.innerHTML=`${svg(icons.link)} ${escapeHtml(it.label||it.url)} <span class="muted">${it.tag?('#'+escapeHtml(it.tag)):''}</span>`;
const wrap=document.createElement('div'); wrap.className='row'; wrap.append(a, spacer(), tinyBtn('✎', ()=>edit(idx)), tinyBtn('🗑', ()=>del(idx)));
list.appendChild(wrap);
})}
draw();
add.onclick = () => { const u=url.value.trim(); if (!/^https?:\/\//.test(u)) return toast('Enter a valid http(s) URL'); state.items.push({ url:u, label: label.value.trim(), tag: tag.value.trim() }); setState({ items: state.items }); url.value='https://'; label.value=''; tag.value=''; draw(); };
function edit(i){ const it=state.items[i]; const l=prompt('Label', it.label||''); if (l==null) return; const t=prompt('Tag', it.tag||''); if (t==null) return; it.label=l; it.tag=t; setState({ items: state.items }); draw(); }
function del(i){ state.items.splice(i,1); setState({ items: state.items }); draw(); }
function inputEl(type, value, placeholder){ const i=document.createElement('input'); i.type=type; i.value=value; i.placeholder=placeholder||''; return i }
function button(t){ const b=document.createElement('button'); b.className='btn'; b.textContent=t; return b }
function tinyBtn(t, fn){ const b=document.createElement('button'); b.className='icon-btn'; b.textContent=t; b.onclick=fn; return b }
function spacer(){ const s=document.createElement('span'); s.className='spacer'; return s }
},
searchIndex({ state }) { return state.items.map(it => ({ text: `${it.label} ${it.tag} ${it.url}`, href: it.url })) },
actions: [ { label:'Open bookmark by search…', icon: icons.link, run: () => toast('Use the top search to find bookmarks.')} ]
});
// --- World Clock Plugin -------------------------------------------
Registry.register({
id: 'worldclock', name: 'World Clock', icon: icons.sun, size: 'S',
description: 'Multiple timezones at a glance with live updates.',
defaultSettings: { timezones: 'America/Chicago, Europe/London, Asia/Tokyo', hour12: false },
settingsSchema: [
{ key: 'timezones', type: 'text', label: 'IANA time zones (comma-separated)', hint: 'e.g., America/Chicago, Europe/London' },
{ key: 'hour12', type: 'checkbox', label: '12-hour clock' }
],
createState: () => ({ _int: null }),
render({ el, state, setState, settings }) {
el.innerHTML = '';
const tzs = settings.timezones.split(',').map(s=>s.trim()).filter(Boolean);
if (state._int) { clearInterval(state._int); state._int = null }
const table = document.createElement('table');
table.innerHTML = '<thead><tr><th>City</th><th>Time</th></tr></thead><tbody></tbody>';
const tbody = table.querySelector('tbody');
tzs.forEach(tz => {
const tr = document.createElement('tr');
const city = tz.split('/').pop().replace(/_/g,' ');
tr.innerHTML = `<td><strong>${escapeHtml(city)}</strong><div class="hint">${escapeHtml(tz)}</div></td><td data-tz="${tz}"></td>`;
tbody.appendChild(tr);
});
el.appendChild(table);
function tick(){
const now = new Date();
el.querySelectorAll('[data-tz]').forEach(td => {
try {
const tz = td.getAttribute('data-tz');
const fmt = new Intl.DateTimeFormat([], { hour:'2-digit', minute:'2-digit', second:'2-digit', hour12: !!settings.hour12, timeZone: tz });
td.textContent = fmt.format(now);
} catch(e) { td.textContent = '—' }
});
}
tick();
state._int = setInterval(tick, 1000);
setState({ _int: state._int });
},
searchIndex(){ return [] }
});
// --- Countdowns Plugin ------------------------------------------
Registry.register({
id: 'countdown', name: 'Countdowns', icon: icons.timer, size: 'M',
description: 'Track time left to important dates.',
defaultSettings: { title: 'Countdowns' },
settingsSchema: [],
createState: () => ({ events: [], _int: null }),
render({ el, state, setState }) {
el.innerHTML='';
if (state._int) { clearInterval(state._int); state._int = null }
const row = document.createElement('div'); row.className = 'row';
const name = document.createElement('input'); name.placeholder='Event name'; name.style.flex='1';
const when = document.createElement('input'); when.type='datetime-local';
const add = document.createElement('button'); add.className='btn'; add.textContent='Add';
row.append(name, when, add); el.appendChild(row);
const table=document.createElement('table'); table.innerHTML = '<thead><tr><th>Event</th><th>Target</th><th>Left</th><th></th></tr></thead><tbody></tbody>';
const tbody=table.querySelector('tbody'); el.appendChild(table);
function renderRows(){
tbody.innerHTML='';
state.events.forEach((ev, idx)=>{
const tr=document.createElement('tr');
tr.innerHTML = `<td><strong>${escapeHtml(ev.name)}</strong></td><td>${escapeHtml(ev.when || '')}</td><td data-left></td><td class="row" style="justify-content:end"><button class="icon-btn">🗑</button></td>`;
tr.querySelector('.icon-btn').onclick = () => { state.events.splice(idx,1); setState({ events: state.events }); renderRows(); };
tbody.appendChild(tr);
});
tick();
}
add.onclick = () => {
const n=name.value.trim(); const w=when.value;
if (!n || !w) return toast('Enter a name and date');
state.events.push({ name:n, when:w }); setState({ events: state.events }); name.value=''; when.value=''; renderRows();
};
function fmt(ms){ if(ms<=0) return 'Done'; const s=Math.floor(ms/1000); const d=Math.floor(s/86400); const h=Math.floor((s%86400)/3600); const m=Math.floor((s%3600)/60); const ss=s%60; return `${d}d ${h}h ${m}m ${ss}s` }
function tick(){
const now = Date.now();
tbody.querySelectorAll('tr').forEach((tr, i)=>{
const ev = state.events[i]; const t = new Date(ev.when).getTime();
tr.querySelector('[data-left]').textContent = fmt(t - now);
});
}
renderRows();
state._int = setInterval(tick, 1000); setState({ _int: state._int });
},
searchIndex({ state }) { return state.events.map(e => ({ text: e.name })) }
});
// --- Kanban Plugin ----------------------------------------------
Registry.register({
id: 'kanban', name: 'Kanban', icon: icons.list, size: 'L',
description: 'Drag tasks across columns. Touch-friendly with fallback buttons.',
defaultSettings: { columns: 'Backlog, Doing, Review, Done' },
settingsSchema: [ { key:'columns', type:'text', label:'Columns (comma-separated)' } ],
createState: () => ({ items: [] }),
render({ el, state, setState, settings }) {
el.innerHTML='';
const cols = settings.columns.split(',').map(s=>s.trim()).filter(Boolean);
const row = document.createElement('div'); row.className='row row-wrap';
const input = document.createElement('input'); input.placeholder='New task'; input.style.flex='1';
const add = document.createElement('button'); add.className='btn'; add.textContent='Add';
row.append(input, add); el.appendChild(row);
const board = document.createElement('div'); board.className='kanban'; board.style.position='relative'; el.appendChild(board);
function renderBoard(){
board.innerHTML='';
cols.forEach(c => {
const col = document.createElement('div'); col.className='col'; col.dataset.col = c;
const h = document.createElement('div'); h.className='row'; h.innerHTML = `<strong>${escapeHtml(c)}</strong><span class="spacer"></span><span class="muted">${state.items.filter(i=>i.col===c).length}</span>`;
const list = document.createElement('div'); list.className='list';
col.append(h, list); board.appendChild(col);
});
state.items.forEach(it => makeCard(it));
}
function makeCard(it){
const host = board.querySelector(`[data-col="${CSS.escape(it.col)}"] .list`);
if (!host) return;
const card = document.createElement('div'); card.className='task'; card.innerHTML = `${escapeHtml(it.text)}`;
// mouse DnD (desktop)
card.setAttribute('draggable','true');
card.addEventListener('dragstart', (e)=>{ e.dataTransfer.setData('text/plain', it.id) });
card.addEventListener('dragend', ()=> board.querySelectorAll('.col').forEach(c=>c.classList.remove('dragover')));
// touch/pointer DnD (mobile)
card.addEventListener('pointerdown', (e)=>{
if (e.pointerType === 'mouse') return; // let HTML5 DnD handle desktop
e.preventDefault();
let overCol = null;
const move = (ev)=>{
const elAt = document.elementFromPoint(ev.clientX, ev.clientY);
const col = elAt && elAt.closest('.kanban .col');
board.querySelectorAll('.col').forEach(c=>c.classList.remove('dragover'));
if (col){ col.classList.add('dragover'); overCol = col; }
};
const up = ()=>{
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
board.querySelectorAll('.col').forEach(c=>c.classList.remove('dragover'));
if (overCol){ it.col = overCol.dataset.col; setState({ items: state.items }); renderBoard(); }
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
});
// fallback arrows (visible on tiny screens via title)
card.title = 'Tip: drag on touch; or long-press then drag.
Use ←/→ buttons if drag is tricky.';
const controls = document.createElement('div'); controls.className='row'; controls.style.justifyContent='end'; controls.style.gap='6px';
const left = document.createElement('button'); left.className='icon-btn'; left.textContent='←';
const right = document.createElement('button'); right.className='icon-btn'; right.textContent='→';
left.onclick = ()=> { const i = cols.indexOf(it.col); if (i>0){ it.col = cols[i-1]; setState({ items: state.items }); renderBoard(); } };
right.onclick = ()=> { const i = cols.indexOf(it.col); if (i<cols.length-1){ it.col = cols[i+1]; setState({ items: state.items }); renderBoard(); } };
const wrap = document.createElement('div'); wrap.style.display='grid'; wrap.style.gridTemplateColumns='1fr auto'; wrap.style.alignItems='center'; wrap.style.gap='6px';
const txt = document.createElement('div'); txt.textContent = it.text; wrap.append(txt, controls);
controls.append(left, right);
host.appendChild(card);
card.appendChild(wrap);
}
board.addEventListener('dragover', (e)=>{ e.preventDefault(); const col = e.target.closest('.col'); if (col) col.classList.add('dragover') });
board.addEventListener('dragleave', (e)=>{ const col = e.target.closest('.col'); if (col) col.classList.remove('dragover') });
board.addEventListener('drop', (e)=>{ e.preventDefault(); const col = e.target.closest('.col'); const id = e.dataTransfer.getData('text/plain'); const item = state.items.find(x=>x.id===id); if (item && col){ item.col = col.dataset.col; setState({ items: state.items }); renderBoard(); }});
add.onclick = () => { const t=input.value.trim(); if(!t) return; state.items.push({ id: uid(), text: t, col: cols[0] || 'Backlog' }); setState({ items: state.items }); input.value=''; renderBoard(); };
renderBoard();
},
searchIndex({ state }) { return state.items.map(i => ({ text: i.text })) },
actions: [ { label:'Add Kanban board', icon: icons.plus, run: ({ app }) => app.addWidget('kanban') } ]
});
});
state.items.forEach(it => {
const host = board.querySelector(`[data-col="${CSS.escape(it.col)}"] .list`);
if (!host) return;
const card = document.createElement('div'); card.className='task'; card.setAttribute('draggable','true'); card.innerHTML = `${escapeHtml(it.text)}`;
card.addEventListener('dragstart', (e)=>{ e.dataTransfer.setData('text/plain', it.id) });
host.appendChild(card);
});
}
add.onclick = () => { const t=input.value.trim(); if(!t) return; state.items.push({ id: uid(), text: t, col: cols[0] || 'Backlog' }); setState({ items: state.items }); input.value=''; renderBoard(); };
renderBoard();
},
searchIndex({ state }) { return state.items.map(i => ({ text: i.text })) },
actions: [ { label:'Add Kanban board', icon: icons.plus, run: ({ app }) => app.addWidget('kanban') } ]
});
// --- Sketch Pad Plugin ------------------------------------------
Registry.register({
id: 'sketch', name: 'Sketch Pad', icon: icons.note, size: 'M',
description: 'Quick freehand notes — draw, erase, save snapshot.',
defaultSettings: { title: 'Sketch', lineWidth: 3 },
settingsSchema: [ { key:'lineWidth', type:'number', label:'Line width', min:1, max:40 } ],
createState: () => ({ dataUrl: null }),
render({ el, state, setState, settings }) {
el.innerHTML='';
const tools = document.createElement('div'); tools.className='sketch-toolbar';
const color = document.createElement('input'); color.type='color';
const size = document.createElement('input'); size.type='range'; size.min=1; size.max=40; size.value=String(settings.lineWidth||3);
const erase = document.createElement('button'); erase.className='btn'; erase.textContent='Eraser'; let erasing=false;
const clear = document.createElement('button'); clear.className='btn'; clear.textContent='Clear';
const save = document.createElement('button'); save.className='btn'; save.textContent='Save';
tools.append(color, size, erase, clear, save); el.appendChild(tools);
const canvas = document.createElement('canvas'); canvas.className='sketch-canvas'; el.appendChild(canvas);
const ctx = canvas.getContext('2d');
function resize(){
const ratio = window.devicePixelRatio || 1; const w = el.clientWidth - 24; const h = 320;
canvas.width = Math.max(240, w) * ratio; canvas.height = h * ratio; canvas.style.width = Math.max(240, w) + 'px'; canvas.style.height = h + 'px';
ctx.scale(ratio, ratio);
ctx.lineCap='round'; ctx.lineJoin='round';
if (state.dataUrl) { const img=new Image(); img.onload=()=>{ ctx.drawImage(img,0,0,canvas.width/ratio,canvas.height/ratio) }; img.src=state.dataUrl }
}
resize();
window.addEventListener('resize', resize, { once: true });
let drawing=false, last=null;
function pos(e){ const r=canvas.getBoundingClientRect(); const x=(e.clientX|| (e.touches&&e.touches[0].clientX))-r.left; const y=(e.clientY|| (e.touches&&e.touches[0].clientY))-r.top; return {x, y} }
function down(e){ drawing=true; last=pos(e); e.preventDefault() }
function move(e){ if(!drawing) return; const p=pos(e); ctx.strokeStyle = erasing ? 'rgba(0,0,0,1)' : color.value; ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'; ctx.lineWidth = Number(size.value)||3; ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); last=p; }
function up(){ drawing=false }
canvas.addEventListener('mousedown', down); canvas.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
canvas.addEventListener('touchstart', down, {passive:false}); canvas.addEventListener('touchmove', move, {passive:false}); canvas.addEventListener('touchend', up);
erase.onclick = () => { erasing=!erasing; erase.classList.toggle('warn', erasing); erase.textContent = erasing ? 'Eraser (on)' : 'Eraser' };
clear.onclick = () => { ctx.clearRect(0,0,canvas.width,canvas.height) };
save.onclick = () => { state.dataUrl = canvas.toDataURL('image/png'); setState({ dataUrl: state.dataUrl }); toast('Sketch saved in widget data') };
},
searchIndex(){ return [] }
});
// ==========================================================
// App Boot
// ==========================================================
function init() {
// Theme
setTheme(localStorage.getItem(THEME_KEY) || 'dark');
// Load state or create a nice starter layout
const saved = load();
if (saved && Array.isArray(saved.widgets)) {
App.state = saved;
} else {
App.addWidget('notes', { title: 'Scratchpad' });
App.addWidget('tasks');
App.addWidget('pomodoro');
App.addWidget('habits');
App.addWidget('bookmarks');
App.addWidget('worldclock');
App.addWidget('countdown');
App.addWidget('kanban');
}
renderGrid();
// Search field (content search)
el('#searchInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const q = e.target.value; const results = searchAll(q);
if (!results.length) return toast('No results');
// Open palette with matches as actions
const palActions = results.slice(0, 20).map(r => ({ label: `${r.plugin.name}: ${r.text.slice(0,100)}`, hint: 'Open widget', run: () => toast('Open the widget to view/edit this item.') }));
Palette.open();
// Inject
const ul = el('#paletteList'); ul.innerHTML = '';
palActions.forEach(a => { const li = document.createElement('li'); li.innerHTML = `${svg(icons.bolt)}<div><div>${a.label}</div><div class="hint">${a.hint}</div></div>`; li.onclick = () => { Palette.close(); a.run() }; ul.appendChild(li); });
}
});
// Top actions
el('#addBtn').onclick = openCatalog;
el('#themeBtn').onclick = toggleTheme;
// Close modals
els('[data-close-catalog]').forEach(b => b.onclick = () => el('#catalog').classList.remove('show'));
els('[data-close-settings]').forEach(b => b.onclick = () => el('#settings').classList.remove('show'));
// Palette shortcuts
document.addEventListener('keydown', (e) => {
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
if ((isMac && e.metaKey && e.key.toLowerCase()==='k') || (!isMac && e.ctrlKey && e.key.toLowerCase()==='k')) { e.preventDefault(); Palette.open(); }
if (e.key === 'Escape') { el('#palette').classList.remove('show'); el('#catalog').classList.remove('show'); el('#settings').classList.remove('show'); }
});
el('#paletteInput').oninput = () => Palette.refresh();
}
// Escape HTML helper
function escapeHtml(s){ return (s||'').replace(/[&<>"]|\"/g, c=>({"&":"&","<":"<",">":">","\"":"""}[c])) }
// Init now (after definitions)
init();
// Expose a tiny developer API for quick experimentation in console
window.PicoDesk = { Registry, App };
})();
</script> <!--
=============================================================
Developer notes & extension mini-guide
-------------------------------------------------------------
• Register a new widget by calling Registry.register({...}).
• See examples above (Notes, Tasks, Pomodoro, Habits, Bookmarks).
• Minimal interface:
Registry.register({
id: 'my-widget',
name: 'My Widget',
icon: '<circle cx="12" cy="12" r="6"/>',
description: 'What it does',
defaultSettings: { title: 'My Widget' },
settingsSchema: [ { key:'title', type:'text', label:'Title' } ],
createState: () => ({ count: 0 }),
render({ el, state, setState, settings, setSettings, context }) {
el.innerHTML = '';
const b = document.createElement('button'); b.className='btn'; b.textContent='Count +1'; b.onclick=()=>{ setState({ count: state.count+1 }); b.nextSibling.textContent = 'Count: '+state.count };
const p = document.createElement('span'); p.style.marginLeft='8px'; p.textContent='Count: '+state.count;
el.append(b, p);
},
searchIndex({ state }) { return [{ text: 'Count is '+state.count }] },
actions: [ { label:'Add My Widget', run: ({ app }) => app.addWidget('my-widget') } ]
})
• Data is persisted to localStorage. Use Export/Import from the Command Palette to backup or sync.
• The command palette (Ctrl/Cmd+K) aggregates built-in actions + plugin actions.
• The top search indexes plugin text via plugin.searchIndex().
• This app is framework-free, dependency-free, single file. Drop into any static host.
=============================================================
--></body>
</html>