slotlock 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/mcp-app.d.ts CHANGED
@@ -9,8 +9,14 @@ export declare function clientSupportsSlotlockMcpApp(capabilities: unknown): boo
9
9
  * `ui/notifications/tool-result` channel; the template contains no tenant or event data and uses
10
10
  * textContent for every untrusted value. Keeping this as reviewed source instead of generated UI
11
11
  * also lets MCP hosts inspect and cache the exact resource before rendering it.
12
+ *
13
+ * A certain slot (from `slotlock_find_available_slots` or `slotlock_find_next_available`) can be
14
+ * picked: the view sends the host one `ui/message` naming it, in a fixed sentence holding only the
15
+ * resource id and the two instants it checked, so the agent books it through the normal path,
16
+ * where Slotlock asks a person to confirm the write if it is set to. The view itself never calls a
17
+ * tool, and every write tool is visible to the model only.
12
18
  */
13
- export declare const SLOTLOCK_MCP_APP_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Slotlock calendar</title>\n <style>\n :root{color-scheme:light dark;font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);background:var(--color-background-primary,#f5f5f0);color:var(--color-text-primary,#171915)}\n *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent}button,input{font:inherit}\n .shell{display:grid;gap:14px;padding:16px;max-width:1000px;margin:0 auto}\n .mast{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:2px 2px 10px;border-bottom:1px solid var(--color-border-primary,#d7d9d2)}\n .eyebrow{margin:0 0 5px;font-size:11px;line-height:1.2;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-secondary,#667064)}\n h1{margin:0;font-size:clamp(22px,4vw,32px);line-height:1;letter-spacing:-.045em}p{margin:0}.status{display:inline-flex;align-items:center;gap:7px;min-height:30px;padding:6px 10px;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:999px;font-size:12px;color:var(--color-text-secondary,#667064);background:var(--color-background-secondary,#fff)}\n .dot{width:7px;height:7px;border-radius:50%;background:#b7791f}.status[data-ready=\"true\"] .dot{background:#23855b}\n .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{min-width:min(100%,280px);min-height:42px;padding:9px 12px;border:1px solid var(--color-border-primary,#c9cdc4);border-radius:9px;background:var(--color-background-secondary,#fff);color:inherit}.search:focus-visible{outline:3px solid color-mix(in srgb,var(--color-ring-primary,#2e5bff) 42%,transparent);outline-offset:2px}\n .summary{color:var(--color-text-secondary,#667064);font-size:13px}.panel{min-width:0;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:12px;background:var(--color-background-secondary,#fff);overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 14px;border-bottom:1px solid var(--color-border-primary,#e1e3dd)}h2{margin:0;font-size:14px;letter-spacing:-.01em}.items{display:grid;min-width:0}.item{display:grid;grid-template-columns:minmax(130px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:13px 14px;border-bottom:1px solid var(--color-border-primary,#eceee8)}.item:last-child{border-bottom:0}.item-title{font-weight:650;overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--color-text-secondary,#596157);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:4px 7px;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:6px;font-size:11px;color:var(--color-text-secondary,#596157);white-space:nowrap}.empty{padding:30px 16px;text-align:center;color:var(--color-text-secondary,#667064);font-size:14px}.error{color:var(--color-text-danger,#9b2c2c)}\n details{border-top:1px solid var(--color-border-primary,#e1e3dd)}summary{cursor:pointer;padding:11px 14px;color:var(--color-text-secondary,#667064);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--color-background-tertiary,#f6f7f3)}\n @media(max-width:600px){.shell{padding:12px}.mast{align-items:stretch;flex-direction:column}.status{align-self:flex-start}.item{grid-template-columns:1fr}.badge{justify-self:start}.search{width:100%;font-size:16px}}\n @media(prefers-reduced-motion:no-preference){.dot{transition:background-color .18s ease}.item{animation:reveal .16s ease-out both}@keyframes reveal{from{opacity:.5;transform:translateY(3px)}to{opacity:1;transform:none}}}\n </style>\n</head>\n<body>\n <main class=\"shell\">\n <header class=\"mast\">\n <div><p class=\"eyebrow\">Resource-first scheduling</p><h1>Slotlock calendar</h1></div>\n <div class=\"status\" id=\"status\" data-ready=\"false\" role=\"status\"><span class=\"dot\" aria-hidden=\"true\"></span><span id=\"statusText\">Connecting to host</span></div>\n </header>\n <div class=\"toolbar\">\n <label><span class=\"eyebrow\">Filter this result</span><input id=\"filter\" class=\"search\" type=\"search\" autocomplete=\"off\" placeholder=\"Resource, event or time\"></label>\n <p class=\"summary\" id=\"summary\" aria-live=\"polite\">Waiting for a calendar result.</p>\n </div>\n <section class=\"panel\" aria-labelledby=\"resultHeading\">\n <div class=\"panel-head\"><h2 id=\"resultHeading\">Calendar result</h2></div>\n <div class=\"items\" id=\"items\"><p class=\"empty\">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>\n <details><summary>Structured result</summary><pre id=\"raw\">No result yet.</pre></details>\n </section>\n </main>\n <script>\n (() => {\n 'use strict';\n const state = { data: null, query: '', requestId: 1 };\n const status = document.getElementById('status');\n const statusText = document.getElementById('statusText');\n const items = document.getElementById('items');\n const summary = document.getElementById('summary');\n const raw = document.getElementById('raw');\n const filter = document.getElementById('filter');\n const safeText = (value) => value === null || value === undefined ? '\u2014' : String(value);\n const post = (message) => window.parent.postMessage(message, '*');\n const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };\n const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };\n const line = (title, detail, badge) => {\n const row = document.createElement('article'); row.className = 'item';\n const heading = document.createElement('div'); heading.className = 'item-title'; heading.textContent = safeText(title);\n const body = document.createElement('div'); body.className = 'item-detail'; body.textContent = safeText(detail);\n const mark = document.createElement('div'); mark.className = 'badge'; mark.textContent = safeText(badge);\n row.append(heading, body, mark); return row;\n };\n const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' \u2192 ');\n // A refused call carries no structuredContent (hosts validate it against the tool's\n // outputSchema); its text content is {\"error\":{\"code\":\u2026}}.\n const refusalOf = (content) => {\n const text = Array.isArray(content) ? content.find((part) => part && part.type === 'text')?.text : undefined;\n try {\n const parsed = JSON.parse(text);\n if (parsed && parsed.error && typeof parsed.error === 'object') return { error: parsed.error };\n } catch {}\n return { error: { code: 'request_refused' } };\n };\n const flatten = (data) => {\n if (!data || typeof data !== 'object') return [];\n if (data.error) return [{ title: data.error.code || 'Unable to load', detail: 'The calendar server refused this request.', badge: 'Error' }];\n if (Array.isArray(data.resources)) return data.resources.flatMap((resource) => {\n if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));\n return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];\n });\n if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));\n if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];\n if ('start' in data || 'end' in data) {\n const coverage = data.coverage && typeof data.coverage === 'object' ? data.coverage : null;\n const certainty = coverage?.certainty;\n const completeInterval = typeof data.start === 'string' && typeof data.end === 'string';\n if (certainty !== 'certain' || !completeInterval) {\n const reason = typeof coverage?.reason === 'string' ? coverage.reason.replaceAll('_', ' ') : 'Required calendar coverage is incomplete.';\n return [{ title: 'Availability unproven', detail: reason, badge: certainty || 'uncertain' }];\n }\n return [{ title: data.resource_id || 'Next available', detail: intervalText(data), badge: certainty }];\n }\n return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));\n };\n const render = () => {\n clear(items);\n const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));\n if (rows.length === 0) { const empty = document.createElement('p'); empty.className = 'empty'; empty.textContent = state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'; items.append(empty); }\n else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));\n summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';\n raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';\n };\n filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });\n window.addEventListener('message', (event) => {\n if (event.source !== window.parent) return;\n const message = event.data;\n if (!message || message.jsonrpc !== '2.0') return;\n if (message.id === state.requestId && message.result) {\n post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });\n setStatus('Connected', true);\n return;\n }\n if (message.method === 'ui/notifications/tool-result') {\n const result = message.params || {};\n state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);\n setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);\n render();\n }\n if (message.method === 'ui/notifications/host-context-changed' && message.params?.theme) document.documentElement.dataset.theme = message.params.theme;\n });\n post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.0.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });\n })();\n </script>\n</body>\n</html>";
19
+ export declare const SLOTLOCK_MCP_APP_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Slotlock calendar</title>\n <style>\n :root{color-scheme:light dark;--fb-bg:#fbfbf9;--fb-surface:#ffffff;--fb-sunken:#f3f4f0;--fb-text:#171915;--fb-muted:#5d655b;--fb-faint:#8a9187;--fb-line:#e3e5df;--fb-line-strong:#c9cdc4;--fb-inverse:#171915;--fb-on-inverse:#ffffff;--fb-ring:#2e5bff;--fb-ok:#23855b;--fb-warn:#b7791f;--fb-danger:#9b2c2c}\n @media (prefers-color-scheme:dark){:root:not([data-theme=\"light\"]){--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}}\n :root[data-theme=\"dark\"]{--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}\n :root{--bg:var(--color-background-primary,var(--fb-bg));--surface:var(--color-background-secondary,var(--fb-surface));--sunken:var(--color-background-tertiary,var(--fb-sunken));--text:var(--color-text-primary,var(--fb-text));--muted:var(--color-text-secondary,var(--fb-muted));--faint:var(--color-text-tertiary,var(--fb-faint));--line:var(--color-border-primary,var(--fb-line));--line-strong:var(--color-border-secondary,var(--fb-line-strong));--inverse:var(--color-background-inverse,var(--fb-inverse));--on-inverse:var(--color-text-inverse,var(--fb-on-inverse));--ring:var(--color-ring-primary,var(--fb-ring));--ok:var(--color-text-success,var(--fb-ok));--danger:var(--color-text-danger,var(--fb-danger));font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif);color:var(--text);background:var(--bg)}\n *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent;-webkit-font-smoothing:antialiased}button,input,textarea{font:inherit;color:inherit}p{margin:0}\n button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--ring);outline-offset:2px}\n .shell{display:grid;gap:12px;padding:16px;max-width:760px;margin:0 auto}\n .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n .status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}.dot{width:7px;height:7px;border-radius:50%;background:var(--fb-warn)}.status[data-ready=\"true\"] .dot{background:var(--ok)}\n /* The picker */\n .picker{display:grid;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}.picker[hidden]{display:none}\n .head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:16px 16px 12px}\n .head h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em}\n .meta{margin-top:4px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 10px}.meta span+span::before{content:\"\u00B7\";margin-right:10px;color:var(--faint)}\n .clock{display:inline-flex;padding:2px;border-radius:8px;background:var(--sunken);flex:none}.clock button{min-width:40px;padding:4px 8px;border:0;border-radius:6px;background:transparent;font-size:12px;color:var(--muted);cursor:pointer}.clock button[aria-pressed=\"true\"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.08)}\n .days{display:flex;gap:6px;padding:0 16px 12px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}.days::-webkit-scrollbar{display:none}.days[hidden]{display:none}\n .day{flex:none;display:grid;justify-items:center;gap:1px;width:56px;padding:8px 0 7px;border:1px solid var(--line);border-radius:12px;background:transparent;cursor:pointer;scroll-snap-align:start;font-variant-numeric:tabular-nums}\n .day small{font-size:11px;color:var(--muted)}.day strong{font-size:17px;font-weight:var(--font-weight-semibold,600);line-height:1.2}.day i{font-style:normal;font-size:10px;color:var(--faint)}\n .day:hover{border-color:var(--line-strong)}.day[aria-pressed=\"true\"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.day[aria-pressed=\"true\"] small,.day[aria-pressed=\"true\"] i{color:inherit;opacity:.75}\n .times{display:grid;gap:14px;padding:14px 16px 16px;border-top:1px solid var(--line)}.times[hidden],.choice[hidden]{display:none}.foot:has(.choice[hidden]){justify-content:flex-end}\n .part h2{margin:0 0 8px;font-size:11px;font-weight:var(--font-weight-medium,500);letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}\n .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}\n .time{display:grid;justify-items:center;gap:1px;min-height:42px;padding:8px 6px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);cursor:pointer;font-variant-numeric:tabular-nums;font-weight:var(--font-weight-medium,500);transition:border-color .12s,background-color .12s}\n .time small{font-size:10px;font-weight:400;color:var(--muted)}.time:hover:not(:disabled){border-color:var(--text)}\n .time[aria-pressed=\"true\"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.time[aria-pressed=\"true\"] small{color:inherit;opacity:.75}.time:disabled{cursor:default}.time:disabled:not([aria-pressed=\"true\"]){opacity:.45}\n .one{display:grid;gap:4px;padding:4px 16px 16px}.one strong{font-size:var(--font-heading-md-size,22px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em;font-variant-numeric:tabular-nums}.one span{font-size:14px;color:var(--muted)}.one[hidden]{display:none}\n .foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border-top:1px solid var(--line);background:var(--sunken)}\n .choice{min-width:0;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.choice b{display:block;color:var(--text);font-weight:var(--font-weight-semibold,600)}\n .book{flex:none;min-height:40px;padding:9px 16px;border:0;border-radius:10px;background:var(--inverse);color:var(--on-inverse);font-weight:var(--font-weight-semibold,600);cursor:pointer}.book:disabled{cursor:default;opacity:.4}.book[data-sent=\"true\"]{opacity:1;background:transparent;color:var(--ok);padding-inline:4px}\n .fallback{display:grid;gap:6px;padding:12px 16px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}.fallback[hidden]{display:none}.fallback textarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line-strong);border-radius:8px;background:var(--bg);font:12px/1.5 var(--font-mono,ui-monospace,monospace);resize:vertical}\n /* Every other result */\n .list[hidden]{display:none}.list{display:grid;gap:12px}\n .mast{display:flex;align-items:center;justify-content:space-between;gap:12px}.mast h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600)}\n .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{flex:1;min-width:min(100%,220px);min-height:38px;padding:8px 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}.summary{font-size:13px;color:var(--muted)}\n .panel{min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden}.items{display:grid;min-width:0}\n .item{display:grid;grid-template-columns:minmax(120px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)}.item:last-child{border-bottom:0}.item-title{font-weight:var(--font-weight-semibold,600);overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--muted);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:3px 7px;border:1px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);white-space:nowrap}.empty{padding:28px 16px;text-align:center;color:var(--muted);font-size:14px}\n details{border-top:1px solid var(--line)}summary{cursor:pointer;padding:10px 14px;color:var(--muted);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--sunken)}\n @media(max-width:520px){.shell{padding:10px}.item{grid-template-columns:1fr}.badge{justify-self:start}.grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr))}.foot{flex-wrap:wrap}.book{flex:1}}\n @media(prefers-reduced-motion:reduce){.time{transition:none}}\n </style>\n</head>\n<body>\n <main class=\"shell\">\n <section class=\"picker\" id=\"picker\" aria-labelledby=\"pickTitle\" hidden>\n <div class=\"head\">\n <div><h1 id=\"pickTitle\">Pick a time</h1><p class=\"meta\" id=\"pickMeta\"></p></div>\n <div class=\"clock\" role=\"group\" aria-label=\"Clock\"><button type=\"button\" id=\"h12\" aria-pressed=\"false\">12h</button><button type=\"button\" id=\"h24\" aria-pressed=\"false\">24h</button></div>\n </div>\n <div class=\"days\" id=\"days\" role=\"group\" aria-label=\"Day\"></div>\n <div class=\"one\" id=\"one\" hidden></div>\n <div class=\"times\" id=\"times\"></div>\n <div class=\"foot\">\n <p class=\"choice\" id=\"choice\" aria-live=\"polite\">No time picked</p>\n <button type=\"button\" class=\"book\" id=\"book\" disabled>Book this time</button>\n </div>\n <div class=\"fallback\" id=\"fallback\" hidden><p>Your host did not pass the choice on. Send this to your agent:</p><textarea id=\"fallbackText\" readonly></textarea></div>\n </section>\n <section class=\"list\" id=\"list\">\n <header class=\"mast\"><h1>Slotlock calendar</h1><p class=\"status\" id=\"status\" data-ready=\"false\" role=\"status\"><span class=\"dot\" aria-hidden=\"true\"></span><span id=\"statusText\">Connecting to host</span></p></header>\n <div class=\"toolbar\">\n <label class=\"sr\" for=\"filter\">Filter this result</label><input id=\"filter\" class=\"search\" type=\"search\" autocomplete=\"off\" placeholder=\"Filter by resource, event or time\">\n <p class=\"summary\" id=\"summary\" aria-live=\"polite\">Waiting for a calendar result.</p>\n </div>\n <div class=\"panel\">\n <div class=\"items\" id=\"items\"><p class=\"empty\">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>\n <details><summary>Structured result</summary><pre id=\"raw\">No result yet.</pre></details>\n </div>\n </section>\n </main>\n <script>\n (() => {\n 'use strict';\n const state = { data: null, query: '', requestId: 1, nextId: 2, timeZone: undefined, locale: undefined, hour12: undefined, day: null, chosen: null, sent: false, pending: {}, size: '' };\n const $ = (id) => document.getElementById(id);\n const status = $('status'), statusText = $('statusText'), items = $('items'), summary = $('summary'), raw = $('raw'), filter = $('filter');\n const picker = $('picker'), list = $('list'), days = $('days'), times = $('times'), one = $('one'), choice = $('choice'), book = $('book'), pickMeta = $('pickMeta'), fallback = $('fallback'), fallbackText = $('fallbackText'), h12 = $('h12'), h24 = $('h24');\n const safeText = (value) => value === null || value === undefined ? '\u2014' : String(value);\n const post = (message) => window.parent.postMessage(message, '*');\n const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };\n const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };\n const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; };\n\n // The host's look: its standard theme variables (only these names, as plain values) and its theme.\n const THEME_KEYS = /^--(color-(background|text|border|ring)-[a-z]+|font-(sans|mono)|font-weight-[a-z]+|font-(text|heading)-[a-z]+-size)$/;\n const applyHost = (context) => {\n if (!context || typeof context !== 'object') return;\n if (context.theme === 'light' || context.theme === 'dark') document.documentElement.dataset.theme = context.theme;\n const variables = context.styles && context.styles.variables;\n if (variables && typeof variables === 'object') {\n Object.entries(variables).forEach(([key, value]) => {\n if (THEME_KEYS.test(key) && typeof value === 'string' && value.length <= 200 && !/url\\(|expression|[;{}<>]/i.test(value)) document.documentElement.style.setProperty(key, value);\n });\n }\n if (typeof context.timeZone === 'string') state.timeZone = context.timeZone;\n if (typeof context.locale === 'string') state.locale = context.locale;\n };\n\n // Only what the view has checked goes into the message a person sends: an id of plain\n // characters and two instants it parsed itself, never a title or any other server text.\n const SAFE_ID = /^[A-Za-z0-9._:-]{1,200}$/;\n const instant = (value) => { const time = typeof value === 'string' ? Date.parse(value) : NaN; return Number.isFinite(time) ? new Date(time) : null; };\n const HOLD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n const pickable = (slot) => {\n if (!slot || typeof slot.resource_id !== 'string' || !SAFE_ID.test(slot.resource_id)) return null;\n const start = instant(slot.start); const end = instant(slot.end);\n if (!start || !end || end <= start) return null;\n if (slot.hold_id === undefined) return { resource_id: slot.resource_id, start, end };\n // A held time (slotlock_hold_slots): booking it names the hold, so it needs a plain id.\n const expires = instant(slot.expires_at);\n return typeof slot.hold_id === 'string' && HOLD_ID.test(slot.hold_id) && expires\n ? { resource_id: slot.resource_id, start, end, hold_id: slot.hold_id, expires }\n : null;\n };\n const slotsOf = (data) => {\n if (!data || typeof data !== 'object' || data.error) return [];\n // Held time is free for this agent alone until it expires: certain by being held.\n if (Array.isArray(data.holds)) return data.holds.map(pickable).filter((slot) => slot && slot.hold_id);\n if (data.coverage?.certainty !== 'certain') return [];\n const listed = Array.isArray(data.slots) ? data.slots : 'start' in data ? [data] : [];\n return listed.map(pickable).filter(Boolean);\n };\n\n const fmt = (date, options) => {\n const zone = state.timeZone ? { timeZone: state.timeZone } : {};\n try { return new Intl.DateTimeFormat(state.locale, { ...options, ...zone }).format(date); }\n catch { return new Intl.DateTimeFormat(undefined, options).format(date); }\n };\n const prefers12 = () => { try { const cycle = new Intl.DateTimeFormat(state.locale, { hour: 'numeric' }).resolvedOptions().hourCycle; return cycle === 'h11' || cycle === 'h12'; } catch { return false; } };\n const hour12 = () => state.hour12 === undefined ? prefers12() : state.hour12;\n const dayKey = (date) => fmt(date, { year: 'numeric', month: '2-digit', day: '2-digit' });\n const dayLabel = (date) => fmt(date, { weekday: 'short', day: 'numeric', month: 'short' });\n const timeLabel = (date) => fmt(date, { hour: hour12() ? 'numeric' : '2-digit', minute: '2-digit', hour12: hour12() });\n // The hour as a number in any locale: German formats it as \"10 Uhr\", so read the hour part.\n const hourOf = (date) => {\n const zone = state.timeZone ? { timeZone: state.timeZone } : {};\n let parts;\n try { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', ...zone }).formatToParts(date); }\n catch { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23' }).formatToParts(date); }\n return Number(parts.find((part) => part.type === 'hour')?.value) % 24;\n };\n const minutes = (slot) => Math.round((slot.end - slot.start) / 60000);\n const lengthLabel = (total) => total < 60 ? total + ' min' : Math.floor(total / 60) + ' h' + (total % 60 ? ' ' + (total % 60) + ' min' : '');\n const sentence = (slot) => 'Book the slot I picked: ' + (slot.hold_id ? 'hold ' + slot.hold_id + ', ' : '') + 'resource ' + slot.resource_id + ', from ' + slot.start.toISOString() + ' to ' + slot.end.toISOString() + ' (' + dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (state.timeZone ? ' ' + state.timeZone : '') + ').';\n\n const renderPicker = (slots) => {\n const resources = [...new Set(slots.map((slot) => slot.resource_id))];\n const lengths = new Set(slots.map(minutes));\n clear(pickMeta);\n if (lengths.size === 1) pickMeta.append(el('span', '', lengthLabel(minutes(slots[0]))));\n if (resources.length > 1) pickMeta.append(el('span', '', resources.length + ' resources'));\n pickMeta.append(el('span', '', state.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local time'));\n const held = slots.filter((slot) => slot.expires).map((slot) => slot.expires).sort((a, b) => a - b)[0];\n if (held) pickMeta.append(el('span', 'held', 'Held until ' + timeLabel(held)));\n h12.setAttribute('aria-pressed', String(hour12())); h24.setAttribute('aria-pressed', String(!hour12()));\n const byDay = new Map();\n slots.forEach((slot) => { const key = dayKey(slot.start); if (!byDay.has(key)) byDay.set(key, []); byDay.get(key).push(slot); });\n if (!byDay.has(state.day)) state.day = byDay.keys().next().value;\n const single = slots.length === 1;\n $('pickTitle').textContent = single ? 'Next available' : 'Pick a time';\n clear(days); clear(times); clear(one);\n days.hidden = single || byDay.size < 2;\n one.hidden = !single;\n times.hidden = single;\n if (single) {\n if (!state.chosen) state.chosen = slots[0];\n one.append(el('strong', '', timeLabel(slots[0].start) + '\u2013' + timeLabel(slots[0].end)), el('span', '', fmt(slots[0].start, { weekday: 'long', day: 'numeric', month: 'long' })));\n } else {\n byDay.forEach((daySlots, key) => {\n const first = daySlots[0].start;\n const button = el('button', 'day');\n button.type = 'button';\n button.setAttribute('aria-pressed', String(key === state.day));\n button.setAttribute('aria-label', dayLabel(first) + ', ' + daySlots.length + (daySlots.length === 1 ? ' time' : ' times'));\n button.append(el('small', '', fmt(first, { weekday: 'short' })), el('strong', '', fmt(first, { day: 'numeric' })), el('i', '', fmt(first, { month: 'short' })));\n button.addEventListener('click', () => { state.day = key; render(); });\n days.append(button);\n });\n const parts = [['Morning', (h) => h < 12], ['Afternoon', (h) => h >= 12 && h < 17], ['Evening', (h) => h >= 17]];\n const daySlots = byDay.get(state.day) || [];\n parts.forEach(([name, test]) => {\n const inPart = daySlots.filter((slot) => test(hourOf(slot.start)));\n if (inPart.length === 0) return;\n const part = el('section', 'part'); const grid = el('div', 'grid');\n part.append(el('h2', '', name), grid);\n inPart.forEach((slot) => {\n const button = el('button', 'time', timeLabel(slot.start));\n button.type = 'button';\n const isChosen = state.chosen && state.chosen.resource_id === slot.resource_id && +state.chosen.start === +slot.start;\n button.setAttribute('aria-pressed', String(Boolean(isChosen)));\n button.setAttribute('aria-label', dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (resources.length > 1 ? ', resource ' + (resources.indexOf(slot.resource_id) + 1) : ''));\n if (resources.length > 1) button.append(el('small', '', 'Resource ' + (resources.indexOf(slot.resource_id) + 1)));\n else if (lengths.size > 1) button.append(el('small', '', lengthLabel(minutes(slot))));\n button.disabled = state.sent;\n button.addEventListener('click', () => { state.chosen = slot; fallback.hidden = true; render(); });\n grid.append(button);\n });\n times.append(part);\n });\n }\n clear(choice);\n choice.hidden = single;\n if (state.chosen) {\n choice.append(el('b', '', dayLabel(state.chosen.start) + ', ' + timeLabel(state.chosen.start) + '\u2013' + timeLabel(state.chosen.end)));\n if (resources.length > 1) choice.append(document.createTextNode('Resource ' + (resources.indexOf(state.chosen.resource_id) + 1)));\n } else choice.textContent = 'No time picked';\n book.disabled = !state.chosen || state.sent;\n book.dataset.sent = String(state.sent);\n book.textContent = state.sent ? '\u2713 Sent to your agent' : 'Book this time';\n };\n book.addEventListener('click', () => {\n if (!state.chosen || state.sent) return;\n state.sent = true;\n fallback.hidden = true;\n const text = sentence(state.chosen);\n const id = state.nextId++;\n state.pending[id] = text;\n setStatus('Sent to your agent', true);\n post({ jsonrpc: '2.0', id, method: 'ui/message', params: { role: 'user', content: { type: 'text', text } } });\n render();\n });\n h12.addEventListener('click', () => { state.hour12 = true; render(); });\n h24.addEventListener('click', () => { state.hour12 = false; render(); });\n\n const reportSize = () => {\n const root = document.documentElement;\n const size = { width: Math.ceil(root.scrollWidth), height: Math.ceil(root.scrollHeight) };\n const key = size.width + 'x' + size.height;\n if (key === state.size) return;\n state.size = key;\n post({ jsonrpc: '2.0', method: 'ui/notifications/size-changed', params: size });\n };\n const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' \u2192 ');\n // A refused call carries no structuredContent (hosts validate it against the tool's\n // outputSchema); its text content is {\"error\":{\"code\":\u2026}}.\n const refusalOf = (content) => {\n const text = Array.isArray(content) ? content.find((part) => part && part.type === 'text')?.text : undefined;\n try {\n const parsed = JSON.parse(text);\n if (parsed && parsed.error && typeof parsed.error === 'object') return { error: parsed.error };\n } catch {}\n return { error: { code: 'request_refused' } };\n };\n const flatten = (data) => {\n if (!data || typeof data !== 'object') return [];\n if (data.error) return [{ title: data.error.code || 'Unable to load', detail: 'The calendar server refused this request.', badge: 'Error' }];\n if (Array.isArray(data.resources)) return data.resources.flatMap((resource) => {\n if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));\n return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];\n });\n if (Array.isArray(data.slots)) {\n if (data.coverage?.certainty !== 'certain') return [{ title: 'Availability unproven', detail: 'Required calendar coverage is incomplete.', badge: data.coverage?.certainty || 'uncertain' }];\n return data.slots.length === 0 ? [{ title: 'No free time', detail: 'Nothing of that length is free in the window.', badge: 'certain' }] : data.slots.map((slot) => ({ title: slot.resource_id, detail: intervalText(slot), badge: 'free' }));\n }\n if (Array.isArray(data.holds)) return [{ title: 'Nothing held', detail: 'Every time asked for was taken.', badge: 'refused' }];\n if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));\n if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];\n if ('start' in data || 'end' in data) {\n const coverage = data.coverage && typeof data.coverage === 'object' ? data.coverage : null;\n const certainty = coverage?.certainty;\n const completeInterval = typeof data.start === 'string' && typeof data.end === 'string';\n if (certainty !== 'certain' || !completeInterval) {\n const reason = typeof coverage?.reason === 'string' ? coverage.reason.replaceAll('_', ' ') : 'Required calendar coverage is incomplete.';\n return [{ title: 'Availability unproven', detail: reason, badge: certainty || 'uncertain' }];\n }\n return [{ title: data.resource_id || 'Next available', detail: intervalText(data), badge: certainty }];\n }\n return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));\n };\n const line = (title, detail, badge) => {\n const row = el('article', 'item');\n row.append(el('div', 'item-title', safeText(title)), el('div', 'item-detail', safeText(detail)), el('div', 'badge', safeText(badge)));\n return row;\n };\n const render = () => {\n const slots = slotsOf(state.data);\n picker.hidden = slots.length === 0;\n list.hidden = slots.length > 0;\n if (slots.length > 0) { renderPicker(slots); reportSize(); return; }\n clear(items);\n const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));\n if (rows.length === 0) items.append(el('p', 'empty', state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'));\n else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));\n summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';\n raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';\n reportSize();\n };\n filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });\n window.addEventListener('message', (event) => {\n if (event.source !== window.parent) return;\n const message = event.data;\n if (!message || message.jsonrpc !== '2.0') return;\n if (message.id === state.requestId && message.result) {\n applyHost(message.result.hostContext);\n post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });\n setStatus('Connected', true);\n render();\n return;\n }\n if (message.id in state.pending) {\n const text = state.pending[message.id];\n delete state.pending[message.id];\n if (message.error) {\n // Refused or unsupported: the person can still send it, and pick again.\n state.sent = false;\n fallbackText.value = text;\n fallback.hidden = false;\n setStatus('Not sent', false);\n render();\n }\n return;\n }\n if (message.method === 'ui/notifications/tool-result') {\n const result = message.params || {};\n state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);\n state.chosen = null; state.sent = false; state.day = null;\n fallback.hidden = true;\n setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);\n render();\n }\n if (message.method === 'ui/notifications/host-context-changed') { applyHost(message.params); render(); }\n });\n if (typeof ResizeObserver === 'function') new ResizeObserver(reportSize).observe(document.documentElement);\n post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.1.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });\n })();\n </script>\n</body>\n</html>";
14
20
  export declare const SLOTLOCK_MCP_APP_RESOURCE: Readonly<{
15
21
  uri: "ui://slotlock/calendar";
16
22
  name: "Slotlock calendar";
package/mcp-app.js CHANGED
@@ -20,6 +20,12 @@ export function clientSupportsSlotlockMcpApp(capabilities) {
20
20
  * `ui/notifications/tool-result` channel; the template contains no tenant or event data and uses
21
21
  * textContent for every untrusted value. Keeping this as reviewed source instead of generated UI
22
22
  * also lets MCP hosts inspect and cache the exact resource before rendering it.
23
+ *
24
+ * A certain slot (from `slotlock_find_available_slots` or `slotlock_find_next_available`) can be
25
+ * picked: the view sends the host one `ui/message` naming it, in a fixed sentence holding only the
26
+ * resource id and the two instants it checked, so the agent books it through the normal path,
27
+ * where Slotlock asks a person to confirm the write if it is set to. The view itself never calls a
28
+ * tool, and every write tool is visible to the model only.
23
29
  */
24
30
  export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
25
31
  <html lang="en">
@@ -28,56 +34,236 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
28
34
  <meta name="viewport" content="width=device-width,initial-scale=1">
29
35
  <title>Slotlock calendar</title>
30
36
  <style>
31
- :root{color-scheme:light dark;font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);background:var(--color-background-primary,#f5f5f0);color:var(--color-text-primary,#171915)}
32
- *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent}button,input{font:inherit}
33
- .shell{display:grid;gap:14px;padding:16px;max-width:1000px;margin:0 auto}
34
- .mast{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:2px 2px 10px;border-bottom:1px solid var(--color-border-primary,#d7d9d2)}
35
- .eyebrow{margin:0 0 5px;font-size:11px;line-height:1.2;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-secondary,#667064)}
36
- h1{margin:0;font-size:clamp(22px,4vw,32px);line-height:1;letter-spacing:-.045em}p{margin:0}.status{display:inline-flex;align-items:center;gap:7px;min-height:30px;padding:6px 10px;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:999px;font-size:12px;color:var(--color-text-secondary,#667064);background:var(--color-background-secondary,#fff)}
37
- .dot{width:7px;height:7px;border-radius:50%;background:#b7791f}.status[data-ready="true"] .dot{background:#23855b}
38
- .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{min-width:min(100%,280px);min-height:42px;padding:9px 12px;border:1px solid var(--color-border-primary,#c9cdc4);border-radius:9px;background:var(--color-background-secondary,#fff);color:inherit}.search:focus-visible{outline:3px solid color-mix(in srgb,var(--color-ring-primary,#2e5bff) 42%,transparent);outline-offset:2px}
39
- .summary{color:var(--color-text-secondary,#667064);font-size:13px}.panel{min-width:0;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:12px;background:var(--color-background-secondary,#fff);overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 14px;border-bottom:1px solid var(--color-border-primary,#e1e3dd)}h2{margin:0;font-size:14px;letter-spacing:-.01em}.items{display:grid;min-width:0}.item{display:grid;grid-template-columns:minmax(130px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:13px 14px;border-bottom:1px solid var(--color-border-primary,#eceee8)}.item:last-child{border-bottom:0}.item-title{font-weight:650;overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--color-text-secondary,#596157);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:4px 7px;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:6px;font-size:11px;color:var(--color-text-secondary,#596157);white-space:nowrap}.empty{padding:30px 16px;text-align:center;color:var(--color-text-secondary,#667064);font-size:14px}.error{color:var(--color-text-danger,#9b2c2c)}
40
- details{border-top:1px solid var(--color-border-primary,#e1e3dd)}summary{cursor:pointer;padding:11px 14px;color:var(--color-text-secondary,#667064);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--color-background-tertiary,#f6f7f3)}
41
- @media(max-width:600px){.shell{padding:12px}.mast{align-items:stretch;flex-direction:column}.status{align-self:flex-start}.item{grid-template-columns:1fr}.badge{justify-self:start}.search{width:100%;font-size:16px}}
42
- @media(prefers-reduced-motion:no-preference){.dot{transition:background-color .18s ease}.item{animation:reveal .16s ease-out both}@keyframes reveal{from{opacity:.5;transform:translateY(3px)}to{opacity:1;transform:none}}}
37
+ :root{color-scheme:light dark;--fb-bg:#fbfbf9;--fb-surface:#ffffff;--fb-sunken:#f3f4f0;--fb-text:#171915;--fb-muted:#5d655b;--fb-faint:#8a9187;--fb-line:#e3e5df;--fb-line-strong:#c9cdc4;--fb-inverse:#171915;--fb-on-inverse:#ffffff;--fb-ring:#2e5bff;--fb-ok:#23855b;--fb-warn:#b7791f;--fb-danger:#9b2c2c}
38
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}}
39
+ :root[data-theme="dark"]{--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}
40
+ :root{--bg:var(--color-background-primary,var(--fb-bg));--surface:var(--color-background-secondary,var(--fb-surface));--sunken:var(--color-background-tertiary,var(--fb-sunken));--text:var(--color-text-primary,var(--fb-text));--muted:var(--color-text-secondary,var(--fb-muted));--faint:var(--color-text-tertiary,var(--fb-faint));--line:var(--color-border-primary,var(--fb-line));--line-strong:var(--color-border-secondary,var(--fb-line-strong));--inverse:var(--color-background-inverse,var(--fb-inverse));--on-inverse:var(--color-text-inverse,var(--fb-on-inverse));--ring:var(--color-ring-primary,var(--fb-ring));--ok:var(--color-text-success,var(--fb-ok));--danger:var(--color-text-danger,var(--fb-danger));font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif);color:var(--text);background:var(--bg)}
41
+ *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent;-webkit-font-smoothing:antialiased}button,input,textarea{font:inherit;color:inherit}p{margin:0}
42
+ button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
43
+ .shell{display:grid;gap:12px;padding:16px;max-width:760px;margin:0 auto}
44
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
45
+ .status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}.dot{width:7px;height:7px;border-radius:50%;background:var(--fb-warn)}.status[data-ready="true"] .dot{background:var(--ok)}
46
+ /* The picker */
47
+ .picker{display:grid;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}.picker[hidden]{display:none}
48
+ .head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:16px 16px 12px}
49
+ .head h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em}
50
+ .meta{margin-top:4px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 10px}.meta span+span::before{content:"·";margin-right:10px;color:var(--faint)}
51
+ .clock{display:inline-flex;padding:2px;border-radius:8px;background:var(--sunken);flex:none}.clock button{min-width:40px;padding:4px 8px;border:0;border-radius:6px;background:transparent;font-size:12px;color:var(--muted);cursor:pointer}.clock button[aria-pressed="true"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.08)}
52
+ .days{display:flex;gap:6px;padding:0 16px 12px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}.days::-webkit-scrollbar{display:none}.days[hidden]{display:none}
53
+ .day{flex:none;display:grid;justify-items:center;gap:1px;width:56px;padding:8px 0 7px;border:1px solid var(--line);border-radius:12px;background:transparent;cursor:pointer;scroll-snap-align:start;font-variant-numeric:tabular-nums}
54
+ .day small{font-size:11px;color:var(--muted)}.day strong{font-size:17px;font-weight:var(--font-weight-semibold,600);line-height:1.2}.day i{font-style:normal;font-size:10px;color:var(--faint)}
55
+ .day:hover{border-color:var(--line-strong)}.day[aria-pressed="true"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.day[aria-pressed="true"] small,.day[aria-pressed="true"] i{color:inherit;opacity:.75}
56
+ .times{display:grid;gap:14px;padding:14px 16px 16px;border-top:1px solid var(--line)}.times[hidden],.choice[hidden]{display:none}.foot:has(.choice[hidden]){justify-content:flex-end}
57
+ .part h2{margin:0 0 8px;font-size:11px;font-weight:var(--font-weight-medium,500);letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
58
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}
59
+ .time{display:grid;justify-items:center;gap:1px;min-height:42px;padding:8px 6px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);cursor:pointer;font-variant-numeric:tabular-nums;font-weight:var(--font-weight-medium,500);transition:border-color .12s,background-color .12s}
60
+ .time small{font-size:10px;font-weight:400;color:var(--muted)}.time:hover:not(:disabled){border-color:var(--text)}
61
+ .time[aria-pressed="true"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.time[aria-pressed="true"] small{color:inherit;opacity:.75}.time:disabled{cursor:default}.time:disabled:not([aria-pressed="true"]){opacity:.45}
62
+ .one{display:grid;gap:4px;padding:4px 16px 16px}.one strong{font-size:var(--font-heading-md-size,22px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em;font-variant-numeric:tabular-nums}.one span{font-size:14px;color:var(--muted)}.one[hidden]{display:none}
63
+ .foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border-top:1px solid var(--line);background:var(--sunken)}
64
+ .choice{min-width:0;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.choice b{display:block;color:var(--text);font-weight:var(--font-weight-semibold,600)}
65
+ .book{flex:none;min-height:40px;padding:9px 16px;border:0;border-radius:10px;background:var(--inverse);color:var(--on-inverse);font-weight:var(--font-weight-semibold,600);cursor:pointer}.book:disabled{cursor:default;opacity:.4}.book[data-sent="true"]{opacity:1;background:transparent;color:var(--ok);padding-inline:4px}
66
+ .fallback{display:grid;gap:6px;padding:12px 16px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}.fallback[hidden]{display:none}.fallback textarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line-strong);border-radius:8px;background:var(--bg);font:12px/1.5 var(--font-mono,ui-monospace,monospace);resize:vertical}
67
+ /* Every other result */
68
+ .list[hidden]{display:none}.list{display:grid;gap:12px}
69
+ .mast{display:flex;align-items:center;justify-content:space-between;gap:12px}.mast h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600)}
70
+ .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{flex:1;min-width:min(100%,220px);min-height:38px;padding:8px 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}.summary{font-size:13px;color:var(--muted)}
71
+ .panel{min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden}.items{display:grid;min-width:0}
72
+ .item{display:grid;grid-template-columns:minmax(120px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)}.item:last-child{border-bottom:0}.item-title{font-weight:var(--font-weight-semibold,600);overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--muted);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:3px 7px;border:1px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);white-space:nowrap}.empty{padding:28px 16px;text-align:center;color:var(--muted);font-size:14px}
73
+ details{border-top:1px solid var(--line)}summary{cursor:pointer;padding:10px 14px;color:var(--muted);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--sunken)}
74
+ @media(max-width:520px){.shell{padding:10px}.item{grid-template-columns:1fr}.badge{justify-self:start}.grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr))}.foot{flex-wrap:wrap}.book{flex:1}}
75
+ @media(prefers-reduced-motion:reduce){.time{transition:none}}
43
76
  </style>
44
77
  </head>
45
78
  <body>
46
79
  <main class="shell">
47
- <header class="mast">
48
- <div><p class="eyebrow">Resource-first scheduling</p><h1>Slotlock calendar</h1></div>
49
- <div class="status" id="status" data-ready="false" role="status"><span class="dot" aria-hidden="true"></span><span id="statusText">Connecting to host</span></div>
50
- </header>
51
- <div class="toolbar">
52
- <label><span class="eyebrow">Filter this result</span><input id="filter" class="search" type="search" autocomplete="off" placeholder="Resource, event or time"></label>
53
- <p class="summary" id="summary" aria-live="polite">Waiting for a calendar result.</p>
54
- </div>
55
- <section class="panel" aria-labelledby="resultHeading">
56
- <div class="panel-head"><h2 id="resultHeading">Calendar result</h2></div>
57
- <div class="items" id="items"><p class="empty">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>
58
- <details><summary>Structured result</summary><pre id="raw">No result yet.</pre></details>
80
+ <section class="picker" id="picker" aria-labelledby="pickTitle" hidden>
81
+ <div class="head">
82
+ <div><h1 id="pickTitle">Pick a time</h1><p class="meta" id="pickMeta"></p></div>
83
+ <div class="clock" role="group" aria-label="Clock"><button type="button" id="h12" aria-pressed="false">12h</button><button type="button" id="h24" aria-pressed="false">24h</button></div>
84
+ </div>
85
+ <div class="days" id="days" role="group" aria-label="Day"></div>
86
+ <div class="one" id="one" hidden></div>
87
+ <div class="times" id="times"></div>
88
+ <div class="foot">
89
+ <p class="choice" id="choice" aria-live="polite">No time picked</p>
90
+ <button type="button" class="book" id="book" disabled>Book this time</button>
91
+ </div>
92
+ <div class="fallback" id="fallback" hidden><p>Your host did not pass the choice on. Send this to your agent:</p><textarea id="fallbackText" readonly></textarea></div>
93
+ </section>
94
+ <section class="list" id="list">
95
+ <header class="mast"><h1>Slotlock calendar</h1><p class="status" id="status" data-ready="false" role="status"><span class="dot" aria-hidden="true"></span><span id="statusText">Connecting to host</span></p></header>
96
+ <div class="toolbar">
97
+ <label class="sr" for="filter">Filter this result</label><input id="filter" class="search" type="search" autocomplete="off" placeholder="Filter by resource, event or time">
98
+ <p class="summary" id="summary" aria-live="polite">Waiting for a calendar result.</p>
99
+ </div>
100
+ <div class="panel">
101
+ <div class="items" id="items"><p class="empty">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>
102
+ <details><summary>Structured result</summary><pre id="raw">No result yet.</pre></details>
103
+ </div>
59
104
  </section>
60
105
  </main>
61
106
  <script>
62
107
  (() => {
63
108
  'use strict';
64
- const state = { data: null, query: '', requestId: 1 };
65
- const status = document.getElementById('status');
66
- const statusText = document.getElementById('statusText');
67
- const items = document.getElementById('items');
68
- const summary = document.getElementById('summary');
69
- const raw = document.getElementById('raw');
70
- const filter = document.getElementById('filter');
109
+ const state = { data: null, query: '', requestId: 1, nextId: 2, timeZone: undefined, locale: undefined, hour12: undefined, day: null, chosen: null, sent: false, pending: {}, size: '' };
110
+ const $ = (id) => document.getElementById(id);
111
+ const status = $('status'), statusText = $('statusText'), items = $('items'), summary = $('summary'), raw = $('raw'), filter = $('filter');
112
+ const picker = $('picker'), list = $('list'), days = $('days'), times = $('times'), one = $('one'), choice = $('choice'), book = $('book'), pickMeta = $('pickMeta'), fallback = $('fallback'), fallbackText = $('fallbackText'), h12 = $('h12'), h24 = $('h24');
71
113
  const safeText = (value) => value === null || value === undefined ? '—' : String(value);
72
114
  const post = (message) => window.parent.postMessage(message, '*');
73
115
  const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };
74
116
  const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };
75
- const line = (title, detail, badge) => {
76
- const row = document.createElement('article'); row.className = 'item';
77
- const heading = document.createElement('div'); heading.className = 'item-title'; heading.textContent = safeText(title);
78
- const body = document.createElement('div'); body.className = 'item-detail'; body.textContent = safeText(detail);
79
- const mark = document.createElement('div'); mark.className = 'badge'; mark.textContent = safeText(badge);
80
- row.append(heading, body, mark); return row;
117
+ const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; };
118
+
119
+ // The host's look: its standard theme variables (only these names, as plain values) and its theme.
120
+ const THEME_KEYS = /^--(color-(background|text|border|ring)-[a-z]+|font-(sans|mono)|font-weight-[a-z]+|font-(text|heading)-[a-z]+-size)$/;
121
+ const applyHost = (context) => {
122
+ if (!context || typeof context !== 'object') return;
123
+ if (context.theme === 'light' || context.theme === 'dark') document.documentElement.dataset.theme = context.theme;
124
+ const variables = context.styles && context.styles.variables;
125
+ if (variables && typeof variables === 'object') {
126
+ Object.entries(variables).forEach(([key, value]) => {
127
+ if (THEME_KEYS.test(key) && typeof value === 'string' && value.length <= 200 && !/url\\(|expression|[;{}<>]/i.test(value)) document.documentElement.style.setProperty(key, value);
128
+ });
129
+ }
130
+ if (typeof context.timeZone === 'string') state.timeZone = context.timeZone;
131
+ if (typeof context.locale === 'string') state.locale = context.locale;
132
+ };
133
+
134
+ // Only what the view has checked goes into the message a person sends: an id of plain
135
+ // characters and two instants it parsed itself, never a title or any other server text.
136
+ const SAFE_ID = /^[A-Za-z0-9._:-]{1,200}$/;
137
+ const instant = (value) => { const time = typeof value === 'string' ? Date.parse(value) : NaN; return Number.isFinite(time) ? new Date(time) : null; };
138
+ const HOLD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
139
+ const pickable = (slot) => {
140
+ if (!slot || typeof slot.resource_id !== 'string' || !SAFE_ID.test(slot.resource_id)) return null;
141
+ const start = instant(slot.start); const end = instant(slot.end);
142
+ if (!start || !end || end <= start) return null;
143
+ if (slot.hold_id === undefined) return { resource_id: slot.resource_id, start, end };
144
+ // A held time (slotlock_hold_slots): booking it names the hold, so it needs a plain id.
145
+ const expires = instant(slot.expires_at);
146
+ return typeof slot.hold_id === 'string' && HOLD_ID.test(slot.hold_id) && expires
147
+ ? { resource_id: slot.resource_id, start, end, hold_id: slot.hold_id, expires }
148
+ : null;
149
+ };
150
+ const slotsOf = (data) => {
151
+ if (!data || typeof data !== 'object' || data.error) return [];
152
+ // Held time is free for this agent alone until it expires: certain by being held.
153
+ if (Array.isArray(data.holds)) return data.holds.map(pickable).filter((slot) => slot && slot.hold_id);
154
+ if (data.coverage?.certainty !== 'certain') return [];
155
+ const listed = Array.isArray(data.slots) ? data.slots : 'start' in data ? [data] : [];
156
+ return listed.map(pickable).filter(Boolean);
157
+ };
158
+
159
+ const fmt = (date, options) => {
160
+ const zone = state.timeZone ? { timeZone: state.timeZone } : {};
161
+ try { return new Intl.DateTimeFormat(state.locale, { ...options, ...zone }).format(date); }
162
+ catch { return new Intl.DateTimeFormat(undefined, options).format(date); }
163
+ };
164
+ const prefers12 = () => { try { const cycle = new Intl.DateTimeFormat(state.locale, { hour: 'numeric' }).resolvedOptions().hourCycle; return cycle === 'h11' || cycle === 'h12'; } catch { return false; } };
165
+ const hour12 = () => state.hour12 === undefined ? prefers12() : state.hour12;
166
+ const dayKey = (date) => fmt(date, { year: 'numeric', month: '2-digit', day: '2-digit' });
167
+ const dayLabel = (date) => fmt(date, { weekday: 'short', day: 'numeric', month: 'short' });
168
+ const timeLabel = (date) => fmt(date, { hour: hour12() ? 'numeric' : '2-digit', minute: '2-digit', hour12: hour12() });
169
+ // The hour as a number in any locale: German formats it as "10 Uhr", so read the hour part.
170
+ const hourOf = (date) => {
171
+ const zone = state.timeZone ? { timeZone: state.timeZone } : {};
172
+ let parts;
173
+ try { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', ...zone }).formatToParts(date); }
174
+ catch { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23' }).formatToParts(date); }
175
+ return Number(parts.find((part) => part.type === 'hour')?.value) % 24;
176
+ };
177
+ const minutes = (slot) => Math.round((slot.end - slot.start) / 60000);
178
+ const lengthLabel = (total) => total < 60 ? total + ' min' : Math.floor(total / 60) + ' h' + (total % 60 ? ' ' + (total % 60) + ' min' : '');
179
+ const sentence = (slot) => 'Book the slot I picked: ' + (slot.hold_id ? 'hold ' + slot.hold_id + ', ' : '') + 'resource ' + slot.resource_id + ', from ' + slot.start.toISOString() + ' to ' + slot.end.toISOString() + ' (' + dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (state.timeZone ? ' ' + state.timeZone : '') + ').';
180
+
181
+ const renderPicker = (slots) => {
182
+ const resources = [...new Set(slots.map((slot) => slot.resource_id))];
183
+ const lengths = new Set(slots.map(minutes));
184
+ clear(pickMeta);
185
+ if (lengths.size === 1) pickMeta.append(el('span', '', lengthLabel(minutes(slots[0]))));
186
+ if (resources.length > 1) pickMeta.append(el('span', '', resources.length + ' resources'));
187
+ pickMeta.append(el('span', '', state.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local time'));
188
+ const held = slots.filter((slot) => slot.expires).map((slot) => slot.expires).sort((a, b) => a - b)[0];
189
+ if (held) pickMeta.append(el('span', 'held', 'Held until ' + timeLabel(held)));
190
+ h12.setAttribute('aria-pressed', String(hour12())); h24.setAttribute('aria-pressed', String(!hour12()));
191
+ const byDay = new Map();
192
+ slots.forEach((slot) => { const key = dayKey(slot.start); if (!byDay.has(key)) byDay.set(key, []); byDay.get(key).push(slot); });
193
+ if (!byDay.has(state.day)) state.day = byDay.keys().next().value;
194
+ const single = slots.length === 1;
195
+ $('pickTitle').textContent = single ? 'Next available' : 'Pick a time';
196
+ clear(days); clear(times); clear(one);
197
+ days.hidden = single || byDay.size < 2;
198
+ one.hidden = !single;
199
+ times.hidden = single;
200
+ if (single) {
201
+ if (!state.chosen) state.chosen = slots[0];
202
+ one.append(el('strong', '', timeLabel(slots[0].start) + '–' + timeLabel(slots[0].end)), el('span', '', fmt(slots[0].start, { weekday: 'long', day: 'numeric', month: 'long' })));
203
+ } else {
204
+ byDay.forEach((daySlots, key) => {
205
+ const first = daySlots[0].start;
206
+ const button = el('button', 'day');
207
+ button.type = 'button';
208
+ button.setAttribute('aria-pressed', String(key === state.day));
209
+ button.setAttribute('aria-label', dayLabel(first) + ', ' + daySlots.length + (daySlots.length === 1 ? ' time' : ' times'));
210
+ button.append(el('small', '', fmt(first, { weekday: 'short' })), el('strong', '', fmt(first, { day: 'numeric' })), el('i', '', fmt(first, { month: 'short' })));
211
+ button.addEventListener('click', () => { state.day = key; render(); });
212
+ days.append(button);
213
+ });
214
+ const parts = [['Morning', (h) => h < 12], ['Afternoon', (h) => h >= 12 && h < 17], ['Evening', (h) => h >= 17]];
215
+ const daySlots = byDay.get(state.day) || [];
216
+ parts.forEach(([name, test]) => {
217
+ const inPart = daySlots.filter((slot) => test(hourOf(slot.start)));
218
+ if (inPart.length === 0) return;
219
+ const part = el('section', 'part'); const grid = el('div', 'grid');
220
+ part.append(el('h2', '', name), grid);
221
+ inPart.forEach((slot) => {
222
+ const button = el('button', 'time', timeLabel(slot.start));
223
+ button.type = 'button';
224
+ const isChosen = state.chosen && state.chosen.resource_id === slot.resource_id && +state.chosen.start === +slot.start;
225
+ button.setAttribute('aria-pressed', String(Boolean(isChosen)));
226
+ button.setAttribute('aria-label', dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (resources.length > 1 ? ', resource ' + (resources.indexOf(slot.resource_id) + 1) : ''));
227
+ if (resources.length > 1) button.append(el('small', '', 'Resource ' + (resources.indexOf(slot.resource_id) + 1)));
228
+ else if (lengths.size > 1) button.append(el('small', '', lengthLabel(minutes(slot))));
229
+ button.disabled = state.sent;
230
+ button.addEventListener('click', () => { state.chosen = slot; fallback.hidden = true; render(); });
231
+ grid.append(button);
232
+ });
233
+ times.append(part);
234
+ });
235
+ }
236
+ clear(choice);
237
+ choice.hidden = single;
238
+ if (state.chosen) {
239
+ choice.append(el('b', '', dayLabel(state.chosen.start) + ', ' + timeLabel(state.chosen.start) + '–' + timeLabel(state.chosen.end)));
240
+ if (resources.length > 1) choice.append(document.createTextNode('Resource ' + (resources.indexOf(state.chosen.resource_id) + 1)));
241
+ } else choice.textContent = 'No time picked';
242
+ book.disabled = !state.chosen || state.sent;
243
+ book.dataset.sent = String(state.sent);
244
+ book.textContent = state.sent ? '✓ Sent to your agent' : 'Book this time';
245
+ };
246
+ book.addEventListener('click', () => {
247
+ if (!state.chosen || state.sent) return;
248
+ state.sent = true;
249
+ fallback.hidden = true;
250
+ const text = sentence(state.chosen);
251
+ const id = state.nextId++;
252
+ state.pending[id] = text;
253
+ setStatus('Sent to your agent', true);
254
+ post({ jsonrpc: '2.0', id, method: 'ui/message', params: { role: 'user', content: { type: 'text', text } } });
255
+ render();
256
+ });
257
+ h12.addEventListener('click', () => { state.hour12 = true; render(); });
258
+ h24.addEventListener('click', () => { state.hour12 = false; render(); });
259
+
260
+ const reportSize = () => {
261
+ const root = document.documentElement;
262
+ const size = { width: Math.ceil(root.scrollWidth), height: Math.ceil(root.scrollHeight) };
263
+ const key = size.width + 'x' + size.height;
264
+ if (key === state.size) return;
265
+ state.size = key;
266
+ post({ jsonrpc: '2.0', method: 'ui/notifications/size-changed', params: size });
81
267
  };
82
268
  const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' → ');
83
269
  // A refused call carries no structuredContent (hosts validate it against the tool's
@@ -97,6 +283,11 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
97
283
  if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));
98
284
  return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];
99
285
  });
