Vee

Vee documentation

Widgets

Vee plugins can render on your desktop and in Notification Center as native WidgetKit tiles, not just in the menu bar. Every plugin already has a widget representation for free; a plugin that opts in can print a widget card — a JSON payload describing real data — and Vee draws it with native SwiftUI.

This page covers the widget surface contract, the card schema, the five templates, and the composable layout tree for cards that the templates do not fit. For the menu-bar output format, see the Plugin authoring reference.

By default your plugin's widget tile is a scrape of its menu-bar line — whatever color=/sfimage= is on the title, plus a progress=/sparkline= if the first row has one. That's automatic; every plugin already has a widget representation with no changes.

For a rich tile — real data laid out per widget size, not a caricature of the menu bar — opt a plugin into the widget surface contract:

text
# <vee.surface>both</vee.surface>
  • <vee.surface>menu</vee.surface> (or omit the tag) — unchanged: a normal menu-bar plugin, scraped for its widget tile.
  • <vee.surface>both</vee.surface> — served in the menu as usual, and invoked a second time in widget mode to produce a rich widget card.
  • <vee.surface>widget</vee.surface>widget-only: no status item, no menu bar presence at all. The plugin exists only to feed a widget.

The widget-mode cadence needs no separate tag — it reuses the plugin's filename interval (the same field the menu bar uses), with only a small safety floor: max(filename interval, 10s). Because Vee is an always-running app, it pushes widget reloads the moment new data arrives (rather than waiting on WidgetKit's passive budget, which only applies when an app isn't running), so a cpu.5s.sh widget can track near-real-time data straight from the menu-bar plugin's own cadence. A widget-only plugin whose filename carries no interval falls back to the 10-second floor.

VEE_TARGET

Every run gets a VEE_TARGET environment variable:

  • VEE_TARGET=menu — a normal run; print the usual xbar/SwiftBar text (or JSON).
  • VEE_TARGET=widget — a widget-mode run; print one JSON object (the "card", schema below) to stdout and nothing else.

Branch on it like Scriptable's config.runsInWidget. If your plugin ignores VEE_TARGET=widget and prints menu text anyway, Vee falls back to scraping that text — graceful degradation, never a crash.

The card

json
{
  "vee_widget": 1,
  "template": "stat",
  "title": "Revenue",
  "symbol": "chart.line.uptrend.xyaxis",
  "tint": "green",
  "value": "$18.2k",
  "caption": "today",
  "detail": "214 orders",
  "status": "ok",

  "progress": 0.72,
  "trend": [12.1, 13.4, 12.9, 15.0, 18.2],

  "items": [
    { "label": "Orders",  "value": "214", "symbol": "bag",           "tint": "blue" },
    { "label": "Refunds", "value": "3",   "symbol": "arrow.uturn.left", "tint": "red" }
  ],

  "actions": [
    { "label": "Refresh", "kind": "refresh" },
    { "label": "Open",    "kind": "href",     "url": "https://dash.example.com" }
  ],

  "refresh_after": 900,
  "stale_after": 3600
}
FieldTypeMeaning
vee_widgetintPayload schema version (currently 1).
templateenumstat | gauge | trend | list | board. Unknown → stat + a Debug diagnostic.
titlestring?Tile heading (the plugin/metric name).
symbolstring?SF Symbol name for the glyph.
tintcolor?Named (green) or #rrggbbaa.
valuestring?The headline value, already formatted by the plugin.
captionstring?Small secondary line (e.g. "today").
detailstring?One more line of context.
statusenum?ok | warning | error — drives styling and the health roll-up.
progressdouble?0…1, clamped; the gauge template's fill.
trend[double]?The trend template's series.
items[Item]?Rows for list/board: {label, value?, symbol?, tint?}.
actions[Action]?Up to two rendered as buttons — see below.
refresh_afterint?Seconds; a hint for the next widget reload.
stale_afterint?Seconds; when the tile should show a stale treatment (else the interval-derived default).
layoutnode?A composable layout tree. When present, it replaces template entirely.

Unknown top-level keys are ignored (forward-compatible); an invalid value (bad progress, a non-finite trend entry, an unsafe href URL) degrades to nil/dropped with a diagnostic, visible in the plugin's Debug console — never a crash.

Templates

Five native SwiftUI templates, each adapting across the small/medium/large widget families — describe your data, Vee draws it:

  • stat — glyph, big value in tint, title/caption. The default.
  • gauge — stat + a native gauge from progress.
  • trend — stat + a sparkline from trend.
  • listtitle header + items as rows, truncated per family (small shows the headline value; medium ≤3 rows; large ≤8).
  • board — a compact grid of items as stat cells (a KPI board); small collapses to the headline.

If none of the five fits your data, skip template and send a layout tree instead.

The layout tree

The five templates cover most tiles. When your data does not fit one — two columns, a header rail, a KPI grid, a ring over a label — a card can carry a layout tree instead: a small vocabulary of containers and leaves that Vee walks into native SwiftUI.

