Repository navigation
Views and UI
Views (API 1.5) are HTML pages from an extension's package that the app
shows in sandboxed frames: a page in the main area, an item in the status
bar, a popover, or a control beside a canvas's title. A view cannot reach the
network, the app's window or the extension's process directly; it talks to
its extension through the app, by postMessage.
┌──────── Devlog window ────────┐ ┌──── extension process ────┐
│ <iframe sandbox=allow-scripts│ │ │
│ src=devlog-ext://…/x.html> │ │ ctx.views.handle('x', fn) │
│ devlog.call('status') ────┼──IPC──►│ fn('status', []) │
│ ◄── reply ────────────────┼────────┤ return {...} │
│ ◄── message ──────────────┼────────┤ ctx.views.post('x', data) │
│ ◄── theme, context ───────┤ │ │
└───────────────────────────────┘ └────────────────────────────┘
Other ways for an extension to show things without a view:
ctx.ui.notify, confirm, pick, app.setTrayLabel, ui.highlight and
blocks it writes. See Extension API Reference.
"contributes": {
"views": [
{ "id": "summary", "title": "Summary", "entry": "dist/views/summary.html", "placement": "page", "icon": "Σ" },
{ "id": "status", "title": "Time tracking", "entry": "dist/views/status.html", "placement": "statusbar" },
{ "id": "picker", "title": "Start a task", "entry": "dist/views/picker.html", "placement": "popover" },
{ "id": "header", "title": "Start or stop this task", "entry": "dist/views/header.html", "placement": "canvasHeader", "nodeType": "task" }
]
}Field rules are in Extension Manifest → views.
| Placement | Since | Where | Size | Context it gets |
|---|---|---|---|---|
page |
1.5 | Listed in the sidebar and quick switcher with the app's views; fills the main area. Open it from code with ctx.ui.openPage(id) (1.7). |
The main area | — |
statusbar |
1.5 | A slot in the status bar. | 22 px high; asks for a width with resize, clamped to 24–360 px |
The canvas (and block page) on screen |
popover |
1.5 | Opened by another of its views with popover, anchored to that view; closes on Escape, a click outside, or close. |
Default 320 × 360; width clamped to 160–560 px, height to 640 px | The opener's context |
canvasHeader |
1.6 | Beside a canvas's title, on every canvas or only those of nodeType. |
26 px high; starts 120 px wide and asks for a width with resize, clamped to 24–480 px |
That canvas |
The composer (note box) is not shown on extension pages.
-
URL:
devlog-ext://<the devlog.json key, as hex>/<file>, only from the extension's own folder and only while the extension runs. -
Served types:
.html,.htm,.js,.mjs,.css,.json,.svg,.png,.jpg,.woff2. Anything else is a 404. -
The frame is
sandbox="allow-scripts"(no same origin: no cookies,localStorageor access to the parent), withreferrerpolicy=no-referrer. -
Content Security Policy:
default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'So: scripts from your package only (no inline
<script>), nofetchor WebSocket, no frames, no form submission. Put network calls in the extension process and expose them throughviews.handle.
In the extension:
ctx.views.handle('status', async (method, args) => {
switch (method) {
case 'status': return clock.status()
case 'start': await clock.start(String(args[0])); return clock.status()
default: throw new Error(`No method ${method}`)
}
})
// Push updates to every open copy of the view.
clock.onChange((st) => ctx.views.post('status', st))- One handler per view id (a later
handlereplaces it). One handler may serve several views:for (const v of ['status', 'header']) ctx.views.handle(v, handler). - A thrown error becomes a rejected
devlog.callin the page, with the error's message. - Calls time out after 60 seconds. A call made before the handler is
registered fails with
The view "…" has no handler yet.
@devlog/ui is a small React kit: the bridge, hooks, a few components and
the app's look. Bundle it into the view's script. Full reference:
UI Kit Reference.
src/views/status.tsx:
import '@devlog/ui/styles.css'
import { useLayoutEffect, useRef, useState } from 'react'
import { Button, devlog, mount, useCall, useMessages, useViewContext } from '@devlog/ui'
interface Status { active: string | null; label: string | null }
function StatusItem(): React.JSX.Element {
const initial = useCall<Status>('status')
const [st, setSt] = useState<Status | undefined>()
useMessages((d) => setSt(d as Status)) // ctx.views.post('status', …)
const shown = st ?? initial.data
const here = useViewContext().canvasId // the canvas on screen
// Ask for the width the content needs.
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (ref.current) devlog.resize({ width: Math.ceil(ref.current.scrollWidth) + 2 })
})
return (
<div className="dl-row" ref={ref}>
<span className="dl-muted">{shown?.label ?? 'No active task'}</span>
{here && <Button small variant="primary" onClick={() => void devlog.call('start', here)}>Start</Button>}
<Button small variant="quiet" onClick={() => devlog.popover('picker', { width: 340, height: 380 })}>▾</Button>
</div>
)
}
mount(<StatusItem />, { slot: true }) // slot: the status bar's stylingBuild each view to one script and one stylesheet next to an HTML file.
esbuild, as scripts/build-builtins.mjs does for the built-ins:
npx esbuild src/views/status.tsx --bundle --platform=browser --format=iife \
--target=chrome120 --jsx=automatic --minify \
--define:process.env.NODE_ENV='"production"' \
--outfile=dist/views/status.jsdist/views/status.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Time tracking</title>
<link rel="stylesheet" href="status.css">
</head>
<body>
<div id="root"></div>
<script src="status.js"></script>
</body>
</html>Any page can speak the protocol. Import @devlog/ui/bridge alone, or send
the messages yourself:
// view.js (loaded with <script src="view.js">; inline scripts are blocked)
let next = 1
const waiting = new Map()
const send = (msg) => window.parent.postMessage({ devlog: 1, ...msg }, '*')
window.addEventListener('message', (ev) => {
if (ev.source !== window.parent || ev.data?.devlog !== 1) return
const m = ev.data
if (m.type === 'theme') for (const [k, v] of Object.entries(m.vars)) document.documentElement.style.setProperty(k, v)
if (m.type === 'reply') { const w = waiting.get(m.id); waiting.delete(m.id); m.ok ? w?.resolve(m.value) : w?.reject(new Error(m.error)) }
if (m.type === 'message') render(m.data)
})
const call = (method, ...args) => new Promise((resolve, reject) => {
const id = next++
waiting.set(id, { resolve, reject })
send({ type: 'call', id, method, args })
})
send({ type: 'ready' })
call('status').then(render)Types: @devlog/extension-api/view. Every message carries devlog: 1.
Messages from anything but window.parent should be ignored.
type |
Fields | Since | Meaning |
|---|---|---|---|
ready |
— | 1.5 | The page is listening. The app answers with theme, then context. |
call |
id, method, args
|
1.5 | Ask the extension (its views.handle handler). Answered by reply with the same id. |
resize |
width?, height?
|
1.5 | The size the page would like. Status bar and header items get their height from the bar. |
popover |
view, width?, height?
|
1.5 | Open one of the extension's popover views, anchored to this one. |
close |
— | 1.5 | Close this view (a popover). |
open |
canvasId, date?, blockId?
|
1.5 | Show a canvas, or a block's page, in the app. |
command |
command |
1.5 | Run one of the extension's commands, with source: 'view' and the view's context (1.6). |
key |
key, code, ctrlKey, metaKey, altKey, shiftKey
|
1.7 | A shortcut the page did not use (Ctrl, Alt or Cmd with a key; F-keys), so Ctrl+K and the like still reach the app. @devlog/ui/bridge sends these for you. |
type |
Fields | Since | Meaning |
|---|---|---|---|
theme |
vars, dark
|
1.5 | The app's colours as CSS custom properties, and whether it is dark. Sent after ready, on load, and whenever the theme changes. |
reply |
id, ok, value?, error?
|
1.5 | The answer to a call. |
message |
data |
1.5 | Something the extension sent with ctx.views.post. |
context |
context: ViewContext |
1.6 | Where the view is shown; sent after theme, and again when it changes. |
--bg, --bg-2, --bg-3, --fg, --fg-muted, --border,
--border-strong, --accent, --accent-fg, --warn, --danger, --ok,
--link, --code-bg, --shadow, --mono, --sans, --font. Use them in
your CSS (color: var(--fg)) and the view follows the user's theme and
light/dark mode. @devlog/ui/styles.css has defaults for the moment before
the first theme message.
| Placement | context |
|---|---|
canvasHeader |
{ canvasId } of the canvas it sits on |
statusbar |
The canvas on screen, and date + blockId when a block page is on screen |
popover |
The context of the view that opened it |
page |
{} |
Commands run from a view (command message) get the same context.
- Keep state in the extension process and push it with
views.post; views come and go (a popover closes, the user navigates). - Several copies of a view can be open at once;
postreaches them all. - Status bar items: call
resizewhenever the content changes width, and usemount(…, { slot: true })(orbody.dl-slot) for a transparent, one-line, 22 px body. - A view loads only while its extension runs; if the extension is stopped the frame shows nothing.
Devlog 0.18.0 · extension API 1.7.0 · storage format 4 · Repository · Design notes
Using Devlog
Writing extensions
- Overview
- Quickstart
- Manifest
- API Reference
- Types
- Views and UI
- UI Kit Reference
- Timesheet Destinations
- Activity and Time Data
- Sandbox and Permissions
- Testing
- Built-in Extensions
- API Versions
- Troubleshooting
Devlog internals