286
+ if (Array.isArray(data.slots)) {
287
+ if (data.coverage?.certainty !== 'certain') return [{ title: 'Availability unproven', detail: 'Required calendar coverage is incomplete.', badge: data.coverage?.certainty || 'uncertain' }];
288
+ return data.slots.length === 0 ? [{ title: 'No free time', detail: 'Nothing of that length is free in the window.', badge: 'certain' }] : data.slots.map((slot) => ({ title: slot.resource_id, detail: intervalText(slot), badge: 'free' }));
289
+ }
290
+ if (Array.isArray(data.holds)) return [{ title: 'Nothing held', detail: 'Every time asked for was taken.', badge: 'refused' }];
100
291
  if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));
101
292
  if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];
102
293
  if ('start' in data || 'end' in data) {
@@ -111,13 +302,23 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
111
302
  }
112
303
  return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));
113
304
  };
305
+ const line = (title, detail, badge) => {
306
+ const row = el('article', 'item');
307
+ row.append(el('div', 'item-title', safeText(title)), el('div', 'item-detail', safeText(detail)), el('div', 'badge', safeText(badge)));
308
+ return row;
309
+ };
114
310
  const render = () => {
311
+ const slots = slotsOf(state.data);
312
+ picker.hidden = slots.length === 0;
313
+ list.hidden = slots.length > 0;
314
+ if (slots.length > 0) { renderPicker(slots); reportSize(); return; }
115
315
  clear(items);
116
316
  const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));