A card is either a template or a tree. Set layout and the template field is not consulted at all.

json
{
  "vee_widget": 1,
  "layout": {
    "type": "vstack",
    "align": "leading",
    "spacing": 6,
    "children": [
      {
        "type": "hstack",
        "spacing": 5,
        "children": [
          { "type": "image", "symbol": "cpu", "style": { "tint": "blue" } },
          { "type": "text", "text": "CPU",
            "style": { "font": { "size": "caption", "weight": "semibold" }, "tint": "secondary" } },
          { "type": "spacer" }
        ]
      },
      { "type": "text", "text": "38%",
        "style": { "font": { "size": "title", "design": "rounded" }, "tint": "green",
                   "monospaced_digit": true, "min_scale": 0.6 } },
      { "type": "gauge", "value": 0.38, "gauge_style": "circular", "style": { "tint": "green" } }
    ]
  }
}

The tree is deliberately bounded, not freeform: there is no absolute positioning, no point frames, and no scroll views. Every node maps 1:1 to a SwiftUI primitive, which is what keeps the renderer small and keeps this from turning into the WebView canvas Vee exists to avoid.

Node types

Every node has a type. Containers hold children; leaves do not.

typeKindCarriesRenders as
vstackcontainerchildren, align, spacingA vertical stack
hstackcontainerchildren, align, spacingA horizontal stack
zstackcontainerchildren, alignA depth stack — overlays, rings over labels
gridcontainerchildren, columns, spacingA grid, left-aligned
textleaftextA text run
imageleafsymbolAn SF Symbol glyph (v1 renders SF Symbols only)
gaugeleafvalue, gauge_styleA native gauge
sparklineleafvaluesA sparkline
spacerleafmin_lengthFlexible space
dividerleafA hairline rule

An unrecognised type renders nothing and produces a diagnostic, rather than failing the card.

Node fields

FieldTypeApplies toMeaning
typestringallThe node kind, from the table above. Required.
textstring?textThe string to draw. Truncated at 512 characters.
symbolstring?imageSF Symbol name.
valuedouble?gaugeFill, clamped to 0…1.
values[double]?sparklineThe series. Non-finite entries are dropped; capped at 256 points.
gauge_stylestring?gaugelinear (default) or circular.
alignstring?containersCross-axis alignment — see below.
spacingdouble?containersInter-child spacing in points, clamped 0…64.
columnsint?gridColumn count. Default 2, clamped 1…4.
min_lengthdouble?spacerMinimum space in points, clamped 0…4096.
families[string]?allWhich widget sizes this node appears in — see Adapting per widget size.
styleobject?allPer-element styling — see below.
children[node]?containersChild nodes.

align takes a different vocabulary per container, matching SwiftUI's own — anything unrecognised falls back to the default:

ContainerAccepted alignDefault
vstackleading, center, trailingleading
hstacktop, center, bottomcenter
zstacktopLeading, top, bottom, leading, trailing, centercenter

style

A bounded set of modifiers. Every numeric value is clamped.

FieldTypeMeaning
fontobject?Text font — see below.
tintcolor?Named (green, secondary) or #rrggbbaa.
alignstring?Multiline text alignment: leading (default), center, trailing.
paddingdouble?Uniform padding in points, clamped 0…64.
line_limitint?Maximum text lines, clamped 1…20.
monospaced_digitbool?Fixed-width digits — stops numeric columns jittering between refreshes.
min_scaledouble?Minimum scale factor, clamped 0.3…1.0. Lets a headline shrink to fit instead of truncating.
fillbool?Grow to fill the available width. This is the only width control; arbitrary point frames are deliberately not exposed.

style.font:

FieldTypeAccepted values
sizestring?caption2, caption, footnote, subheadline, headline, title3, title2, title, largeTitle. Anything else falls back to body.
point_sizedouble?An explicit size, clamped 8…96. Wins over size when both are set.
weightstring?medium, semibold, bold. Anything else is regular.
designstring?rounded, monospaced, serif. Anything else is the default face.

Use size by default — a semantic token scales with the system text size. Reach for point_size only when a token cannot hit what you need, like an oversized headline number or a very small legend.

Adapting per widget size (families)

A node with families renders only in the widget sizes it lists; a node without it renders in all of them. Values are small, medium, and large.

This lets one tree adapt by subtraction rather than making you author three payloads — the same way the preset templates truncate their rows per size:

json
{ "type": "text", "text": "214 orders today", "families": ["medium", "large"] }

The small tile drops that row; the medium and large tiles keep it.

Limits

The tree is sanitized before it is rendered. Every limit degrades — truncating, clamping, or dropping, with a diagnostic in the Debug console — and never fails the card:

LimitValue
Maximum nesting depth8 levels (deeper children are dropped)
Maximum nodes per tree64 (extra nodes dropped)
Maximum text length512 characters (truncated)
Maximum sparkline points256 (truncated)

