{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "desktop-1",
  "title": "Desktop 1: preferences window",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The Settings window of a Tauri or Electron app: toolbar tabs on macOS, a side nav with setting cards on Windows. Launch at login, menu bar, appearance, a global shortcut recorder and update settings that apply as they change.",
  "dependencies": [
    "lucide-react",
    "radix-ui"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/app-window.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/switch.json",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/feel-presets.json",
    "https://ui.ballmac.com/r/shortcut.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/desktop-1/desktop-1.tsx",
      "content": "// Ballmac UI: Desktop 1. https://ui.ballmac.com/blocks/desktop-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Tabs as TabsPrimitive } from \"radix-ui\"\nimport { Keyboard, Palette, RefreshCw, Settings2, SlidersHorizontal } from \"lucide-react\"\n\nimport { AppWindow, AppWindowContent, AppWindowTitleBar } from \"@/components/ballmac/app-window\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useFeel, type FeelId } from \"@/lib/ballmac/feel\"\nimport { formatShortcut, shortcutFromEvent, usesMacKeys } from \"@/lib/ballmac/shortcut\"\nimport { cn } from \"@/lib/utils\"\n\ntype Desktop1Values = {\n  launchAtLogin: boolean\n  showInMenuBar: boolean\n  appearance: \"system\" | \"light\" | \"dark\"\n  density: \"comfortable\" | \"compact\"\n  /** The global shortcut as key names joined with \"+\", for example \"Mod+Shift+Space\". Mod is ⌘ on a Mac and Ctrl elsewhere. */\n  shortcut: string\n  autoCheck: boolean\n  autoInstall: boolean\n  channel: \"stable\" | \"beta\"\n  crashReports: boolean\n}\n\ntype Desktop1Pane = {\n  id: string\n  label: string\n  icon: React.ReactNode\n  content: React.ReactNode\n}\n\ntype Desktop1Props = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** Which platform the window handles like. Defaults to the page's feel. */\n  feel?: FeelId\n  /** Draw the window frame. Turn off inside a real Tauri or Electron window that already has one. */\n  frame?: boolean\n  /** The app name, used in copy such as \"Open Acme at login\". */\n  appName?: string\n  /** The settings when the window opens. */\n  defaultValues?: Desktop1Values\n  /** Controlled settings. Pass with onValuesChange. */\n  values?: Desktop1Values\n  /** Called with every change. Settings apply immediately, as they do in desktop apps: there is no Save button. */\n  onValuesChange?: (values: Desktop1Values) => void\n  /** The pane shown first. */\n  defaultPane?: string\n  /** More panes after the built-in ones, for your own settings. */\n  extraPanes?: Desktop1Pane[]\n  /** The version shown in the Updates pane. */\n  version?: string\n  /** Called by \"Check now\" in the Updates pane. */\n  onCheckForUpdates?: () => void\n  /** Called by \"Reset all settings\" after the built-in values are restored. */\n  onReset?: () => void\n  /** Window controls, passed to App Window. */\n  onClose?: () => void\n  onMinimize?: () => void\n}\n\nconst defaults: Desktop1Values = {\n  launchAtLogin: true,\n  showInMenuBar: true,\n  appearance: \"system\",\n  density: \"comfortable\",\n  shortcut: \"Mod+Shift+Space\",\n  autoCheck: true,\n  autoInstall: false,\n  channel: \"stable\",\n  crashReports: true,\n}\n\n/**\n * A button that records the next key combination. Escape cancels, Backspace or Delete clears. A combination needs at least one\n * modifier, so typing a letter cannot steal it from every other app.\n */\nfunction ShortcutRecorder({ value, onChange, feel, labelledBy }: { value: string; onChange: (v: string) => void; feel: FeelId; labelledBy: string }) {\n  const [recording, setRecording] = React.useState(false)\n  const mac = usesMacKeys(feel)\n  return (\n    <div className=\"flex items-center gap-2\">\n      <button\n        type=\"button\"\n        data-slot=\"desktop-1-shortcut\"\n        aria-labelledby={labelledBy}\n        aria-describedby={`${labelledBy}-hint`}\n        aria-pressed={recording}\n        onClick={() => setRecording((r) => !r)}\n        onBlur={() => setRecording(false)}\n        onKeyDown={(e) => {\n          if (!recording) return\n          if (e.key === \"Tab\") return setRecording(false)\n          e.preventDefault()\n          if (e.key === \"Escape\") return setRecording(false)\n          if (e.key === \"Backspace\" || e.key === \"Delete\") {\n            onChange(\"\")\n            return setRecording(false)\n          }\n          const next = shortcutFromEvent(e, mac)\n          if (next) {\n            onChange(next)\n            setRecording(false)\n          }\n        }}\n        className={cn(\n          \"border-input bg-background inline-flex h-[var(--bm-control-h-sm,calc(var(--spacing)*8))] min-w-32 items-center justify-center rounded-md border px-3 font-mono text-[13px] tabular-nums outline-none\",\n          \"focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n          recording && \"border-ring ring-ring/30 text-muted-foreground ring-[3px]\"\n        )}\n      >\n        <span dir=\"ltr\">{recording ? \"Type a shortcut…\" : formatShortcut(value, feel) || \"Record shortcut\"}</span>\n      </button>\n      <span id={`${labelledBy}-hint`} className=\"sr-only\">\n        {recording ? \"Press the new key combination. Escape cancels, Delete clears.\" : \"Press to record a new shortcut.\"}\n      </span>\n      {value && !recording ? (\n        <Button variant=\"ghost\" size=\"sm\" onClick={() => onChange(\"\")}>\n          Clear\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\nconst FeelContext = React.createContext<FeelId>(\"web\")\n\n/** A group of setting rows. Windows draws each row as its own card; macOS, iOS and the web group them in one rounded box. */\nfunction Group({ title, children }: { title?: string; children: React.ReactNode }) {\n  const feel = React.useContext(FeelContext)\n  const id = React.useId()\n  return (\n    <section aria-labelledby={title ? id : undefined} className=\"grid gap-2\">\n      {title ? (\n        <h3 id={id} className={cn(\"text-sm font-semibold\", feel === \"ios\" && \"text-muted-foreground px-4 text-xs font-normal uppercase\")}>\n          {title}\n        </h3>\n      ) : null}\n      <div className={cn(feel === \"windows\" ? \"grid gap-1\" : \"bg-card divide-border divide-y overflow-hidden rounded-lg border\", feel === \"macos\" && \"bg-muted/40\")}>{children}</div>\n    </section>\n  )\n}\n\n/** One setting: its name and an optional line of help on the start side, the control on the end side. */\nfunction Row({ label, description, children, id: idProp }: { label: string; description?: string; children: (id: string) => React.ReactNode; id?: string }) {\n  const feel = React.useContext(FeelContext)\n  const auto = React.useId()\n  const id = idProp ?? auto\n  return (\n    <div\n      data-slot=\"desktop-1-row\"\n      className={cn(\"flex flex-wrap items-center justify-between gap-x-6 gap-y-2\", feel === \"windows\" ? \"bg-card rounded-md border px-4 py-3.5\" : \"px-4 py-3\")}\n    >\n      <div className=\"min-w-0 flex-1 basis-48\">\n        <p id={id} className=\"text-sm\">\n          {label}\n        </p>\n        {description ? (\n          <p id={`${id}-desc`} className=\"text-muted-foreground mt-0.5 text-xs text-pretty\">\n            {description}\n          </p>\n        ) : null}\n      </div>\n      <div className=\"flex shrink-0 items-center\">{children(id)}</div>\n    </div>\n  )\n}\n\nfunction Desktop1({\n  feel: feelProp,\n  frame = true,\n  appName = \"Acme\",\n  defaultValues = defaults,\n  values: valuesProp,\n  onValuesChange,\n  defaultPane = \"general\",\n  extraPanes = [],\n  version = \"2.4.1\",\n  onCheckForUpdates,\n  onReset,\n  onClose,\n  onMinimize,\n  className,\n  ...props\n}: Desktop1Props) {\n  const page = useFeel().feel\n  const feel = feelProp ?? page\n  const dir = useDirection()\n  const [internal, setInternal] = React.useState(defaultValues)\n  const v = valuesProp ?? internal\n  const [pane, setPane] = React.useState(defaultPane)\n  const set = <K extends keyof Desktop1Values>(k: K, value: Desktop1Values[K]) => {\n    const next = { ...v, [k]: value }\n    if (!valuesProp) setInternal(next)\n    onValuesChange?.(next)\n  }\n  const sideNav = feel === \"windows\"\n  const settingsApp = feel === \"macos\" ? \"System Settings\" : feel === \"windows\" ? \"Settings\" : \"settings\"\n\n  const panes: Desktop1Pane[] = [\n    {\n      id: \"general\",\n      label: \"General\",\n      icon: <Settings2 />,\n      content: (\n        <>\n          <Group title=\"Startup\">\n            <Row label={`Open ${appName} at login`} description={`${appName} starts in the background when you sign in to this ${feel === \"macos\" ? \"Mac\" : \"computer\"}.`}>\n              {(id) => <Switch checked={v.launchAtLogin} onCheckedChange={(c) => set(\"launchAtLogin\", c)} aria-labelledby={id} aria-describedby={`${id}-desc`} />}\n            </Row>\n            <Row label={feel === \"windows\" ? \"Show in the system tray\" : \"Show in the menu bar\"} description=\"Quick access without opening the main window.\">\n              {(id) => <Switch checked={v.showInMenuBar} onCheckedChange={(c) => set(\"showInMenuBar\", c)} aria-labelledby={id} aria-describedby={`${id}-desc`} />}\n            </Row>\n          </Group>\n          <Group title=\"Privacy\">\n            <Row label=\"Send crash reports\" description=\"Anonymous reports help fix problems. They never include your files.\">\n              {(id) => <Switch checked={v.crashReports} onCheckedChange={(c) => set(\"crashReports\", c)} aria-labelledby={id} aria-describedby={`${id}-desc`} />}\n            </Row>\n          </Group>\n        </>\n      ),\n    },\n    {\n      id: \"appearance\",\n      label: \"Appearance\",\n      icon: <Palette />,\n      content: (\n        <Group title=\"Look\">\n          <Row label=\"Appearance\" description={`System follows the light or dark setting in ${settingsApp}.`}>\n            {(id) => (\n              <SegmentedControl value={v.appearance} onValueChange={(a) => set(\"appearance\", a as Desktop1Values[\"appearance\"])} aria-labelledby={id} size=\"sm\">\n                <SegmentedControlItem value=\"system\">System</SegmentedControlItem>\n                <SegmentedControlItem value=\"light\">Light</SegmentedControlItem>\n                <SegmentedControlItem value=\"dark\">Dark</SegmentedControlItem>\n              </SegmentedControl>\n            )}\n          </Row>\n          <Row label=\"Density\">\n            {(id) => (\n              <Select value={v.density} onValueChange={(d) => set(\"density\", d as Desktop1Values[\"density\"])}>\n                <SelectTrigger size=\"sm\" className=\"w-36\" aria-labelledby={id}>\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"comfortable\">Comfortable</SelectItem>\n                  <SelectItem value=\"compact\">Compact</SelectItem>\n                </SelectContent>\n              </Select>\n            )}\n          </Row>\n        </Group>\n      ),\n    },\n    {\n      id: \"shortcuts\",\n      label: \"Shortcuts\",\n      icon: <Keyboard />,\n      content: (\n        <Group title=\"Global\">\n          <Row label={`Show ${appName}`} description=\"Works from any app. Use at least one modifier key.\">\n            {(id) => <ShortcutRecorder value={v.shortcut} onChange={(s) => set(\"shortcut\", s)} feel={feel} labelledBy={id} />}\n          </Row>\n        </Group>\n      ),\n    },\n    {\n      id: \"updates\",\n      label: \"Updates\",\n      icon: <RefreshCw />,\n      content: (\n        <>\n          <Group>\n            <Row label={`${appName} ${version}`} description={v.autoCheck ? \"Checks for updates once a day.\" : \"Automatic checks are off.\"}>\n              {() => (\n                <Button variant=\"outline\" size=\"sm\" onClick={onCheckForUpdates}>\n                  Check now\n                </Button>\n              )}\n            </Row>\n          </Group>\n          <Group title=\"Automatic updates\">\n            <Row label=\"Check for updates automatically\">\n              {(id) => <Switch checked={v.autoCheck} onCheckedChange={(c) => set(\"autoCheck\", c)} aria-labelledby={id} />}\n            </Row>\n            <Row label=\"Download and install in the background\" description=\"The update is applied the next time you quit.\">\n              {(id) => <Switch checked={v.autoInstall} disabled={!v.autoCheck} onCheckedChange={(c) => set(\"autoInstall\", c)} aria-labelledby={id} aria-describedby={`${id}-desc`} />}\n            </Row>\n            <Row label=\"Update channel\" description=\"Beta builds arrive earlier and may have rough edges.\">\n              {(id) => (\n                <SegmentedControl value={v.channel} onValueChange={(c) => set(\"channel\", c as Desktop1Values[\"channel\"])} aria-labelledby={id} size=\"sm\">\n                  <SegmentedControlItem value=\"stable\">Stable</SegmentedControlItem>\n                  <SegmentedControlItem value=\"beta\">Beta</SegmentedControlItem>\n                </SegmentedControl>\n              )}\n            </Row>\n          </Group>\n        </>\n      ),\n    },\n    {\n      id: \"advanced\",\n      label: \"Advanced\",\n      icon: <SlidersHorizontal />,\n      content: (\n        <Group title=\"Reset\">\n          <Row label=\"Reset all settings\" description=\"Puts every setting back to how it was when you installed the app. Your data is kept.\">\n            {() => (\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => {\n                  if (!valuesProp) setInternal(defaults)\n                  onValuesChange?.(defaults)\n                  onReset?.()\n                }}\n              >\n                Reset…\n              </Button>\n            )}\n          </Row>\n        </Group>\n      ),\n    },\n    ...extraPanes,\n  ]\n  const current = panes.find((p) => p.id === pane) ?? panes[0]!\n  // macOS names the window after the pane, as System Settings and every Settings window do; Windows keeps \"Settings\"\n  const title = feel === \"macos\" ? current.label : \"Settings\"\n\n  const tabs = (\n    <TabsPrimitive.Root\n      value={current.id}\n      onValueChange={setPane}\n      orientation={sideNav ? \"vertical\" : \"horizontal\"}\n      dir={dir}\n      className={cn(\"flex min-h-0 flex-1\", sideNav ? \"flex-col sm:flex-row\" : \"flex-col\")}\n    >\n      <TabsPrimitive.List\n        aria-label=\"Settings\"\n        data-slot=\"desktop-1-tabs\"\n        className={cn(\n          \"flex shrink-0\",\n          sideNav\n            ? \"gap-0.5 overflow-x-auto p-2 sm:w-52 sm:flex-col sm:overflow-visible sm:p-3\"\n            : \"justify-center gap-1 overflow-x-auto border-b px-2 py-1.5\"\n        )}\n      >\n        {panes.map((p) => (\n          <TabsPrimitive.Trigger\n            key={p.id}\n            value={p.id}\n            className={cn(\n              \"text-muted-foreground data-[state=active]:text-foreground relative flex shrink-0 items-center rounded-md outline-none [&_svg]:shrink-0\",\n              \"focus-visible:ring-[3px] focus-visible:ring-ring/50 transition-colors duration-150\",\n              sideNav\n                ? \"hover:bg-foreground/[0.05] data-[state=active]:bg-foreground/[0.06] h-9 gap-3 px-3 text-sm [&_svg]:size-4 sm:w-full\"\n                : \"hover:bg-foreground/[0.05] data-[state=active]:bg-foreground/[0.07] min-w-16 flex-col gap-1 px-2.5 py-1.5 text-[11px] [&_svg]:size-5\"\n            )}\n          >\n            {sideNav ? (\n              <span aria-hidden=\"true\" className=\"bg-primary absolute inset-y-2 start-0 hidden w-[3px] rounded-full [[data-state=active]>&]:block\" />\n            ) : null}\n            <span aria-hidden=\"true\" className={cn(!sideNav && \"[[data-state=active]>&]:text-primary\")}>\n              {p.icon}\n            </span>\n            {p.label}\n          </TabsPrimitive.Trigger>\n        ))}\n      </TabsPrimitive.List>\n      {panes.map((p) => (\n        <TabsPrimitive.Content key={p.id} value={p.id} className=\"min-h-0 flex-1 overflow-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\">\n          <div className={cn(\"mx-auto grid w-full max-w-2xl gap-6\", sideNav ? \"p-4 sm:p-6\" : \"p-5\")}>\n            {sideNav ? <h2 className=\"text-xl font-semibold tracking-tight\">{p.label}</h2> : null}\n            {p.content}\n          </div>\n        </TabsPrimitive.Content>\n      ))}\n    </TabsPrimitive.Root>\n  )\n\n  const body = <FeelContext.Provider value={feel}>{tabs}</FeelContext.Provider>\n  if (!frame)\n    return (\n      <div data-slot=\"desktop-1\" data-feel={feelProp} className={cn(\"flex min-h-0 flex-col\", className)} {...props}>\n        {body}\n      </div>\n    )\n  return (\n    <AppWindow data-slot=\"desktop-1\" feel={feel} onClose={onClose} onMinimize={onMinimize} className={cn(\"h-[30rem] w-full max-w-3xl\", className)} {...props}>\n      <AppWindowTitleBar title={title} icon={<Settings2 />} className={cn(!sideNav && \"border-b-0\")} />\n      <AppWindowContent className=\"flex flex-col overflow-hidden\">{body}</AppWindowContent>\n    </AppWindow>\n  )\n}\n\nexport { Desktop1, formatShortcut, type Desktop1Props, type Desktop1Values, type Desktop1Pane }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/desktop-1/desktop-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "desktop",
      "preferences",
      "settings",
      "tauri",
      "electron",
      "macos",
      "windows",
      "shortcut",
      "hotkey",
      "feel"
    ],
    "version": "1.0.1",
    "updated": "2026-10-17",
    "changelog": [
      {
        "version": "1.0.1",
        "date": "2026-10-17",
        "note": "The shortcut formatting and recording moved into the shortcut lib item, which this block now installs. formatShortcut is still exported from here; behaviour is unchanged.",
        "breaking": false
      },
      {
        "version": "1.0.0",
        "date": "2026-10-17",
        "note": "First release.",
        "breaking": false
      }
    ],
    "tests": true,
    "ai": {
      "summary": "A complete preferences window. <Desktop1 appName defaultValues onValuesChange={(v) => persist(v)} onCheckForUpdates />. Panes: General, Appearance, Shortcuts (a key-combination recorder that writes ⇧⌘Space on a Mac and Ctrl+Shift+Space on Windows), Updates, Advanced, plus extraPanes. Layout follows the feel: toolbar tabs and a pane-named title on macOS, side nav and setting cards on Windows.",
      "whenToUse": [
        "The Settings or Preferences window of a Tauri or Electron app",
        "A desktop-style web app that needs app-level preferences"
      ],
      "whenNotToUse": [
        "Account settings saved with a button (use settings-1)",
        "A settings page inside a web app's layout (use settings-2 or settings-panel)"
      ],
      "composesWith": [
        "app-window",
        "feel-auto",
        "desktop-3",
        "desktop-6"
      ],
      "a11y": [
        {
          "keys": "Arrow keys in the tabs",
          "action": "Move between panes (left and right on macOS, up and down in the Windows side nav); right-to-left swaps them"
        },
        {
          "keys": "Enter on the shortcut field",
          "action": "Starts recording; the next combination with a modifier is saved, Escape cancels, Delete clears"
        },
        {
          "keys": "Tab",
          "action": "Every switch, segmented control and select is labelled by its row"
        }
      ],
      "customization": [
        "feel: 'macos' | 'windows' | 'ios' | 'web' (defaults to the page's feel)",
        "frame={false} drops the window chrome inside a real native window",
        "values + onValuesChange to control it; shortcut strings use Mod for ⌘ or Ctrl, e.g. 'Mod+Shift+Space'",
        "extraPanes=[{ id, label, icon, content }]; formatShortcut(shortcut, feel) is exported for your menus",
        "Tauri: pass the shortcut to @tauri-apps/plugin-global-shortcut (replace Mod with CommandOrControl)"
      ]
    },
    "statesNone": "Settings apply the moment they change, as desktop apps do: nothing to load, submit or fail.",
    "examples": [
      "desktop-1-demo",
      "desktop-1-windows",
      "desktop-1-custom"
    ],
    "url": "https://ui.ballmac.com/components/desktop-1"
  },
  "categories": [
    "blocks",
    "desktop"
  ],
  "type": "registry:block"
}