117
- if (rows.length === 0) { const empty = document.createElement('p'); empty.className = 'empty'; empty.textContent = state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'; items.append(empty); }
317
+ if (rows.length === 0) items.append(el('p', 'empty', state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'));
118
318
  else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));
119
319
  summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';
120
320
  raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';
321
+ reportSize();
121
322
  };
122
323
  filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });
123
324
  window.addEventListener('message', (event) => {
@@ -125,19 +326,37 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
125
326
  const message = event.data;
126
327
  if (!message || message.jsonrpc !== '2.0') return;
127
328
  if (message.id === state.requestId && message.result) {
329
+ applyHost(message.result.hostContext);
128
330
  post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });
129
331
  setStatus('Connected', true);
332
+ render();
333
+ return;
334
+ }
335
+ if (message.id in state.pending) {
336
+ const text = state.pending[message.id];
337
+ delete state.pending[message.id];
338
+ if (message.error) {
339
+ // Refused or unsupported: the person can still send it, and pick again.
340
+ state.sent = false;
341
+ fallbackText.value = text;
342
+ fallback.hidden = false;
343
+ setStatus('Not sent', false);
344
+ render();
345
+ }
130
346
  return;
131
347
  }
132
348
  if (message.method === 'ui/notifications/tool-result') {
133
349
  const result = message.params || {};
134
350
  state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);