Unknown keys are ignored, so a tree stays forward-compatible.

Building a tree with the SDK

All three SDKs expose the same namespaced builders — Node.VStack, Node.HStack, Node.ZStack, Grid, Text, Image, Gauge, Sparkline, Spacer, Divider — and emit byte-identical JSON. Each of these produces exactly the payload shown at the top of this section:

TypeScript

ts
import { widgetCard, Node } from "./src/vee.ts";

widgetCard({
  layout: Node.VStack(
    [
      Node.HStack([
        Node.Image("cpu", { style: { tint: "blue" } }),
        Node.Text("CPU", { style: { font: { size: "caption", weight: "semibold" }, tint: "secondary" } }),
        Node.Spacer(),
      ], { spacing: 5 }),
      Node.Text("38%", {
        style: { font: { size: "title", design: "rounded" }, tint: "green", monospacedDigit: true, minScale: 0.6 },
      }),
      Node.Gauge(0.38, { gaugeStyle: "circular", style: { tint: "green" } }),
    ],
    { align: "leading", spacing: 6 },
  ),
}).print();

Python

python
from vee import widget_card, Node

widget_card(
    layout=Node.VStack(
        [
            Node.HStack(
                [
                    Node.Image("cpu", style={"tint": "blue"}),
                    Node.Text("CPU", style={"font": {"size": "caption", "weight": "semibold"}, "tint": "secondary"}),
                    Node.Spacer(),
                ],
                spacing=5,
            ),
            Node.Text(
                "38%",
                style={"font": {"size": "title", "design": "rounded"}, "tint": "green",
                       "monospaced_digit": True, "min_scale": 0.6},
            ),
            Node.Gauge(0.38, gauge_style="circular", style={"tint": "green"}),
        ],
        align="leading",
        spacing=6,
    ),
).print()

Go

go
layout := vee.Node.VStack(
	[]vee.WidgetNode{
		vee.Node.HStack([]vee.WidgetNode{
			vee.Node.Image("cpu", vee.Style(vee.WidgetNodeStyle{Tint: vee.Str("blue")})),
			vee.Node.Text("CPU", vee.Style(vee.WidgetNodeStyle{
				Font: &vee.WidgetNodeFont{Size: vee.Str("caption"), Weight: vee.Str("semibold")},
				Tint: vee.Str("secondary"),
			})),
			vee.Node.Spacer(),
		}, vee.Spacing(5)),
		vee.Node.Text("38%", vee.Style(vee.WidgetNodeStyle{
			Font:            &vee.WidgetNodeFont{Size: vee.Str("title"), Design: vee.Str("rounded")},
			Tint:            vee.Str("green"),
			MonospacedDigit: vee.Bool(true),
			MinScale:        vee.Float(0.6),
		})),
		vee.Node.Gauge(0.38, vee.GaugeStyle("circular"), vee.Style(vee.WidgetNodeStyle{Tint: vee.Str("green")})),
	},
	vee.Align("leading"), vee.Spacing(6),
)

c := &vee.WidgetCard{Layout: &layout}
c.Print()

Each SDK ships this as a runnable widget-layout example with a shared golden fixture, so the three implementations and the Swift parser cannot drift apart.

Actions

Up to two actions render as buttons:

  • refresh — re-runs this plugin.
  • href — opens a URL (scheme-filtered like menu href=: http/https/ custom app deep links; never file/javascript/…).
  • shortcut — runs a named macOS Shortcut (name), like menu shortcut=.

There is deliberately no shell action — a widget button must not run an arbitrary command without the menu's context.

Building the card with the SDK

The TypeScript, Python, and Go SDKs all have Stat/Gauge/Trend/ List/Board builders that emit this JSON for you:

ts
import { Stat } from "./src/vee.ts";

if (process.env.VEE_TARGET === "widget") {
  Stat({
    title: "Revenue",
    symbol: "chart.line.uptrend.xyaxis",
    tint: "green",
    value: "$18.2k",
    status: "ok",
    actions: [{ kind: "refresh", label: "Refresh" }],
  }).print();
} else {
  // ordinary menu-bar output
}

See Plugin SDKs for the Python/Go equivalents.

Editor validation (JSON Schema)

Vee publishes a schema for the card payload, so your editor can check a card while you write it — unknown fields, bad enum values, out-of-range numbers — against the same constraints Vee applies at runtime:

Reference it from a card you are authoring by hand:

json
{
  "$schema": "https://vee.navbytes.io/schemas/widget-card.schema.json",
  "vee_widget": 1,
  "template": "stat",
  "title": "Revenue"
}

$schema is an unknown key to Vee, and unknown keys are ignored, so leaving it in a shipped plugin is harmless.

The schema covers every field the parser reads, including the layout tree, and CI validates it against the SDKs' golden fixtures — so it cannot quietly drift from what Vee accepts.

See also