{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "widgets",
  "title": "Widgets",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Desktop widgets in three sizes: weather with a sky gradient, hourly strip and daily ranges; a calendar with a month grid and events; a battery widget with rings. They scale with their width and use UTC dates.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/widgets.tsx",
      "content": "// Ballmac UI: Widgets. https://ui.ballmac.com/components/widgets\n\"use client\"\n\nimport * as React from \"react\"\nimport { Cloud, CloudLightning, CloudRain, CloudSnow, CloudSun, Moon, Navigation, Sun, Zap } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype WidgetSize = \"small\" | \"medium\" | \"large\"\n\n/** Small is a square, medium is twice as wide, large is twice as wide and as tall. Every size scales with its width. */\nconst SHAPE: Record<WidgetSize, string> = {\n  small: \"aspect-square\",\n  medium: \"aspect-[2.14/1]\",\n  large: \"aspect-[0.955/1]\",\n}\n\ntype WidgetProps = React.ComponentProps<\"section\"> & {\n  /** Which size the widget draws at. Set the width with `className` (a small widget is about 10rem wide). */\n  size?: WidgetSize\n  /** Accessible name of the widget. */\n  label: string\n  /** Class names for the widget's surface (background and text color). `className` sizes and places the widget. */\n  surfaceClassName?: string\n}\n\n/** Frame for a desktop widget: rounded, container-scaled, and one of three sizes. Use it for your own widgets. */\nfunction Widget({ size = \"small\", label, className, surfaceClassName, children, ...props }: WidgetProps) {\n  return (\n    <section data-slot=\"widget\" data-size={size} aria-label={label} className={cn(\"@container relative w-40 max-w-full\", SHAPE[size], size !== \"small\" && \"w-80\", className)} {...props}>\n      <div\n        data-slot=\"widget-surface\"\n        className={cn(\n          \"absolute inset-0 overflow-hidden rounded-[calc(var(--u)*14)] shadow-[0_8px_24px_-8px_rgb(0_0_0/0.35),0_0_0_1px_rgb(0_0_0/0.06)]\",\n          size === \"small\" ? \"[--u:1cqw]\" : \"[--u:0.467cqw]\",\n          surfaceClassName\n        )}\n      >\n        {children}\n      </div>\n    </section>\n  )\n}\n\n/** Font size in the widget's own unit. */\nconst fs = (n: number): React.CSSProperties => ({ fontSize: `calc(var(--u) * ${n})` })\nconst sp = (n: number) => `calc(var(--u) * ${n})`\n\n// Weather\n\ntype WeatherCondition = \"clear\" | \"partly-cloudy\" | \"cloudy\" | \"rain\" | \"snow\" | \"storm\" | \"night\"\n\nconst SKY: Record<WeatherCondition, string> = {\n  clear: \"bg-[linear-gradient(165deg,oklch(0.47_0.17_255),oklch(0.58_0.13_232))]\",\n  \"partly-cloudy\": \"bg-[linear-gradient(165deg,oklch(0.46_0.15_255),oklch(0.57_0.1_238))]\",\n  cloudy: \"bg-[linear-gradient(165deg,oklch(0.44_0.04_255),oklch(0.55_0.035_245))]\",\n  rain: \"bg-[linear-gradient(165deg,oklch(0.36_0.06_260),oklch(0.48_0.06_245))]\",\n  snow: \"bg-[linear-gradient(165deg,oklch(0.46_0.06_245),oklch(0.57_0.045_235))]\",\n  storm: \"bg-[linear-gradient(165deg,oklch(0.28_0.07_290),oklch(0.4_0.08_270))]\",\n  night: \"bg-[linear-gradient(165deg,oklch(0.22_0.08_275),oklch(0.34_0.1_262))]\",\n}\n\nconst SKY_ICON: Record<WeatherCondition, React.ComponentType<{ className?: string }>> = {\n  clear: Sun,\n  \"partly-cloudy\": CloudSun,\n  cloudy: Cloud,\n  rain: CloudRain,\n  snow: CloudSnow,\n  storm: CloudLightning,\n  night: Moon,\n}\n\nconst SKY_WORD: Record<WeatherCondition, string> = {\n  clear: \"Clear\",\n  \"partly-cloudy\": \"Partly Cloudy\",\n  cloudy: \"Cloudy\",\n  rain: \"Rain\",\n  snow: \"Snow\",\n  storm: \"Thunderstorms\",\n  night: \"Clear Night\",\n}\n\ntype HourlyForecast = { label: string; temp: number; condition: WeatherCondition }\ntype DailyForecast = { day: string; low: number; high: number; condition: WeatherCondition }\n\ntype WeatherWidgetProps = Omit<WidgetProps, \"label\" | \"children\" | \"surfaceClassName\"> & {\n  /** Place name. */\n  city: string\n  /** Current temperature, in degrees. */\n  temperature: number\n  /** Current sky. Chooses the gradient, icon and wording. */\n  condition?: WeatherCondition\n  /** Today's high. */\n  high: number\n  /** Today's low. */\n  low: number\n  /** Upcoming hours, shown on medium and large widgets. */\n  hourly?: HourlyForecast[]\n  /** Upcoming days, shown on the large widget. */\n  daily?: DailyForecast[]\n}\n\nfunction SkyIcon({ condition, className, style }: { condition: WeatherCondition; className?: string; style?: React.CSSProperties }) {\n  const Icon = SKY_ICON[condition]\n  return (\n    <span aria-hidden=\"true\" className={cn(\"flex items-center justify-center drop-shadow-[0_1px_3px_rgb(0_0_0/0.25)]\", className)} style={style}>\n      <Icon className=\"size-full\" />\n    </span>\n  )\n}\n\n/** A weather widget with a sky gradient that follows the condition, hourly strip and daily ranges. */\nfunction WeatherWidget({ city, temperature, condition = \"clear\", high, low, hourly = [], daily = [], size = \"small\", className, ...props }: WeatherWidgetProps) {\n  const min = Math.min(...daily.map((d) => d.low), low)\n  const max = Math.max(...daily.map((d) => d.high), high)\n  const summary = `${city}, ${temperature} degrees, ${SKY_WORD[condition]}. High ${high}, low ${low}.`\n  return (\n    <Widget size={size} label={`Weather in ${city}`} surfaceClassName={cn(\"text-white [text-shadow:0_1px_2px_rgb(0_0_0/0.18)]\", SKY[condition])} className={className} {...props}>\n      <p className=\"sr-only\">{summary}</p>\n      <div aria-hidden=\"true\" className=\"flex size-full flex-col\" style={{ padding: sp(size === \"small\" ? 11 : size === \"medium\" ? 9 : 13) }}>\n        <div className=\"flex items-start justify-between\">\n          <div className=\"min-w-0\">\n            <p className=\"flex items-center gap-[calc(var(--u)*3)] font-semibold\" style={fs(size === \"small\" ? 9.5 : 10)}>\n              <span className=\"truncate\">{city}</span>\n              <Navigation className=\"shrink-0 fill-current\" style={{ width: sp(8), height: sp(8) }} />\n            </p>\n            <p className=\"font-light tracking-tight tabular-nums\" style={{ ...fs(size === \"small\" ? 31 : size === \"medium\" ? 27 : 38), lineHeight: 1.05, marginInlineStart: sp(-1) }}>\n              {temperature}°\n            </p>\n          </div>\n          {size !== \"small\" && (\n            <div className=\"flex flex-col items-end text-end\" style={{ gap: sp(1) }}>\n              <SkyIcon condition={condition} style={{ width: sp(size === \"medium\" ? 14 : 20), height: sp(size === \"medium\" ? 14 : 20) }} />\n              <p className=\"font-semibold\" style={fs(9.5)}>{SKY_WORD[condition]}</p>\n              <p className=\"tabular-nums\" style={fs(9.5)}>H:{high}° L:{low}°</p>\n            </div>\n          )}\n        </div>\n        {size === \"small\" && (\n          <div className=\"mt-auto\">\n            <p className=\"flex items-center font-semibold\" style={{ gap: sp(4), ...fs(9.5) }}>\n              <SkyIcon condition={condition} style={{ width: sp(12), height: sp(12) }} />\n              <span className=\"truncate\">{SKY_WORD[condition]}</span>\n            </p>\n            <p className=\"tabular-nums\" style={fs(9.5)}>H:{high}° L:{low}°</p>\n          </div>\n        )}\n        {size !== \"small\" && hourly.length > 0 && (\n          <div className={cn(\"flex justify-between border-t border-white/25\", size === \"medium\" ? \"mt-auto\" : \"mt-[calc(var(--u)*10)]\")} style={{ paddingTop: sp(size === \"medium\" ? 5 : 8) }}>\n            {hourly.slice(0, 6).map((h) => (\n              <div key={h.label} className=\"flex flex-col items-center\" style={{ gap: sp(size === \"medium\" ? 2.5 : 4), ...fs(size === \"medium\" ? 8.5 : 9) }}>\n                <span className=\"font-medium\">{h.label}</span>\n                <SkyIcon condition={h.condition} style={{ width: sp(size === \"medium\" ? 11 : 13), height: sp(size === \"medium\" ? 11 : 13) }} />\n                <span className=\"font-semibold tabular-nums\" style={fs(size === \"medium\" ? 9.5 : 10)}>{h.temp}°</span>\n              </div>\n            ))}\n          </div>\n        )}\n        {size === \"large\" && daily.length > 0 && (\n          <ul className=\"mt-[calc(var(--u)*8)] flex flex-1 flex-col justify-between border-t border-white/25\" style={{ paddingTop: sp(4) }}>\n            {daily.slice(0, 5).map((d) => (\n              <li key={d.day} className=\"grid grid-cols-[18%_12%_1fr] items-center\" style={{ gap: sp(6), ...fs(10) }}>\n                <span className=\"font-semibold\">{d.day}</span>\n                <SkyIcon condition={d.condition} style={{ width: sp(12), height: sp(12) }} />\n                <span className=\"flex items-center\" style={{ gap: sp(6) }}>\n                  <span className=\"w-[14%] text-end tabular-nums opacity-85\">{d.low}°</span>\n                  <span className=\"relative h-[calc(var(--u)*3.5)] flex-1 rounded-full bg-white/25\">\n                    <span\n                      className=\"absolute inset-y-0 rounded-full bg-gradient-to-r from-[oklch(0.85_0.13_200)] to-[oklch(0.85_0.15_80)]\"\n                      style={{ left: `${((d.low - min) / (max - min || 1)) * 100}%`, right: `${100 - ((d.high - min) / (max - min || 1)) * 100}%` }}\n                    />\n                  </span>\n                  <span className=\"w-[14%] tabular-nums\">{d.high}°</span>\n                </span>\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n    </Widget>\n  )\n}\n\n// Calendar\n\nconst WEEKDAYS = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"]\nconst MONTHS = [\"January\", \"February\", \"March\", \"April\", \"May\", \"June\", \"July\", \"August\", \"September\", \"October\", \"November\", \"December\"]\n\ntype CalendarEvent = { id: string; title: string; time: string; tone?: \"red\" | \"blue\" | \"green\" | \"orange\" | \"purple\" }\n\nconst EVENT_TONE: Record<NonNullable<CalendarEvent[\"tone\"]>, string> = {\n  red: \"bg-destructive\",\n  blue: \"bg-chart-1\",\n  green: \"bg-chart-2\",\n  orange: \"bg-chart-5\",\n  purple: \"bg-chart-4\",\n}\n\ntype CalendarWidgetProps = Omit<WidgetProps, \"label\" | \"children\" | \"surfaceClassName\"> & {\n  /** The day to show, as an ISO date such as \"2026-10-01\". Read as UTC, so every viewer sees the same day. */\n  date: string\n  /** Events on that day, in order. */\n  events?: CalendarEvent[]\n}\n\nfunction monthGrid(year: number, month: number) {\n  const first = new Date(Date.UTC(year, month, 1)).getUTCDay()\n  const days = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()\n  return { lead: first, days }\n}\n\nfunction Events({ events, limit }: { events: CalendarEvent[]; limit: number }) {\n  const msg = useMessages()\n  if (events.length === 0) return <p className=\"text-muted-foreground\" style={fs(9.5)}>{msg(\"widgets.noEventsToday\", \"No events today\")}</p>\n  return (\n    <ul className=\"flex flex-col\" style={{ gap: sp(5) }}>\n      {events.slice(0, limit).map((e) => (\n        <li key={e.id} className=\"flex items-stretch\" style={{ gap: sp(5) }}>\n          <span aria-hidden=\"true\" className={cn(\"w-[calc(var(--u)*2.4)] shrink-0 rounded-full\", EVENT_TONE[e.tone ?? \"blue\"])} />\n          <span className=\"min-w-0 leading-tight\" style={fs(9.5)}>\n            <span className=\"block truncate font-semibold\">{e.title}</span>\n            <span className=\"block truncate text-muted-foreground tabular-nums\">{e.time}</span>\n          </span>\n        </li>\n      ))}\n    </ul>\n  )\n}\n\n/** A calendar widget: the day in big type, a month grid with today circled, and the day's events. */\nfunction CalendarWidget({ date, events = [], size = \"small\", className, ...props }: CalendarWidgetProps) {\n  const d = new Date(`${date}T00:00:00Z`)\n  const year = d.getUTCFullYear()\n  const month = d.getUTCMonth()\n  const day = d.getUTCDate()\n  const weekday = WEEKDAYS[d.getUTCDay()]!\n  const { lead, days } = monthGrid(year, month)\n  const label = `${weekday}, ${MONTHS[month]} ${day}, ${year}`\n  const compact = size === \"medium\"\n  const cell = compact ? 8.5 : 12\n  const grid = (\n    <div aria-hidden=\"true\" className=\"flex flex-col\" style={{ gap: sp(compact ? 1 : 2) }}>\n      <p className=\"font-semibold text-destructive\" style={fs(9.5)}>{MONTHS[month]!.toUpperCase()}</p>\n      <div className=\"grid grid-cols-7 text-center tabular-nums\" style={{ ...fs(compact ? 7.5 : 8.5), rowGap: sp(compact ? 1 : 2) }}>\n        {[\"S\", \"M\", \"T\", \"W\", \"T\", \"F\", \"S\"].map((w, i) => (\n          <span key={i} className=\"text-muted-foreground\">{w}</span>\n        ))}\n        {Array.from({ length: lead }, (_, i) => <span key={`lead-${i}`} />)}\n        {Array.from({ length: days }, (_, i) => (\n          <span\n            key={i}\n            className={cn(\"mx-auto flex items-center justify-center rounded-full font-medium\", i + 1 === day && \"bg-destructive font-bold text-white\")}\n            style={{ width: sp(cell), height: sp(cell) }}\n          >\n            {i + 1}\n          </span>\n        ))}\n      </div>\n    </div>\n  )\n  return (\n    <Widget size={size} label={`Calendar, ${label}`} surfaceClassName=\"bg-background text-foreground\" className={className} {...props}>\n      <div className={cn(\"size-full\", size === \"large\" ? \"flex flex-col\" : size === \"medium\" ? \"grid grid-cols-2\" : \"flex flex-col\")} style={{ padding: sp(size === \"small\" ? 11 : size === \"medium\" ? 9 : 13), gap: sp(10) }}>\n        {size === \"large\" ? (\n          <>\n            {grid}\n            <div className=\"border-t\" style={{ paddingTop: sp(8) }}>\n              <p className=\"sr-only\">{label}</p>\n              <Events events={events} limit={3} />\n            </div>\n          </>\n        ) : (\n          <>\n            <div className=\"flex min-w-0 flex-col\" style={{ gap: sp(4) }}>\n              <p className=\"sr-only\">{label}</p>\n              <p aria-hidden=\"true\" className=\"font-semibold tracking-wide text-destructive uppercase\" style={fs(9.5)}>{weekday}</p>\n              <p aria-hidden=\"true\" className=\"font-light tracking-tight tabular-nums\" style={{ ...fs(size === \"small\" ? 28 : 34), lineHeight: 1 }}>{day}</p>\n              <div className=\"mt-auto\"><Events events={events} limit={1} /></div>\n            </div>\n            {size === \"medium\" && grid}\n          </>\n        )}\n      </div>\n    </Widget>\n  )\n}\n\n// Battery\n\ntype BatteryDevice = {\n  id: string\n  /** Device name. */\n  name: string\n  /** Charge from 0 to 100. */\n  level: number\n  /** Whether it is charging. */\n  charging?: boolean\n  /** Icon for the device. */\n  icon: React.ReactNode\n}\n\ntype BatteryWidgetProps = Omit<WidgetProps, \"label\" | \"children\" | \"surfaceClassName\"> & {\n  /** Devices and their charge. Small shows four, medium four, large lists them all. */\n  devices: BatteryDevice[]\n}\n\nfunction Ring({ device, big }: { device: BatteryDevice; big?: boolean }) {\n  const r = 20\n  const c = 2 * Math.PI * r\n  const level = Math.max(0, Math.min(100, device.level))\n  const low = level <= 20 && !device.charging\n  return (\n    <div aria-hidden=\"true\" className=\"relative flex aspect-square w-full items-center justify-center\">\n      <svg viewBox=\"0 0 48 48\" className=\"absolute inset-0 size-full -rotate-90\">\n        <circle cx=\"24\" cy=\"24\" r={r} fill=\"none\" strokeWidth=\"4.5\" className=\"stroke-foreground/15\" />\n        <circle\n          cx=\"24\"\n          cy=\"24\"\n          r={r}\n          fill=\"none\"\n          strokeWidth=\"4.5\"\n          strokeLinecap=\"round\"\n          strokeDasharray={`${(level / 100) * c} ${c}`}\n          className={cn(low ? \"stroke-destructive\" : \"stroke-[oklch(0.7_0.19_150)]\")}\n        />\n      </svg>\n      <span className=\"flex items-center justify-center text-foreground [&_svg]:size-full\" style={{ width: big ? \"34%\" : \"38%\", height: big ? \"34%\" : \"38%\" }}>\n        {device.icon}\n      </span>\n      {device.charging && (\n        <span className=\"absolute -end-[4%] -bottom-[4%] flex size-[34%] items-center justify-center rounded-full bg-background text-[oklch(0.62_0.19_150)] shadow-sm\">\n          <Zap className=\"size-[70%] fill-current\" />\n        </span>\n      )}\n    </div>\n  )\n}\n\n/** A battery widget: one ring per device, red when low, with a bolt while charging. The large size lists devices with bars. */\nfunction BatteryWidget({ devices, size = \"small\", className, ...props }: BatteryWidgetProps) {\n  const spoken = (d: BatteryDevice) => `${d.name}, ${Math.round(d.level)} percent${d.charging ? \", charging\" : \"\"}`\n  return (\n    <Widget size={size} label=\"Batteries\" surfaceClassName=\"bg-background text-foreground\" className={className} {...props}>\n      <ul className={cn(\"size-full\", size === \"large\" ? \"flex flex-col justify-around\" : size === \"medium\" ? \"grid grid-cols-4 items-center\" : \"grid grid-cols-2 grid-rows-2 items-center\")} style={{ padding: sp(size === \"large\" ? 14 : size === \"small\" ? 9 : 13), gap: sp(size === \"large\" ? 4 : size === \"small\" ? 5 : 10) }}>\n        {devices.slice(0, size === \"large\" ? 6 : 4).map((d) =>\n          size === \"large\" ? (\n            <li key={d.id} className=\"flex items-center\" style={{ gap: sp(10) }}>\n              <span aria-hidden=\"true\" className=\"shrink-0\" style={{ width: sp(30) }}>\n                <Ring device={d} big />\n              </span>\n              <span className=\"min-w-0 flex-1\">\n                <span className=\"sr-only\">{spoken(d)}</span>\n                <span aria-hidden=\"true\" className=\"block truncate font-semibold\" style={fs(11)}>{d.name}</span>\n                <span aria-hidden=\"true\" className=\"mt-[calc(var(--u)*3)] block h-[calc(var(--u)*4)] overflow-hidden rounded-full bg-foreground/15\">\n                  <span className={cn(\"block h-full rounded-full\", d.level <= 20 && !d.charging ? \"bg-destructive\" : \"bg-[oklch(0.7_0.19_150)]\")} style={{ width: `${d.level}%` }} />\n                </span>\n              </span>\n              <span aria-hidden=\"true\" className=\"font-semibold tabular-nums\" style={fs(11)}>{Math.round(d.level)}%</span>\n            </li>\n          ) : (\n            <li key={d.id} className=\"flex flex-col items-center\" style={{ gap: sp(3) }}>\n              <span className=\"sr-only\">{spoken(d)}</span>\n              <span className=\"w-full\" style={{ maxWidth: sp(size === \"small\" ? 24 : 40) }}><Ring device={d} /></span>\n              <span aria-hidden=\"true\" className=\"font-semibold tabular-nums\" style={fs(size === \"small\" ? 9.5 : 10)}>{Math.round(d.level)}%</span>\n            </li>\n          )\n        )}\n      </ul>\n    </Widget>\n  )\n}\n\nexport {\n  Widget,\n  WeatherWidget,\n  CalendarWidget,\n  BatteryWidget,\n  type WidgetProps,\n  type WidgetSize,\n  type WeatherWidgetProps,\n  type WeatherCondition,\n  type HourlyForecast,\n  type DailyForecast,\n  type CalendarWidgetProps,\n  type CalendarEvent,\n  type BatteryWidgetProps,\n  type BatteryDevice,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/widgets.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "widgets",
      "weather",
      "calendar",
      "battery",
      "macos",
      "dashboard",
      "desktop"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<WeatherWidget city temperature condition high low hourly daily size />, <CalendarWidget date=\"2026-10-01\" events size />, <BatteryWidget devices size />. Width is set with className; height follows the size (small square, medium 2:1, large near-square). <Widget> is the bare frame.",
      "whenToUse": [
        "Dashboards that want glanceable cards",
        "Desktop and Today-view mockups"
      ],
      "whenNotToUse": [
        "Interactive charts (chart)",
        "Live data fetching: pass data in"
      ],
      "composesWith": [
        "desktop-icons",
        "phone-frame",
        "control-center"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "Each widget is a named section with a one-sentence summary; decorative icons are hidden"
        },
        {
          "keys": "Color",
          "action": "Charge level and conditions are also given in words"
        }
      ],
      "customization": [
        "size: small | medium | large",
        "className width, e.g. w-48",
        "WeatherWidget: condition clear | partly-cloudy | cloudy | rain | snow | storm | night",
        "BatteryWidget devices with charging"
      ]
    },
    "examples": [
      "widgets-demo",
      "widgets-sizes"
    ],
    "url": "https://ui.ballmac.com/components/widgets"
  },
  "categories": [
    "macos"
  ],
  "type": "registry:ui"
}