351
+ state.chosen = null; state.sent = false; state.day = null;
352
+ fallback.hidden = true;
135
353
  setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);
136
354
  render();
137
355
  }
138
- if (message.method === 'ui/notifications/host-context-changed' && message.params?.theme) document.documentElement.dataset.theme = message.params.theme;
356
+ if (message.method === 'ui/notifications/host-context-changed') { applyHost(message.params); render(); }
139
357
  });
140
- post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.0.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });
358
+ if (typeof ResizeObserver === 'function') new ResizeObserver(reportSize).observe(document.documentElement);
359
+ post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.1.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });
141
360
  })();
142
361
  </script>
143
362
  </body>
package/metrics.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { SlotlockAgentServerEvent } from './agent-server.js';
2
+ import type { SlotlockMaintenanceResult } from './maintenance.js';
3
+ import type { SlotlockRefusalCounts } from './rate-limit.js';
4
+ import type { SlotlockWebhookPassResult } from './webhooks.js';
5
+ export interface SlotlockMetrics {
6
+ /** Count what the agent server reports (its `onEvent`). */
7
+ event(event: SlotlockAgentServerEvent): void;
8
+ /** Count the request limits' refusals (their `onRefusals`). */
9
+ refusals(counts: SlotlockRefusalCounts): void;
10
+ /** Count a maintenance pass (its `onPass`). */
11
+ maintenance(result: SlotlockMaintenanceResult): void;
12
+ /** Count a webhook delivery pass (its `onPass`). */
13
+ webhooks(result: SlotlockWebhookPassResult): void;
14
+ /** Count a scrape refused for its credential. */
15
+ refusedScrape(): void;
16
+ /** Everything counted so far, as a scrape answers it. */
17
+ render(): string;
18
+ }
19
+ /** Counters for one server process, reset when it restarts (`slotlock_start_time_seconds`). */
20
+ export declare function createSlotlockMetrics(info: {
21
+ version: string;
22
+ tzdata?: string;
23
+ now?: () => number;
24
+ }): SlotlockMetrics;