Skill: paranoidearth/widget (version 0.1.0)

--- name: widget description: Create, update, hide, show, list, and delete Übersicht desktop widgets on macOS. Use this skill whenever the user asks for desktop widgets, desktop gadgets, or widgets. argument-hint: "[operation, e.g. add a clock / hide the pomodoro / list all widgets]" allowed-tools: Bash(bash *), Bash(cp *), Bash(mv *), Bash(rm *), Bash(ls *), Write, Edit --- # WidgetDesk Skill ## Environment - Widget directory: `~/Library/Application Support/Übersicht/widgets/` - Template directory: `~/.claude/skills/widget/templates/` after install, or `.claude/skills/widget/templates/` inside this repo - Host requirement: Übersicht is installed and available at `/Applications/Übersicht.app` or `/Applications/Uebersicht.app` ## First Step - When working inside a WidgetDesk repo clone, run `bash scripts/setup.sh` first - `scripts/setup.sh` is the default entrypoint: it installs missing host dependencies, starts Übersicht, prepares the widget directory, installs the skill, and verifies the result - Use `bash scripts/setup.sh --check` only when the user explicitly wants a dry-run check or when you are diagnosing an installation problem - Use `bash ~/.claude/skills/widget/scripts/doctor.sh` only for a fast post-install health check - After setup, use the installed skill path under `~/.claude/skills/widget/` ## Reference Files - Reusable implementation patterns: [patterns.md](patterns.md) - Host management scripts: `scripts/` for setup, starting Übersicht, checking the environment, installing widgets, and listing widgets --- ## Hard Constraints ### 1. Layout - All widgets should default to `position: fixed` - Any bottom-aligned widget must keep `bottom >= 90px` - Default edge spacing is `40px` - Default width should stay within `140px` to `360px` - Default height should stay within `48px` to `220px` - Only exceed these dimensions when the user explicitly asks for a large widget ### 2. Interaction - Display-only widgets should default to `pointer-events: none` - Only enable interaction when the widget truly needs clicking, dragging, or text input - Interactive controls must stay easy to hit - Avoid complex multi-step desktop interactions by default ### 3. Refresh - Command-driven widgets should normally refresh between `1000ms` and `600000ms` - Refresh below `1000ms` only for clocks or clearly time-sensitive UI - Pure frontend widgets should use `refreshFrequency = false` - Avoid high-frequency network requests ### 4. Implementation - Use lowercase kebab-case filenames - Prefer existing templates and `patterns.md` before inventing new structure - Prefer built-in macOS capabilities over extra dependencies - Do not hardcode secrets - Keep widgets single-file by default unless the user explicitly asks for more complexity ### 5. Visual Style - Keep the style consistent, restrained, and macOS-like - Default to dark translucent cards - Recommended corner radius: `14px` to `20px` - Prefer `SF Pro Display` and `SF Mono` - Keep motion short, light, and purposeful - Do not invent a brand-new visual language for every widget --- ## Operations ```bash # First-time setup inside this repo bash scripts/setup.sh bash scripts/setup.sh --check # Fast post-install health check bash ~/.claude/skills/widget/scripts/doctor.sh # Start Übersicht bash ~/.claude/skills/widget/scripts/start-uebersicht.sh # Install or update a template widget bash ~/.claude/skills/widget/scripts/install-widget.sh \ ~/.claude/skills/widget/templates/clock.jsx # List installed widgets bash ~/.claude/skills/widget/scripts/list-widgets.sh # Write a brand-new custom widget cat > ~/Library/Application\ Support/Übersicht/widgets/{name}.jsx << 'EOF' {widget_code} EOF # Hide a widget without deleting it mv ~/Library/Application\ Support/Übersicht/widgets/{name}.jsx \ ~/Library/Application\ Support/Übersicht/widgets/{name}.jsx.disabled # Show a hidden widget mv ~/Library/Application\ Support/Übersicht/widgets/{name}.jsx.disabled \ ~/Library/Application\ Support/Übersicht/widgets/{name}.jsx # Delete a widget rm ~/Library/Application\ Support/Übersicht/widgets/{name}.jsx ``` Prefer the `scripts/` helpers for host operations. Only write raw widget files directly when creating or replacing actual JSX content. When the user asks for a standard widget that already exists as a built-in template, do not rewrite it from scratch. Run `scripts/setup.sh` if needed, then install the matching template. Do not install or copy any widget file until `scripts/setup.sh` has completed successfully and the host app is available. --- ## Widget Format ```jsx // Optional shell command. stdout is passed into render as output. export const command = "date '+%H:%M:%S'" // Refresh frequency in milliseconds. Pure frontend widgets should use false. export const refreshFrequency = 1000 // CSS positioning. Use position: fixed. export const className = ` position: fixed; bottom: 90px; right: 40px; ` // render receives { output, error } export const render = ({ output }) => { return <div>{output?.trim()}</div> } ``` --- ## Required Rules ### Rule 1: Never import React from `react` ```jsx // Bad import { useState } from 'react' // Good import { React } from 'uebersicht' ``` ### Rule 2: Never call hooks directly inside `render` ```jsx // Bad export const render = () => { const [n, setN] = React.useState(0) } // Good const Widget = () => { const { useState } = React const [n, setN] = useState(0) return <div>{n}</div> } export const render = () => <Widget /> ``` ### Rule 3: Never return a function from a state updater ```jsx // Bad setRemaining(r => { if (r <= 1) return p => p === 'work' ? BREAK : WORK }) // Good useEffect(() => { if (remaining !== 0) return setPhase(p => p === 'work' ? 'break' : 'work') setRemaining(p => p === 'work' ? BREAK : WORK) }, [remaining]) ``` --- ## Position Cheatsheet | Position | CSS | |----------|-----| | Bottom right | `bottom: 90px; right: 40px;` | | Bottom left | `bottom: 90px; left: 40px;` | | Top right | `top: 40px; right: 40px;` | | Top left | `top: 40px; left: 40px;` | --- ## Built-In Templates | File | Purpose | Default Position | |------|---------|------------------| | `clock.jsx` | Clock and date | Bottom right | | `horizon-clock.jsx` | Alternate horizontal clock | Bottom right | | `pomodoro.jsx` | Pomodoro timer | Bottom left | | `now-playing.jsx` | Apple Music now playing | Bottom center | | `system-stats.jsx` | CPU, memory, battery | Top right | | `weather-canvas.jsx` | Animated weather card | Top left | | `git-pulse.jsx` | Local Git activity heatmap | Top right | | `memo-capsule.jsx` | Local quick-note capsule | Top center | | `volume-knob.jsx` | System volume control knob | Right side | | `tap-counter.jsx` | Simple interactive counter with persisted local state | Bottom right | Copy a template directly when it already matches the request, or use it as the starting point for a custom widget. --- ## Style Baseline ```css background: rgba(8, 12, 20, 0.72); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 18px; border: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35); font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; color: rgba(255, 255, 255, 0.92); ``` Add this for display-only widgets: ```css pointer-events: none; ``` --- ## Useful macOS Shell Commands ```bash date '+%H:%M:%S' date '+%Y年%-m月%-d日 %A' pmset -g batt | grep -o '[0-9]*%' | head -1 top -l 1 | grep "CPU usage" | awk '{print $3}' curl -s "wttr.in/?format=%t+%C" ``` Remember to `.trim()` command output before rendering it.

path: patterns.md
content: # Widget 可复用模式

生成 widget 时，按需引入以下模式。每个模式自包含，直接内联到 widget 文件中。

---

## 1. 可拖拽定位

让 widget 可被鼠标拖拽移动，位置自动存入 localStorage。

**使用场景**：用户希望自由调整 widget 位置

**要点**：
- 使用 `useRef` 获取 DOM 元素
- 纯 DOM 事件实现拖拽（mousedown/mousemove/mouseup）
- `className` 中不要设 `bottom/top/left/right`，由组件内 state 控制
- 必须移除 `pointer-events: none`

```jsx
import { React } from 'uebersicht'

export const refreshFrequency = false

export const className = `
  position: fixed;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
`

const WIDGET_ID = 'my-widget'

const DraggableWidget = () => {
  const { useState, useEffect, useRef, useCallback } = React
  const storageKey = `oc-pos-${WIDGET_ID}`
  const saved = JSON.parse(localStorage.getItem(storageKey) || 'null')
  const [pos, setPos] = useState(saved || { x: 200, y: 200 })
  const dragging = useRef(false)
  const offset = useRef({ x: 0, y: 0 })

  const onMouseDown = useCallback((e) => {
    dragging.current = true
    offset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }
    e.preventDefault()
  }, [pos])

  useEffect(() => {
    const onMove = (e) => {
      if (!dragging.current) return
      const next = { x: e.clientX - offset.current.x, y: e.clientY - offset.current.y }
      setPos(next)
      localStorage.setItem(storageKey, JSON.stringify(next))
    }
    const onUp = () => { dragging.current = false }
    window.addEventListener('mousemove', onMove)
    window.addEventListener('mouseup', onUp)
    return () => {
      window.removeEventListener('mousemove', onMove)
      window.removeEventListener('mouseup', onUp)
    }
  }, [])

  return (
    <div
      onMouseDown={onMouseDown}
      style={{
        position: 'fixed',
        left: pos.x,
        top: pos.y,
        cursor: 'grab',
        userSelect: 'none',
      }}
    >
      {/* widget 内容 */}
    </div>
  )
}

export const render = () => <DraggableWidget />
```

---

## 2. 状态持久化（localStorage）

widget 被 Übersicht 重新 mount 时恢复之前的状态，防止状态丢失。

**使用场景**：计时器、计数器、待办列表等需要持久状态的 widget

```jsx
// 封装 hook：用法和 useState 一致，自动读写 localStorage
const usePersistedState = (key, initial) => {
  const { useState, useEffect } = React
  const storageKey = `oc-${key}`
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(storageKey)
    return saved !== null ? JSON.parse(saved) : initial
  })

  useEffect(() => {
    localStorage.setItem(storageKey, JSON.stringify(value))
  }, [value])

  return [value, setValue]
}

// 用法示例
const Widget = () => {
  const [count, setCount] = usePersistedState('counter', 0)
  return <div onClick={() => setCount(c => c + 1)}>{count}</div>
}
```

---

## 3. Shell 输出安全解析

command 的 stdout 可能包含换行、空值、特殊字符。统一用分隔符 + 安全解析。

**使用场景**：所有使用 `command` 获取数据的 widget

```jsx
// command 中用 ||| 作分隔符
export const command = `
  echo "field1:$(some_command)"
  echo "field2:$(other_command)"
`

// 安全解析函数
const parseOutput = (raw, fields) => {
  const result = {}
  fields.forEach(f => { result[f] = '?' })
  if (!raw) return result
  raw.trim().split('\n').forEach(line => {
    const idx = line.indexOf(':')
    if (idx > -1) {
      const key = line.slice(0, idx).trim()
      const val = line.slice(idx + 1).trim()
      if (fields.includes(key)) result[key] = val
    }
  })
  return result
}

// 用法
export const render = ({ output }) => {
  const data = parseOutput(output, ['field1', 'field2'])
  return <div>{data.field1}</div>
}
```

---

## 4. 条件渲染 + 占位符

command 首次执行前 output 为空，避免闪烁或显示错误。

**使用场景**：所有 command-based widget

```jsx
export const render = ({ output, error }) => {
  if (error) return <div style={{ color: 'rgba(255,80,80,0.8)' }}>Error</div>
  if (!output) return <div style={{ opacity: 0.3 }}>Loading...</div>

  // 正常渲染
  return <div>{output.trim()}</div>
}
```

---


path: scripts/doctor.sh
content: #!/bin/bash
set -euo pipefail

find_uebersicht_app() {
  local app
  for app in /Applications/*bersicht.app; do
    if [ -d "$app" ]; then
      echo "$app"
      return 0
    fi
  done
  return 1
}

APP_PATH="$(find_uebersicht_app || true)"
WIDGET_DIR="$HOME/Library/Application Support/Übersicht/widgets"

if [ -n "$APP_PATH" ]; then
  echo "OK   app        $APP_PATH"
else
  echo "FAIL app        Übersicht.app not found"
fi

if [ -d "$WIDGET_DIR" ]; then
  echo "OK   widgets    $WIDGET_DIR"
else
  echo "FAIL widgets    $WIDGET_DIR"
fi

if [ -n "$APP_PATH" ] && [ -d "$WIDGET_DIR" ]; then
  exit 0
fi

exit 1


path: scripts/install-widget.sh
content: #!/bin/bash
set -euo pipefail

find_uebersicht_app() {
  local app
  for app in /Applications/*bersicht.app; do
    if [ -d "$app" ]; then
      echo "$app"
      return 0
    fi
  done
  return 1
}

if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
  echo "Usage: $0 <source.jsx> [target-name.jsx]" >&2
  exit 1
fi

SOURCE_FILE="$1"
TARGET_NAME="${2:-$(basename "$SOURCE_FILE")}"
WIDGET_DIR="$HOME/Library/Application Support/Übersicht/widgets"
APP_PATH="$(find_uebersicht_app || true)"

if [ ! -f "$SOURCE_FILE" ]; then
  echo "Source widget not found: $SOURCE_FILE" >&2
  exit 1
fi

if [ -z "$APP_PATH" ]; then
  echo "Übersicht.app not found. Run bash scripts/setup.sh first." >&2
  exit 1
fi

if [ ! -d "$WIDGET_DIR" ]; then
  echo "Widget directory not found: $WIDGET_DIR" >&2
  echo "Run bash scripts/setup.sh first so Übersicht can create the widget directory." >&2
  exit 1
fi

cp "$SOURCE_FILE" "$WIDGET_DIR/$TARGET_NAME"
echo "Installed: $WIDGET_DIR/$TARGET_NAME"


path: scripts/list-widgets.sh
content: #!/bin/bash
set -euo pipefail

WIDGET_DIR="$HOME/Library/Application Support/Übersicht/widgets"

if [ ! -d "$WIDGET_DIR" ]; then
  echo "Widget directory not found: $WIDGET_DIR" >&2
  exit 1
fi

find "$WIDGET_DIR" -maxdepth 1 -type f \( -name '*.jsx' -o -name '*.jsx.disabled' \) \
  | sort


path: scripts/setup.sh
content: #!/bin/bash
set -euo pipefail

GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
CHECK_ONLY=0

if [ "${1:-}" = "--check" ] || [ "${1:-}" = "--doctor" ]; then
  CHECK_ONLY=1
fi

find_uebersicht_app() {
  local app
  for app in /Applications/*bersicht.app; do
    if [ -d "$app" ]; then
      echo "$app"
      return 0
    fi
  done
  return 1
}

require_brew() {
  if command -v brew >/dev/null 2>&1; then
    return 0
  fi
  echo -e "${RED}✗${NC} 未找到 Homebrew，请先安装：https://brew.sh"
  exit 1
}

get_release_metadata() {
  require_brew
  brew info --cask --json=v2 ubersicht | python3 -c '
import json, sys
data = json.load(sys.stdin)["casks"][0]
print(data["url"])
print(data["sha256"])
print(data["version"])
'
}

verify_sha256() {
  local file="$1"
  local expected="$2"
  local actual
  actual="$(shasum -a 256 "$file" | awk "{print \$1}")"
  [ "$actual" = "$expected" ]
}

install_uebersicht_app() {
  local metadata url sha256 version
  metadata="$(get_release_metadata)"
  url="$(printf '%s\n' "$metadata" | sed -n '1p')"
  sha256="$(printf '%s\n' "$metadata" | sed -n '2p')"
  version="$(printf '%s\n' "$metadata" | sed -n '3p')"
  local widgetdesk_cache="$HOME/Library/Caches/widgetdesk"
  local homebrew_cache="$HOME/Library/Caches/Homebrew/downloads"
  local zip_path="$widgetdesk_cache/Uebersicht-${version}.app.zip"
  local cached_homebrew_zip=""
  local tmpdir extracted_app dest_app

  mkdir -p "$widgetdesk_cache"

  cached_homebrew_zip="$(find "$homebrew_cache" -maxdepth 1 -type f -name "*--Uebersicht-${version}.app.zip" 2>/dev/null | head -1 || true)"
  if [ -n "$cached_homebrew_zip" ] && verify_sha256 "$cached_homebrew_zip" "$sha256"; then
    zip_path="$cached_homebrew_zip"
    echo -e "${GREEN}✓${NC} 复用已下载的 Übersicht 安装包"
  else
    if [ ! -f "$zip_path" ] || ! verify_sha256 "$zip_path" "$sha256"; then
      echo -e "${YELLOW}→${NC} 正在下载 Übersicht ${version}..."
      curl -fL "$url" -o "$zip_path"
    fi
    if ! verify_sha256 "$zip_path" "$sha256"; then
      echo -e "${RED}✗${NC} Übersicht 安装包校验失败"
      exit 1
    fi
  fi

  tmpdir="$(mktemp -d)"
  trap 'rm -rf "$tmpdir"' RETURN
  ditto -x -k "$zip_path" "$tmpdir"
  extracted_app="$(find "$tmpdir" -maxdepth 2 -type d -name '*bersicht.app' | head -1 || true)"

  if [ -z "$extracted_app" ]; then
    echo -e "${RED}✗${NC} 无法从安装包中找到 Übersicht.app"
    exit 1
  fi

  dest_app="/Applications/$(basename "$extracted_app")"
  echo -e "${YELLOW}→${NC} 正在安装 $(basename "$extracted_app") 到 /Applications..."
  ditto "$extracted_app" "$dest_app"
  xattr -dr com.apple.quarantine "$dest_app" 2>/dev/null || true
  echo -e "${GREEN}✓${NC} Übersicht 已安装 → $dest_app"
}

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TARGET_SKILL_DIR="$HOME/.claude/skills/widget"
INSTALLED_SKILL_FILE="$TARGET_SKILL_DIR/SKILL.md"

echo ""
echo "WidgetDesk Skill Setup (Claude Code)"
echo "===================================="
echo ""

if [ "$CHECK_ONLY" -eq 1 ]; then
  echo "运行模式：只检查，不做安装"
else
  echo "运行模式：安装或修复依赖，并在结束时验证环境"
  echo ""
fi

APP_PATH="$(find_uebersicht_app || true)"
if [ -n "$APP_PATH" ]; then
  echo -e "${GREEN}✓${NC} Übersicht 已安装"
else
  if [ "$CHECK_ONLY" -eq 1 ]; then
    echo -e "${RED}✗${NC} 未检测到 Übersicht"
    echo "  运行：bash scripts/setup.sh"
    exit 1
  fi
  echo -e "${YELLOW}→${NC} 正在安装 Übersicht..."
  install_uebersicht_app
  APP_PATH="$(find_uebersicht_app || true)"
  if [ -z "$APP_PATH" ]; then
    echo -e "${RED}✗${NC} 未找到 Übersicht.app，请确认安装是否成功"
    exit 1
  fi
  open "$APP_PATH"
  echo -e "${GREEN}✓${NC} Übersicht 安装完成"
fi

WIDGET_DIR="$HOME/Library/Application Support/Übersicht/widgets"
if [ ! -d "$WIDGET_DIR" ]; then
  if [ "$CHECK_ONLY" -eq 1 ]; then
    echo -e "${RED}✗${NC} 未找到 Widget 目录：$WIDGET_DIR"
    echo "  请先手动打开 Übersicht 一次"
    exit 1
  fi
  echo -e "${YELLOW}→${NC} 正在启动 Übersicht 以创建 widget 目录..."
  open "$APP_PATH"
  for _ in 1 2 3 4 5; do
    if [ -d "$WIDGET_DIR" ]; then
      break
    fi
    sleep 1
  done
fi
if [ ! -d "$WIDGET_DIR" ]; then
  echo -e "${RED}✗${NC} 未找到 Widget 目录：$WIDGET_DIR"
  echo "  请先手动打开 Übersicht 一次，然后重新运行 scripts/setup.sh"
  exit 1
fi
echo -e "${GREEN}✓${NC} Widget 目录：$WIDGET_DIR"

if [ ! -f "$SOURCE_SKILL_DIR/SKILL.md" ]; then
  echo -e "${RED}✗${NC} 未找到 skill 文件：$SOURCE_SKILL_DIR/SKILL.md"
  exit 1
fi

if [ "$CHECK_ONLY" -eq 1 ]; then
  echo -e "${GREEN}✓${NC} 项目内 skill 文件完整"
  if [ -f "$INSTALLED_SKILL_FILE" ]; then
    echo -e "${GREEN}✓${NC} 已检测到已安装的 Claude Code skill"
  else
    echo -e "${YELLOW}→${NC} 尚未安装 Claude Code skill 到 ~/.claude/skills/widget/"
  fi
  echo ""
  echo "检查完成。"
  if [ ! -f "$INSTALLED_SKILL_FILE" ]; then
    echo "下一步：运行 bash scripts/setup.sh 完成安装"
  else
    echo "下一步：在 Claude Code 中尝试 /widget add a clock showing the current time"
  fi
  exit 0
fi

mkdir -p "$TARGET_SKILL_DIR/templates" "$TARGET_SKILL_DIR/scripts"

if [ "$SOURCE_SKILL_DIR" != "$TARGET_SKILL_DIR" ]; then
  cp "$SOURCE_SKILL_DIR/SKILL.md" "$TARGET_SKILL_DIR/SKILL.md"
  cp "$SOURCE_SKILL_DIR/patterns.md" "$TARGET_SKILL_DIR/patterns.md"
  cp "$SOURCE_SKILL_DIR/templates/"*.jsx "$TARGET_SKILL_DIR/templates/"
  cp "$SOURCE_SKILL_DIR/scripts/"*.sh "$TARGET_SKILL_DIR/scripts/"
  chmod +x "$TARGET_SKILL_DIR/scripts/"*.sh
  echo -e "${GREEN}✓${NC} Claude Code skill 已安装 → $TARGET_SKILL_DIR"
else
  chmod +x "$TARGET_SKILL_DIR/scripts/"*.sh
  echo -e "${GREEN}✓${NC} 正在使用已安装的 Claude Code skill：$TARGET_SKILL_DIR"
fi

echo ""
echo -e "${GREEN}安装与验证完成！${NC}"
echo ""
echo "诊断命令："
echo "  bash \"$TARGET_SKILL_DIR/scripts/doctor.sh\""
echo ""
echo "使用方式："
echo "  Claude Code：输入 /widget 然后描述你想要的组件"
echo "  例如：/widget 给我在右下角加一个显示天气的组件"
echo ""
echo "内置模板（可直接使用）："
ls "$TARGET_SKILL_DIR/templates/"*.jsx 2>/dev/null | while read -r f; do
  echo "  $(basename "$f")"
done
echo ""


path: scripts/start-uebersicht.sh
content: #!/bin/bash
set -euo pipefail

find_uebersicht_app() {
  local app
  for app in /Applications/*bersicht.app; do
    if [ -d "$app" ]; then
      echo "$app"
      return 0
    fi
  done
  return 1
}

APP_PATH="$(find_uebersicht_app || true)"
if [ -z "$APP_PATH" ]; then
  echo "Übersicht.app not found" >&2
  exit 1
fi

open "$APP_PATH"
echo "Started: $APP_PATH"


path: templates/clock.jsx
content: export const command = `
  echo "time:$(date '+%H:%M:%S')"
  echo "weekday:$(date '+%A')"
  echo "datestr:$(date '+%Y年%-m月%-d日')"
`

export const refreshFrequency = 1000

export const className = `
  position: fixed;
  bottom: 90px;
  right: 40px;
  text-align: right;
  pointer-events: none;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
  color: rgba(255, 255, 255, 0.92);
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
`

export const render = ({ output }) => {
  const lines = {}
  ;(output || '').split('\n').forEach(line => {
    const idx = line.indexOf(':')
    if (idx > -1) lines[line.slice(0, idx)] = line.slice(idx + 1)
  })

  const [hh, mm, ss] = (lines.time || '--:--:--').split(':')

  return (
    <div>
      <div style={{
        fontSize: '72px',
        fontWeight: '200',
        letterSpacing: '-3px',
        lineHeight: 1,
      }}>
        {hh}:{mm}
        <span style={{ fontSize: '36px', opacity: 0.5, marginLeft: '6px' }}>:{ss}</span>
      </div>
      <div style={{
        fontSize: '15px',
        opacity: 0.65,
        marginTop: '8px',
        letterSpacing: '0.5px',
      }}>
        {lines.datestr}　{lines.weekday}
      </div>
    </div>
  )
}


path: templates/git-pulse.jsx
content: export const command = `
  # Get commit counts per date for the last 12 weeks
  echo "---COMMITS---"
  find ~ -maxdepth 3 -name ".git" -type d 2>/dev/null | head -10 | while read gitdir; do
    repodir="$(dirname "$gitdir")"
    git -C "$repodir" log --format="%Y-%m-%d" --since="12 weeks ago" 2>/dev/null
  done | sort | uniq -c | awk '{print $1, $2}'

  # Get today's commits per repo for "most active repo"
  echo "---REPOS---"
  today=$(date '+%Y-%m-%d')
  find ~ -maxdepth 3 -name ".git" -type d 2>/dev/null | head -10 | while read gitdir; do
    repodir="$(dirname "$gitdir")"
    reponame="$(basename "$repodir")"
    count=$(git -C "$repodir" log --format="%Y-%m-%d" --since="$today 00:00" 2>/dev/null | wc -l | tr -d ' ')
    if [ "$count" -gt 0 ] 2>/dev/null; then
      echo "$count $reponame"
    fi
  done | sort -rn | head -1

  # Get current branch of the most recently modified repo
  echo "---BRANCH---"
  find ~ -maxdepth 3 -name ".git" -type d 2>/dev/null | head -10 | while read gitdir; do
    repodir="$(dirname "$gitdir")"
    modtime=$(stat -f "%m" "$repodir/.git" 2>/dev/null || echo "0")
    echo "$modtime $repodir"
  done | sort -rn | head -1 | awk '{print $2}' | while read repodir; do
    git -C "$repodir" branch --show-current 2>/dev/null
  done
`

export const refreshFrequency = 300000

export const className = `
  position: fixed;
  top: 40px;
  right: 40px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', 'Menlo', monospace;
  pointer-events: none;
`

const WEEKS = 12
const DAYS_TOTAL = WEEKS * 7
const CELL_SIZE = 12
const CELL_GAP = 3
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

const getColor = (count) => {
  if (count <= 0) return 'rgba(255,255,255,0.05)'
  if (count <= 2) return '#0e4429'
  if (count <= 5) return '#006d32'
  if (count <= 9) return '#26a641'
  return '#39d353'
}

const parseOutput = (raw) => {
  const result = {
    commits: {},
    topRepo: '',
    todayRepoCount: 0,
    branch: '',
  }
  if (!raw) return result

  const text = raw.trim()
  const commitSection = text.split('---COMMITS---')[1]?.split('---REPOS---')[0] || ''
  const repoSection = text.split('---REPOS---')[1]?.split('---BRANCH---')[0] || ''
  const branchSection = text.split('---BRANCH---')[1] || ''

  // Parse commit counts
  commitSection.trim().split('\n').forEach(line => {
    const trimmed = line.trim()
    if (!trimmed) return
    const parts = trimmed.split(/\s+/)
    if (parts.length >= 2) {
      const count = parseInt(parts[0], 10)
      const date = parts[1]
      if (!isNaN(count) && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
        result.commits[date] = (result.commits[date] || 0) + count
      }
    }
  })

  // Parse top repo
  const repoLine = repoSection.trim().split('\n')[0]?.trim()
  if (repoLine) {
    const parts = repoLine.split(/\s+/)
    if (parts.length >= 2) {
      result.todayRepoCount = parseInt(parts[0], 10) || 0
      result.topRepo = parts.slice(1).join(' ')
    }
  }

  // Parse branch
  result.branch = branchSection.trim().split('\n')[0]?.trim() || ''

  return result
}

const formatDate = (d) => {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

// Convert JS getDay (0=Sun) to row index (0=Mon, 6=Sun)
const jsToRow = (jsDay) => jsDay === 0 ? 6 : jsDay - 1

const buildGrid = (commits) => {
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  const todayRow = jsToRow(today.getDay())

  // The rightmost column (col = WEEKS-1) contains today.
  // We need to find the Saturday (or Sunday) that starts the first column.
  // Going back from today: the last column started on the Monday of this week.
  // Today is at row=todayRow in col=WEEKS-1.
  // The first day on the grid is at row=0, col=0 (a Monday).
  // Days back from today to that Monday:
  //   (WEEKS - 1) * 7 + todayRow
  const daysBack = (WEEKS - 1) * 7 + todayRow

  const grid = []
  for (let r = 0; r < 7; r++) {
    grid[r] = []
    for (let c = 0; c < WEEKS; c++) {
      grid[r][c] = { date: null, count: 0 }
    }
  }

  const monthLabels = []
  const seenMonths = new Set()

  // Fill grid: iterate each cell by (col, row) order
  // offset=0 corresponds to the earliest date (row=0, col=0), which is a Monday
  // offset=daysBack corresponds to today
  for (let col = 0; col < WEEKS; col++) {
    for (let row = 0; row < 7; row++) {
      const offset = col * 7 + row
      // Cells beyond today in the last partial column are future dates
      if (offset > daysBack) continue
      const d = new Date(today)
      d.setDate(today.getDate() - (daysBack - offset))
      const dateStr = formatDate(d)
      grid[row][col] = { date: dateStr, count: commits[dateStr] || 0 }

      // Track month boundaries (when row=0, i.e. Monday of each week)
      if (row === 0) {
        const monthKey = `${d.getFullYear()}-${d.getMonth()}`
        if (!seenMonths.has(monthKey)) {
          seenMonths.add(monthKey)
          monthLabels.push({ col, label: MONTH_NAMES[d.getMonth()] })
        }
      }
    }
  }

  return { grid, monthLabels }
}

const computeStats = (commits) => {
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  const todayStr = formatDate(today)
  const todayCount = commits[todayStr] || 0

  // Calculate streak: consecutive days with commits ending at today (or yesterday if today has 0)
  let streak = 0
  let checkDate = new Date(today)
  if (!commits[todayStr]) {
    checkDate.setDate(checkDate.getDate() - 1)
  }
  for (let i = 0; i < DAYS_TOTAL; i++) {
    const ds = formatDate(checkDate)
    if (commits[ds] && commits[ds] > 0) {
      streak++
      checkDate.setDate(checkDate.getDate() - 1)
    } else {
      break
    }
  }

  // Total commits in 12 weeks
  let total = 0
  Object.values(commits).forEach(c => { total += c })

  return { todayCount, streak, total }
}

const GitPulseInner = ({ output }) => {
  const data = parseOutput(output)
  const { grid, monthLabels } = buildGrid(data.commits)
  const stats = computeStats(data.commits)

  const labelWidth = 28
  const gridWidth = WEEKS * (CELL_SIZE + CELL_GAP) - CELL_GAP
  const monthLabelHeight = 16

  return (
    <div style={{
      background: 'rgba(0, 0, 0, 0.5)',
      backdropFilter: 'blur(24px)',
      WebkitBackdropFilter: 'blur(24px)',
      borderRadius: '16px',
      border: '1px solid rgba(255,255,255,0.08)',
      padding: '16px 18px 14px',
      color: 'rgba(255,255,255,0.9)',
      minWidth: `${labelWidth + gridWidth + 20}px`,
    }}>
      {/* Header */}
      <div style={{
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: '12px',
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
          <div style={{
            width: '8px',
            height: '8px',
            borderRadius: '50%',
            background: stats.todayCount > 0 ? '#39d353' : 'rgba(255,255,255,0.2)',
            boxShadow: stats.todayCount > 0 ? '0 0 6px rgba(57,211,83,0.5)' : 'none',
          }} />
          <span style={{
            fontSize: '12px',
            fontWeight: 600,
            letterSpacing: '0.5px',
            color: 'rgba(255,255,255,0.8)',
          }}>
            Git Pulse
          </span>
          {data.branch && (
            <span style={{
              fontSize: '10px',
              color: 'rgba(255,255,255,0.35)',
              background: 'rgba(255,255,255,0.06)',
              padding: '2px 6px',
              borderRadius: '4px',
            }}>
              {data.branch}
            </span>
          )}
        </div>
        <div style={{
          fontSize: '11px',
          color: stats.streak > 0 ? '#39d353' : 'rgba(255,255,255,0.35)',
          fontWeight: 600,
        }}>
          {stats.streak > 0 ? `${stats.streak}d streak` : 'no streak'}
        </div>
      </div>

      {/* Month labels */}
      <div style={{
        display: 'flex',
        marginLeft: `${labelWidth}px`,
        marginBottom: '4px',
        height: `${monthLabelHeight}px`,
        position: 'relative',
        width: `${gridWidth}px`,
      }}>
        {monthLabels.map((m, i) => (
          <span key={i} style={{
            position: 'absolute',
            left: `${m.col * (CELL_SIZE + CELL_GAP)}px`,
            fontSize: '9px',
            color: 'rgba(255,255,255,0.3)',
            letterSpacing: '0.3px',
          }}>
            {m.label}
          </span>
        ))}
      </div>

      {/* Heatmap grid */}
      <div style={{ display: 'flex', gap: '0px' }}>
        {/* Day labels */}
        <div style={{
          display: 'flex',
          flexDirection: 'column',
          gap: `${CELL_GAP}px`,
          marginRight: '4px',
          width: `${labelWidth - 4}px`,
        }}>
          {DAY_LABELS.map((label, i) => (
            <div key={i} style={{
              height: `${CELL_SIZE}px`,
              fontSize: '9px',
              color: 'rgba(255,255,255,0.25)',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'flex-end',
              paddingRight: '4px',
            }}>
              {label}
            </div>
          ))}
        </div>

        {/* Grid columns */}
        <div style={{ display: 'flex', gap: `${CELL_GAP}px` }}>
          {Array.from({ length: WEEKS }, (_, col) => (
            <div key={col} style={{ display: 'flex', flexDirection: 'column', gap: `${CELL_GAP}px` }}>
              {Array.from({ length: 7 }, (_, row) => {
                const cell = grid[row][col]
                const color = getColor(cell.count)
                return (
                  <div key={row} style={{
                    width: `${CELL_SIZE}px`,
                    height: `${CELL_SIZE}px`,
                    borderRadius: '2px',
                    background: color,
                    transition: 'background 0.3s ease',
                  }} />
                )
              })}
            </div>
          ))}
        </div>
      </div>

      {/* Footer */}
      <div style={{
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginTop: '12px',
        paddingTop: '10px',
        borderTop: '1px solid rgba(255,255,255,0.06)',
      }}>
        <div style={{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>
          <span style={{ color: 'rgba(255,255,255,0.8)', fontWeight: 600 }}>{stats.todayCount}</span>
          <span> commit{stats.todayCount !== 1 ? 's' : ''} today</span>
        </div>
        <div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.3)' }}>
          {data.topRepo
            ? <span>{data.topRepo}</span>
            : <span>{stats.total} in 12w</span>
          }
        </div>
      </div>

      {/* Legend */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'flex-end',
        gap: '4px',
        marginTop: '8px',
      }}>
        <span style={{ fontSize: '9px', color: 'rgba(255,255,255,0.25)', marginRight: '4px' }}>Less</span>
        {[0, 1, 3, 6, 10].map((level, i) => (
          <div key={i} style={{
            width: '8px',
            height: '8px',
            borderRadius: '2px',
            background: getColor(level),
          }} />
        ))}
        <span style={{ fontSize: '9px', color: 'rgba(255,255,255,0.25)', marginLeft: '4px' }}>More</span>
      </div>
    </div>
  )
}

export const render = ({ output }) => {
  return <GitPulseInner output={output} />
}


path: templates/horizon-clock.jsx
content: export const command = `date '+%H:%M:%S|||%Y年%-m月%-d日|||%A'`

export const refreshFrequency = 1000

export const className = `
  position: fixed;
  bottom: 90px;
  left: 50%;
  transform: translateX(-50%);
  width: 400px;
  height: 140px;
  border-radius: 16px;
  overflow: hidden;
  pointer-events: none;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
`

// ---------------------------------------------------------------------------
// Color interpolation helpers
// ---------------------------------------------------------------------------
function hexToRgb(hex) {
  const n = parseInt(hex.replace('#', ''), 16)
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}

function rgbToHex([r, g, b]) {
  return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')
}

function lerpColor(a, b, t) {
  const ra = hexToRgb(a)
  const rb = hexToRgb(b)
  return rgbToHex(ra.map((v, i) => Math.round(v + (rb[i] - v) * t)))
}

// ---------------------------------------------------------------------------
// Sky gradient calculation
// ---------------------------------------------------------------------------
const SKY_STOPS = [
  { hour: 0,  top: '#0a0e27', bot: '#1a1a4e' },
  { hour: 5,  top: '#0a0e27', bot: '#1a1a4e' },
  { hour: 6,  top: '#1a1a4e', bot: '#ff6b35' },
  { hour: 7,  top: '#ffd700', bot: '#ff6b35' },
  { hour: 8,  top: '#87CEEB', bot: '#a8d8ea' },
  { hour: 12, top: '#4a90d9', bot: '#87CEEB' },
  { hour: 15, top: '#4a90d9', bot: '#6ab0de' },
  { hour: 17, top: '#ff8c00', bot: '#ffa040' },
  { hour: 18, top: '#ff4500', bot: '#ff6b35' },
  { hour: 19, top: '#8b1a1a', bot: '#ff4500' },
  { hour: 20, top: '#2d1b4e', bot: '#8b1a1a' },
  { hour: 21, top: '#2d1b4e', bot: '#1a1a4e' },
  { hour: 24, top: '#0a0e27', bot: '#1a1a4e' },
]

function getSkyGradient(hour, minute) {
  const t = hour + minute / 60
  let lo = SKY_STOPS[0]
  let hi = SKY_STOPS[SKY_STOPS.length - 1]
  for (let i = 0; i < SKY_STOPS.length - 1; i++) {
    if (t >= SKY_STOPS[i].hour && t < SKY_STOPS[i + 1].hour) {
      lo = SKY_STOPS[i]
      hi = SKY_STOPS[i + 1]
      break
    }
  }
  const frac = (hi.hour === lo.hour) ? 0 : (t - lo.hour) / (hi.hour - lo.hour)
  const top = lerpColor(lo.top, hi.top, frac)
  const bot = lerpColor(lo.bot, hi.bot, frac)
  return `linear-gradient(to bottom, ${top}, ${bot})`
}

// ---------------------------------------------------------------------------
// Sun / Moon position along an arc
// ---------------------------------------------------------------------------
function getCelestialPos(hour, minute, width, height) {
  // Sun arc: rises at 6:00 (left edge), peaks at 12:00 (center top), sets at 18:00 (right edge)
  const dayProgress = (hour + minute / 60 - 6) / 12 // 0 at 6:00, 1 at 18:00
  const clampedDay = Math.max(0, Math.min(1, dayProgress))

  // Night arc: moon rises at 18:00, peaks at 0:00, sets at 6:00
  let nightHour = hour + minute / 60
  if (nightHour >= 18) nightHour -= 18
  else nightHour += 6
  const nightProgress = nightHour / 12
  const clampedNight = Math.max(0, Math.min(1, nightProgress))

  const padding = 30
  const arcWidth = width - padding * 2

  const sunX = padding + clampedDay * arcWidth
  const sunY = height - 45 - Math.sin(clampedDay * Math.PI) * (height - 60)

  const moonX = padding + clampedNight * arcWidth
  const moonY = height - 45 - Math.sin(clampedNight * Math.PI) * (height - 60)

  const isDaytime = hour >= 6 && hour < 18
  const sunVisible = hour >= 5 && hour < 19
  const moonVisible = hour >= 18 || hour < 7

  return { sunX, sunY, moonX, moonY, isDaytime, sunVisible, moonVisible }
}

// ---------------------------------------------------------------------------
// City skyline SVG path
// ---------------------------------------------------------------------------
const SKYLINE_PATH = [
  // Start from bottom-left
  'M0,140',
  // Building 1 - short wide
  'L0,105 L15,105 L15,95 L35,95 L35,105 L45,105',
  // Building 2 - medium with flat top
  'L45,80 L55,80 L55,75 L70,75 L70,80 L80,80 L80,105',
  // Building 3 - tall narrow tower with antenna
  'L90,105 L90,60 L95,60 L97,42 L99,60 L104,60 L104,105',
  // Building 4 - medium stepped
  'L115,105 L115,85 L125,85 L125,72 L140,72 L140,85 L150,85 L150,105',
  // Building 5 - short
  'L160,105 L160,92 L180,92 L180,105',
  // Building 6 - tallest skyscraper with spire
  'L190,105 L190,55 L195,55 L198,30 L201,55 L210,55 L210,68 L220,68 L220,105',
  // Building 7 - dome-topped
  'L235,105 L235,78 Q247,60 260,78 L260,105',
  // Building 8 - medium blocky
  'L275,105 L275,82 L285,82 L285,75 L300,75 L300,82 L310,82 L310,105',
  // Building 9 - wide low
  'L325,105 L325,90 L350,90 L350,95 L365,95 L365,105',
  // Building 10 - last one with stepped top
  'L375,105 L375,80 L385,80 L385,70 L395,70 L395,80 L400,80 L400,105',
  // Close the bottom
  'L400,140 Z',
].join(' ')

// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
export const render = ({ output }) => {
  const raw = (output || '').trim()
  const parts = raw.split('|||')
  const time = parts[0] || '--:--:--'
  const dateStr = parts[1] || ''
  const weekday = parts[2] || ''

  const [hhStr, mmStr] = time.split(':')
  const hour = parseInt(hhStr, 10) || 0
  const minute = parseInt(mmStr, 10) || 0

  const skyBg = getSkyGradient(hour, minute)
  const { sunX, sunY, moonX, moonY, sunVisible, moonVisible } = getCelestialPos(hour, minute, 400, 140)

  const containerStyle = {
    position: 'relative',
    width: '400px',
    height: '140px',
    background: skyBg,
    borderRadius: '16px',
    overflow: 'hidden',
  }

  // Sun style
  const sunStyle = {
    position: 'absolute',
    left: sunX - 10,
    top: sunY - 10,
    width: '20px',
    height: '20px',
    borderRadius: '50%',
    background: 'radial-gradient(circle, #fff8a8 30%, #ffd700 60%, rgba(255,180,0,0) 100%)',
    boxShadow: '0 0 18px 8px rgba(255,200,50,0.6), 0 0 40px 15px rgba(255,160,0,0.25)',
    opacity: sunVisible ? 1 : 0,
    transition: 'opacity 0.5s',
    pointerEvents: 'none',
  }

  // Moon crescent - we use two overlapping circles
  const moonStyle = {
    position: 'absolute',
    left: moonX - 9,
    top: moonY - 9,
    width: '18px',
    height: '18px',
    opacity: moonVisible ? 1 : 0,
    transition: 'opacity 0.5s',
    pointerEvents: 'none',
  }

  // Time display
  const timeParts = time.split(':')
  const hh = timeParts[0] || '--'
  const mm = timeParts[1] || '--'
  const ss = timeParts[2] || '--'

  const textLayerStyle = {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    justifyContent: 'center',
    zIndex: 10,
    paddingBottom: '18px',
  }

  const timeStyle = {
    fontSize: '64px',
    fontWeight: 100,
    color: '#ffffff',
    lineHeight: 1,
    textShadow: '0 1px 6px rgba(0,0,0,0.5), 0 0 20px rgba(0,0,0,0.2)',
    letterSpacing: '-2px',
  }

  const secondsStyle = {
    fontSize: '28px',
    fontWeight: 100,
    opacity: 0.5,
    marginLeft: '4px',
    letterSpacing: '0px',
  }

  const dateStyle = {
    fontSize: '12px',
    fontWeight: 300,
    color: 'rgba(255,255,255,0.75)',
    marginTop: '4px',
    textShadow: '0 1px 4px rgba(0,0,0,0.5)',
    letterSpacing: '0.5px',
  }

  // Skyline SVG
  const skylineSvgStyle = {
    position: 'absolute',
    bottom: 0,
    left: 0,
    width: '400px',
    height: '140px',
    zIndex: 5,
    pointerEvents: 'none',
  }

  return (
    <div style={containerStyle}>
      {/* Sun */}
      <div style={sunStyle} />

      {/* Moon crescent via SVG */}
      <div style={moonStyle}>
        <svg width="18" height="18" viewBox="0 0 18 18">
          <circle cx="9" cy="9" r="8" fill="#e8e4d4" />
          <circle cx="13" cy="7" r="7" fill="transparent"
            style={{ filter: 'none' }} />
          <circle cx="13" cy="7" r="7"
            fill={(() => {
              // Match the darker sky color for the "bite" out of the moon
              const t = hour + minute / 60
              let lo = SKY_STOPS[0]
              let hi = SKY_STOPS[SKY_STOPS.length - 1]
              for (let i = 0; i < SKY_STOPS.length - 1; i++) {
                if (t >= SKY_STOPS[i].hour && t < SKY_STOPS[i + 1].hour) {
                  lo = SKY_STOPS[i]
                  hi = SKY_STOPS[i + 1]
                  break
                }
              }
              const frac = (hi.hour === lo.hour) ? 0 : (t - lo.hour) / (hi.hour - lo.hour)
              return lerpColor(lo.top, hi.top, frac)
            })()}
          />
          <circle cx="9" cy="9" r="8" fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="0.5" />
        </svg>
      </div>

      {/* City skyline silhouette */}
      <svg style={skylineSvgStyle} viewBox="0 0 400 140" preserveAspectRatio="none">
        <path d={SKYLINE_PATH} fill="#000000" />
      </svg>

      {/* Time & date text layer */}
      <div style={textLayerStyle}>
        <div style={timeStyle}>
          {hh}:{mm}
          <span style={secondsStyle}>:{ss}</span>
        </div>
        <div style={dateStyle}>
          {dateStr}  {weekday}
        </div>
      </div>
    </div>
  )
}


path: templates/memo-capsule.jsx
content: import { React } from 'uebersicht'

export const refreshFrequency = false

export const className = `
  position: fixed;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
`

const STORAGE_KEY = 'widgetdesk-memo-capsule'
const POSITION_KEY = 'widgetdesk-memo-capsule-position'
const MAX_LEN = 180
const WIDTH = 320
const DEFAULT_TOP = 40

const loadMemo = () => {
  const fallback = { draft: '', updatedAt: null }
  try {
    const saved = localStorage.getItem(STORAGE_KEY)
    if (!saved) return fallback
    const parsed = JSON.parse(saved)
    return {
      draft: typeof parsed.draft === 'string' ? parsed.draft : '',
      updatedAt: parsed.updatedAt || null,
    }
  } catch {
    return fallback
  }
}

const saveMemo = (draft) => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify({
    draft,
    updatedAt: new Date().toISOString(),
  }))
}

const defaultPosition = () => {
  const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1440
  return {
    x: Math.max(40, Math.round(viewportWidth / 2 - WIDTH / 2)),
    y: DEFAULT_TOP,
  }
}

const loadPosition = () => {
  const fallback = defaultPosition()
  try {
    const saved = localStorage.getItem(POSITION_KEY)
    if (!saved) return fallback
    const parsed = JSON.parse(saved)
    if (typeof parsed.x !== 'number' || typeof parsed.y !== 'number') return fallback
    return parsed
  } catch {
    return fallback
  }
}

const savePosition = (pos) => {
  localStorage.setItem(POSITION_KEY, JSON.stringify(pos))
}

const formatUpdatedAt = (value) => {
  if (!value) return 'Empty'
  const date = new Date(value)
  if (Number.isNaN(date.getTime())) return 'Saved'
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}

const MemoCapsule = () => {
  const { useMemo, useState, useEffect, useRef, useCallback } = React
  const initial = useMemo(() => loadMemo(), [])
  const initialPos = useMemo(() => loadPosition(), [])
  const [draft, setDraft] = useState(initial.draft)
  const [updatedAt, setUpdatedAt] = useState(initial.updatedAt)
  const [copied, setCopied] = useState(false)
  const [pos, setPos] = useState(initialPos)
  const dragging = useRef(false)
  const offset = useRef({ x: 0, y: 0 })

  useEffect(() => {
    saveMemo(draft)
    setUpdatedAt(new Date().toISOString())
  }, [draft])

  useEffect(() => {
    if (!copied) return
    const timer = setTimeout(() => setCopied(false), 1200)
    return () => clearTimeout(timer)
  }, [copied])

  useEffect(() => {
    savePosition(pos)
  }, [pos])

  const onDragStart = useCallback((event) => {
    dragging.current = true
    offset.current = {
      x: event.clientX - pos.x,
      y: event.clientY - pos.y,
    }
    event.preventDefault()
  }, [pos.x, pos.y])

  useEffect(() => {
    const onMove = (event) => {
      if (!dragging.current) return
      const next = {
        x: Math.max(16, event.clientX - offset.current.x),
        y: Math.max(16, event.clientY - offset.current.y),
      }
      setPos(next)
    }

    const onUp = () => {
      dragging.current = false
    }

    window.addEventListener('mousemove', onMove)
    window.addEventListener('mouseup', onUp)
    return () => {
      window.removeEventListener('mousemove', onMove)
      window.removeEventListener('mouseup', onUp)
    }
  }, [])

  const remaining = MAX_LEN - draft.length

  const onChange = (event) => {
    setDraft(event.target.value.slice(0, MAX_LEN))
  }

  const onClear = () => {
    setDraft('')
  }

  const onCopy = async () => {
    if (!draft.trim()) return
    try {
      await navigator.clipboard.writeText(draft)
      setCopied(true)
    } catch {}
  }

  return (
    <div style={{
      position: 'fixed',
      left: `${pos.x}px`,
      top: `${pos.y}px`,
      width: `${WIDTH}px`,
      background: 'rgba(8, 12, 20, 0.72)',
      backdropFilter: 'blur(24px)',
      WebkitBackdropFilter: 'blur(24px)',
      borderRadius: '18px',
      border: '1px solid rgba(255,255,255,0.08)',
      boxShadow: '0 14px 40px rgba(0, 0, 0, 0.35)',
      padding: '16px 18px 14px',
      color: 'rgba(255,255,255,0.92)',
    }}>
      <div style={{
        display: 'flex',
        alignItems: 'baseline',
        justifyContent: 'space-between',
        gap: '12px',
        marginBottom: '12px',
        cursor: dragging.current ? 'grabbing' : 'grab',
        userSelect: 'none',
      }}>
        <div>
          <div style={{
            fontSize: '10px',
            letterSpacing: '1.8px',
            textTransform: 'uppercase',
            color: 'rgba(255,255,255,0.34)',
            marginBottom: '6px',
          }}>
            Memo
          </div>
          <div
            onMouseDown={onDragStart}
            style={{
              display: 'inline-flex',
              alignItems: 'center',
              fontSize: '18px',
              fontWeight: 600,
              letterSpacing: '-0.3px',
            }}
          >
            Capsule
          </div>
        </div>
        <div style={{
          textAlign: 'right',
          fontVariantNumeric: 'tabular-nums',
        }}>
          <div style={{
            fontSize: '11px',
            color: copied ? 'rgba(124, 255, 186, 0.92)' : 'rgba(255,255,255,0.34)',
            textTransform: 'uppercase',
            letterSpacing: '1.4px',
          }}>
            {copied ? 'Copied' : formatUpdatedAt(updatedAt)}
          </div>
          <div style={{
            fontSize: '26px',
            fontWeight: 300,
            letterSpacing: '-1px',
            color: 'rgba(255,255,255,0.88)',
            lineHeight: 1.1,
            marginTop: '4px',
          }}>
            {remaining}
          </div>
        </div>
      </div>

      <textarea
        value={draft}
        onChange={onChange}
        placeholder="Jot something down..."
        style={{
          width: '100%',
          minHeight: '116px',
          resize: 'none',
          border: 'none',
          outline: 'none',
          borderRadius: '14px',
          padding: '14px 14px 12px',
          boxSizing: 'border-box',
          background: 'rgba(255,255,255,0.04)',
          color: 'rgba(255,255,255,0.92)',
          fontSize: '14px',
          lineHeight: 1.5,
          boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.05)',
          fontFamily: 'inherit',
        }}
      />

      <div style={{
        display: 'flex',
        justifyContent: 'space-between',
        gap: '10px',
        marginTop: '12px',
      }}>
        <button
          onClick={onClear}
          style={{
            border: '1px solid rgba(255,255,255,0.12)',
            background: 'rgba(255,255,255,0.04)',
            color: 'rgba(255,255,255,0.58)',
            borderRadius: '10px',
            padding: '8px 12px',
            fontSize: '12px',
            fontWeight: 600,
            cursor: 'pointer',
            fontFamily: 'inherit',
          }}
        >
          Clear
        </button>
        <button
          onClick={onCopy}
          style={{
            border: '1px solid rgba(124, 191, 255, 0.28)',
            background: 'rgba(124, 191, 255, 0.12)',
            color: 'rgba(160, 214, 255, 0.96)',
            borderRadius: '10px',
            padding: '8px 14px',
            fontSize: '12px',
            fontWeight: 600,
            cursor: draft.trim() ? 'pointer' : 'default',
            opacity: draft.trim() ? 1 : 0.45,
            fontFamily: 'inherit',
          }}
        >
          Copy
        </button>
      </div>
    </div>
  )
}

export const render = () => <MemoCapsule />


path: templates/now-playing.jsx
content: import { React } from 'uebersicht'

export const command = `
TRACK_INFO=$(osascript << 'END'
tell application "Music"
  try
    if player state is not stopped then
      set trackName to name of current track
      set artistName to artist of current track
      set trackPos to player position
      set trackDur to duration of current track
      set playState to player state as string
      return trackName & "|||" & artistName & "|||" & (round trackPos) & "|||" & (round trackDur) & "|||" & playState
    else
      return "stopped"
    end if
  on error
    return "stopped"
  end try
end tell
END
)
echo "$TRACK_INFO"
`

export const refreshFrequency = 2000

export const className = `
  position: fixed;
  bottom: 90px;
  left: 50%;
  transform: translateX(-50%);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
`

const parse = (raw) => {
  if (!raw || raw.trim() === 'stopped') return null
  const p = raw.trim().split('|||')
  if (p.length < 5) return null
  return {
    title:     p[0] || '',
    artist:    p[1] || '',
    position:  parseInt(p[2]) || 0,
    duration:  parseInt(p[3]) || 1,
    isPlaying: p[4].trim() === 'playing',
  }
}

const fmt = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`

const BAR_COUNT = 28

const EqBars = ({ isPlaying }) => {
  const { useState, useEffect, useRef } = React
  const [heights, setHeights] = useState(() =>
    Array.from({ length: BAR_COUNT }, (_, i) =>
      0.15 + Math.abs(Math.sin(i * 0.4)) * 0.3
    )
  )
  const peaksRef = useRef(heights.map(h => h))

  useEffect(() => {
    if (!isPlaying) {
      // 停止时缓慢归零
      const t = setInterval(() => {
        setHeights(h => {
          const next = h.map(v => Math.max(0.04, v * 0.85))
          if (next.every(v => v <= 0.05)) clearInterval(t)
          return next
        })
      }, 80)
      return () => clearInterval(t)
    }

    const t = setInterval(() => {
      setHeights(prev => prev.map((_, i) => {
        // 模拟不同频率段的响应特征
        const bass    = i < 6  ? 0.5 + Math.random() * 0.5 : 0
        const mid     = i < 16 ? 0.3 + Math.random() * 0.5 : 0
        const treble  = 0.1 + Math.random() * 0.4
        const base    = Math.max(bass, mid, treble)
        // 峰值保持
        if (base > peaksRef.current[i]) peaksRef.current[i] = base
        else peaksRef.current[i] *= 0.92
        return Math.min(1, base * 0.7 + peaksRef.current[i] * 0.3)
      }))
    }, 100)
    return () => clearInterval(t)
  }, [isPlaying])

  return (
    <div style={{
      display: 'flex',
      alignItems: 'flex-end',
      gap: '3px',
      height: '48px',
      padding: '0 2px',
    }}>
      {heights.map((h, i) => {
        // 颜色从青绿渐变到蓝紫
        const hue = 175 + (i / BAR_COUNT) * 60
        const light = 45 + h * 25
        return (
          <div key={i} style={{
            flex: 1,
            height: `${Math.max(3, h * 100)}%`,
            background: `hsl(${hue}, 90%, ${light}%)`,
            borderRadius: '1.5px 1.5px 0 0',
            boxShadow: h > 0.6 ? `0 0 6px hsl(${hue}, 90%, ${light}%)` : 'none',
            transition: 'height 0.09s ease-out',
            opacity: isPlaying ? 1 : 0.35,
          }} />
        )
      })}
    </div>
  )
}

const Player = ({ output }) => {
  const track = parse(output)
  const progress = track ? track.position / track.duration : 0

  return (
    <div style={{
      width: '320px',
      background: 'rgba(4, 8, 20, 0.88)',
      backdropFilter: 'blur(30px)',
      WebkitBackdropFilter: 'blur(30px)',
      borderRadius: '16px',
      border: '1px solid rgba(80, 200, 255, 0.12)',
      boxShadow: '0 16px 48px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05)',
      overflow: 'hidden',
    }}>

      {/* 顶部标题栏 */}
      <div style={{
        padding: '10px 16px 0',
        display: 'flex',
        alignItems: 'center',
        gap: '6px',
      }}>
        <div style={{
          width: '6px', height: '6px', borderRadius: '50%',
          background: track?.isPlaying ? 'rgba(80,220,160,1)' : 'rgba(255,255,255,0.15)',
          boxShadow: track?.isPlaying ? '0 0 8px rgba(80,220,160,0.8)' : 'none',
          transition: 'all 0.4s',
        }} />
        <span style={{
          fontSize: '10px',
          letterSpacing: '2px',
          color: 'rgba(80, 200, 255, 0.5)',
          textTransform: 'uppercase',
          fontWeight: 500,
        }}>
          {track?.isPlaying ? 'Now Playing' : 'Paused'}
        </span>
      </div>

      {/* 均衡器 */}
      <div style={{ padding: '10px 16px 6px' }}>
        <EqBars isPlaying={!!track?.isPlaying} />
      </div>

      {/* 分隔线 */}
      <div style={{
        height: '1px',
        background: 'linear-gradient(90deg, transparent, rgba(80,200,255,0.15), transparent)',
        margin: '0 16px',
      }} />

      {/* 曲目信息 */}
      <div style={{ padding: '12px 16px 8px' }}>
        {track ? (
          <>
            <div style={{
              fontSize: '15px',
              fontWeight: '600',
              color: 'rgba(255,255,255,0.92)',
              letterSpacing: '-0.2px',
              marginBottom: '3px',
              overflow: 'hidden',
              textOverflow: 'ellipsis',
              whiteSpace: 'nowrap',
            }}>
              {track.title}
            </div>
            <div style={{
              fontSize: '12px',
              color: 'rgba(80, 200, 255, 0.55)',
              letterSpacing: '0.3px',
            }}>
              {track.artist}
            </div>
          </>
        ) : (
          <div style={{ fontSize: '13px', color: 'rgba(255,255,255,0.2)' }}>
            打开 Music 开始播放
          </div>
        )}
      </div>

      {/* 进度条 */}
      <div style={{ padding: '0 16px 14px' }}>
        <div style={{
          position: 'relative',
          height: '2px',
          background: 'rgba(255,255,255,0.06)',
          borderRadius: '1px',
          marginBottom: '6px',
        }}>
          <div style={{
            position: 'absolute',
            left: 0, top: 0,
            height: '100%',
            width: `${progress * 100}%`,
            background: 'linear-gradient(90deg, rgba(80,180,255,0.8), rgba(120,220,200,0.9))',
            borderRadius: '1px',
            transition: 'width 1.5s linear',
          }} />
          <div style={{
            position: 'absolute',
            top: '50%',
            left: `${Math.min(progress * 100, 98)}%`,
            transform: 'translate(-50%, -50%)',
            width: '7px', height: '7px',
            borderRadius: '50%',
            background: 'rgba(160, 230, 255, 0.95)',
            boxShadow: '0 0 8px rgba(120,200,255,0.8)',
            transition: 'left 1.5s linear',
            opacity: track ? 1 : 0,
          }} />
        </div>

        <div style={{
          display: 'flex',
          justifyContent: 'space-between',
          fontSize: '10px',
          color: 'rgba(255,255,255,0.2)',
          fontVariantNumeric: 'tabular-nums',
          letterSpacing: '0.3px',
        }}>
          <span>{track ? fmt(track.position) : '0:00'}</span>
          <span>{track ? fmt(track.duration) : '0:00'}</span>
        </div>
      </div>

    </div>
  )
}

export const render = ({ output }) => <Player output={output} />


path: templates/pomodoro.jsx
content: import { React } from 'uebersicht'

export const refreshFrequency = false

export const className = `
  position: fixed;
  bottom: 90px;
  left: 40px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
`

const WORK_SECS = 25 * 60
const BREAK_SECS = 5 * 60

const PomodoroWidget = () => {
  const { useState, useEffect } = React
  const [phase, setPhase] = useState('work')
  const [remaining, setRemaining] = useState(WORK_SECS)
  const [running, setRunning] = useState(false)
  const [sessions, setSessions] = useState(0)

  // 每秒倒计时
  useEffect(() => {
    if (!running) return
    const timer = setInterval(() => {
      setRemaining(r => Math.max(0, r - 1))
    }, 1000)
    return () => clearInterval(timer)
  }, [running])

  // remaining 到 0 时切换阶段
  useEffect(() => {
    if (remaining !== 0) return
    setRunning(false)
    if (phase === 'work') {
      setSessions(s => s + 1)
      setPhase('break')
      setRemaining(BREAK_SECS)
    } else {
      setPhase('work')
      setRemaining(WORK_SECS)
    }
  }, [remaining])

  const mins = String(Math.floor(remaining / 60)).padStart(2, '0')
  const secs = String(remaining % 60).padStart(2, '0')
  const total = phase === 'work' ? WORK_SECS : BREAK_SECS
  const progress = 1 - remaining / total
  const isWork = phase === 'work'
  const accent = isWork ? 'rgba(255,82,82,0.9)' : 'rgba(82,196,255,0.9)'
  const accentBg = isWork ? 'rgba(255,82,82,0.12)' : 'rgba(82,196,255,0.12)'
  const circumference = 2 * Math.PI * 36

  return (
    <div style={{
      background: 'rgba(0,0,0,0.45)',
      backdropFilter: 'blur(20px)',
      WebkitBackdropFilter: 'blur(20px)',
      borderRadius: '20px',
      border: '1px solid rgba(255,255,255,0.1)',
      padding: '20px 24px',
      width: '180px',
      color: 'rgba(255,255,255,0.9)',
    }}>
      <div style={{ fontSize: '11px', letterSpacing: '1.5px', textTransform: 'uppercase', color: accent, marginBottom: '16px', fontWeight: 600 }}>
        {isWork ? '专注' : '休息'} · {sessions} 🍅
      </div>

      <div style={{ position: 'relative', width: '88px', margin: '0 auto 16px' }}>
        <svg width="88" height="88" style={{ transform: 'rotate(-90deg)' }}>
          <circle cx="44" cy="44" r="36" fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="4" />
          <circle cx="44" cy="44" r="36" fill="none" stroke={accent} strokeWidth="4"
            strokeLinecap="round"
            strokeDasharray={circumference}
            strokeDashoffset={circumference * (1 - progress)}
            style={{ transition: 'stroke-dashoffset 0.5s ease' }}
          />
        </svg>
        <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '22px', fontWeight: '300', letterSpacing: '-1px' }}>
          {mins}:{secs}
        </div>
      </div>

      <div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
        <button onClick={() => setRunning(r => !r)} style={{ background: accentBg, border: `1px solid ${accent}`, borderRadius: '10px', color: accent, fontSize: '12px', fontWeight: 600, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>
          {running ? '暂停' : '开始'}
        </button>
        <button onClick={() => { setRunning(false); setPhase('work'); setRemaining(WORK_SECS); setSessions(0) }} style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.15)', borderRadius: '10px', color: 'rgba(255,255,255,0.5)', fontSize: '12px', fontWeight: 600, padding: '6px 10px', cursor: 'pointer', fontFamily: 'inherit' }}>
          重置
        </button>
      </div>
    </div>
  )
}

export const render = () => <PomodoroWidget />


path: templates/system-stats.jsx
content: export const command = `
  echo "cpu:$(top -l 1 | grep "CPU usage" | awk '{print $3}' | tr -d '%' 2>/dev/null || echo '?')"
  echo "bat:$(pmset -g batt | grep -o '[0-9]*%' | head -1 | tr -d '%' || echo '?')"
  echo "mem:$(python3 -c "
import subprocess
r = subprocess.run(['vm_stat'], capture_output=True, text=True)
lines = r.stdout.split('\n')
ps = {}
for l in lines[1:]:
    if ':' in l:
        k, v = l.split(':', 1)
        try: ps[k.strip()] = int(v.strip().rstrip('.'))
        except: pass
total = ps.get('Pages free',0)+ps.get('Pages active',0)+ps.get('Pages inactive',0)+ps.get('Pages wired down',0)
used = ps.get('Pages active',0)+ps.get('Pages wired down',0)
print(round(used/total*100) if total else '?')
" 2>/dev/null || echo '?')"
`

export const refreshFrequency = 5000

export const className = `
  position: fixed;
  top: 40px;
  right: 40px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', monospace;
  pointer-events: none;
`

const parseOutput = (raw) => {
  const result = { cpu: '?', bat: '?', mem: '?' }
  if (!raw) return result
  raw.split('\n').forEach(line => {
    const idx = line.indexOf(':')
    if (idx > -1) {
      const key = line.slice(0, idx).trim()
      const val = line.slice(idx + 1).trim()
      if (result.hasOwnProperty(key)) result[key] = val
    }
  })
  return result
}

const Bar = ({ value, color }) => {
  const pct = isNaN(parseInt(value)) ? 0 : Math.min(100, parseInt(value))
  return (
    <div style={{
      width: '80px',
      height: '4px',
      background: 'rgba(255,255,255,0.1)',
      borderRadius: '2px',
      overflow: 'hidden',
    }}>
      <div style={{
        width: `${pct}%`,
        height: '100%',
        background: color,
        borderRadius: '2px',
        transition: 'width 0.4s ease',
      }} />
    </div>
  )
}

const StatRow = ({ label, value, color }) => (
  <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: '10px',
    marginBottom: '10px',
  }}>
    <div style={{ width: '32px', fontSize: '10px', color: 'rgba(255,255,255,0.4)', letterSpacing: '0.5px' }}>
      {label}
    </div>
    <Bar value={value} color={color} />
    <div style={{ fontSize: '11px', color: 'rgba(255,255,255,0.7)', width: '36px', textAlign: 'right' }}>
      {value}%
    </div>
  </div>
)

export const render = ({ output }) => {
  const stats = parseOutput(output)
  const batVal = parseInt(stats.bat)
  const batColor = batVal < 20
    ? 'rgba(255, 82, 82, 0.9)'
    : batVal < 50
    ? 'rgba(255, 196, 0, 0.9)'
    : 'rgba(82, 196, 82, 0.9)'

  return (
    <div style={{
      background: 'rgba(0, 0, 0, 0.4)',
      backdropFilter: 'blur(20px)',
      WebkitBackdropFilter: 'blur(20px)',
      borderRadius: '14px',
      border: '1px solid rgba(255,255,255,0.08)',
      padding: '14px 16px',
    }}>
      <div style={{
        fontSize: '10px',
        letterSpacing: '1.5px',
        color: 'rgba(255,255,255,0.3)',
        marginBottom: '12px',
        textTransform: 'uppercase',
      }}>
        系统
      </div>
      <StatRow label="CPU" value={stats.cpu} color="rgba(130, 180, 255, 0.9)" />
      <StatRow label="MEM" value={stats.mem} color="rgba(180, 130, 255, 0.9)" />
      <StatRow label="BAT" value={stats.bat} color={batColor} />
    </div>
  )
}


path: templates/tap-counter.jsx
content: import { React } from 'uebersicht'

export const refreshFrequency = false

export const className = `
  position: fixed;
  bottom: 90px;
  right: 40px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
  pointer-events: auto;
  user-select: none;
  -webkit-user-select: none;
`

const STORAGE_KEY = 'widgetdesk-tap-counter'
const DEFAULT_VALUE = 12

const loadValue = () => {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (raw === null) return DEFAULT_VALUE
    const parsed = JSON.parse(raw)
    return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : DEFAULT_VALUE
  } catch {
    return DEFAULT_VALUE
  }
}

const saveValue = (value) => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
}

const buttonBase = {
  border: '1px solid rgba(255,255,255,0.08)',
  borderRadius: '12px',
  background: 'rgba(255,255,255,0.05)',
  color: 'rgba(255,255,255,0.9)',
  fontFamily: 'inherit',
  cursor: 'pointer',
}

const TapCounter = () => {
  const { useState, useEffect } = React
  const [count, setCount] = useState(() => loadValue())

  useEffect(() => {
    saveValue(count)
  }, [count])

  return (
    <div
      style={{
        width: '220px',
        background: 'rgba(8, 12, 20, 0.72)',
        backdropFilter: 'blur(24px)',
        WebkitBackdropFilter: 'blur(24px)',
        borderRadius: '18px',
        border: '1px solid rgba(255,255,255,0.08)',
        boxShadow: '0 14px 40px rgba(0,0,0,0.35)',
        padding: '16px',
        color: 'rgba(255,255,255,0.92)',
      }}
    >
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'space-between',
          marginBottom: '14px',
        }}
      >
        <div>
          <div
            style={{
              fontSize: '10px',
              letterSpacing: '1.8px',
              textTransform: 'uppercase',
              color: 'rgba(255,255,255,0.38)',
              marginBottom: '5px',
            }}
          >
            Interactive
          </div>
          <div
            style={{
              fontSize: '18px',
              fontWeight: 600,
              letterSpacing: '-0.4px',
            }}
          >
            Tap Counter
          </div>
        </div>

        <button
          onClick={() => setCount(DEFAULT_VALUE)}
          style={{
            ...buttonBase,
            height: '30px',
            padding: '0 10px',
            fontSize: '11px',
            letterSpacing: '1px',
            textTransform: 'uppercase',
            color: 'rgba(255,255,255,0.65)',
          }}
        >
          Reset
        </button>
      </div>

      <div
        style={{
          display: 'grid',
          gridTemplateColumns: '56px 1fr 56px',
          gap: '10px',
          alignItems: 'center',
        }}
      >
        <button
          onClick={() => setCount((value) => value - 1)}
          style={{
            ...buttonBase,
            height: '56px',
            fontSize: '28px',
            lineHeight: 1,
          }}
          aria-label="Decrease count"
        >
          -
        </button>

        <div
          style={{
            minHeight: '88px',
            borderRadius: '16px',
            background: 'linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.03))',
            boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.05)',
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
          }}
        >
          <div
            style={{
              fontSize: '44px',
              fontWeight: 300,
              letterSpacing: '-2px',
              lineHeight: 1,
              fontVariantNumeric: 'tabular-nums',
            }}
          >
            {count}
          </div>
          <div
            style={{
              marginTop: '8px',
              fontSize: '11px',
              letterSpacing: '1.5px',
              textTransform: 'uppercase',
              color: 'rgba(255,255,255,0.42)',
            }}
          >
            Local state
          </div>
        </div>

        <button
          onClick={() => setCount((value) => value + 1)}
          style={{
            ...buttonBase,
            height: '56px',
            fontSize: '28px',
            lineHeight: 1,
            background: 'rgba(122, 240, 177, 0.12)',
            border: '1px solid rgba(122, 240, 177, 0.32)',
            color: 'rgba(162, 255, 208, 0.98)',
          }}
          aria-label="Increase count"
        >
          +
        </button>
      </div>
    </div>
  )
}

export const render = () => <TapCounter />


path: templates/volume-knob.jsx
content: import { React, run } from 'uebersicht'

export const command = `
VOL=$(osascript -e 'output volume of (get volume settings)')
MUTED=$(osascript -e 'output muted of (get volume settings)')
printf "%s|||%s" "$VOL" "$MUTED"
`

export const refreshFrequency = 800

export const className = `
  position: fixed;
  bottom: 250px;
  right: 40px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
  pointer-events: auto;
  user-select: none;
  -webkit-user-select: none;
`

const clamp = (value, min, max) => Math.max(min, Math.min(max, value))

const setVolume = (value) => {
  const safeValue = clamp(value, 0, 100)
  return run(`osascript -e 'set volume output volume ${safeValue}'`)
}

const setMuted = (muted) =>
  run(`osascript -e 'set volume ${muted ? 'with' : 'without'} output muted'`)

const Widget = ({ output }) => {
  const [volumeText = '0', mutedText = 'false'] = (output || '').trim().split('|||')
  const volume = clamp(parseInt(volumeText, 10) || 0, 0, 100)
  const muted = mutedText.trim() === 'true'

  const accent = muted
    ? 'rgba(255,255,255,0.26)'
    : volume < 35
      ? '#8cc8ff'
      : volume < 70
        ? '#86e2b4'
        : '#ffd27a'

  const radius = 42
  const circumference = 2 * Math.PI * radius
  const arcLength = circumference * 0.75
  const filledLength = (volume / 100) * arcLength

  const buttonStyle = {
    flex: 1,
    height: '34px',
    border: '1px solid rgba(255,255,255,0.08)',
    borderRadius: '10px',
    background: 'rgba(255,255,255,0.05)',
    color: 'rgba(255,255,255,0.88)',
    fontSize: '15px',
    fontFamily: 'inherit',
    cursor: 'pointer',
  }

  return (
    <div
      style={{
        width: '160px',
        padding: '14px',
        background: 'rgba(8, 12, 20, 0.72)',
        backdropFilter: 'blur(24px)',
        WebkitBackdropFilter: 'blur(24px)',
        borderRadius: '18px',
        border: '1px solid rgba(255,255,255,0.08)',
        boxShadow: '0 14px 40px rgba(0,0,0,0.35)',
      }}
    >
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'space-between',
          marginBottom: '12px',
        }}
      >
        <div
          style={{
            fontSize: '11px',
            letterSpacing: '1.8px',
            textTransform: 'uppercase',
            color: 'rgba(255,255,255,0.44)',
          }}
        >
          Volume
        </div>
        <button
          onClick={() => setMuted(!muted)}
          style={{
            border: 'none',
            background: 'transparent',
            color: muted ? 'rgba(255,255,255,0.46)' : 'rgba(255,255,255,0.82)',
            fontSize: '12px',
            letterSpacing: '1px',
            textTransform: 'uppercase',
            cursor: 'pointer',
            padding: 0,
          }}
        >
          {muted ? 'Unmute' : 'Mute'}
        </button>
      </div>

      <div
        style={{
          position: 'relative',
          width: '132px',
          height: '132px',
          margin: '0 auto 12px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <svg width="132" height="132" style={{ position: 'absolute', inset: 0 }}>
          <circle
            cx="66"
            cy="66"
            r={radius}
            fill="none"
            stroke="rgba(255,255,255,0.08)"
            strokeWidth="8"
            strokeLinecap="round"
            strokeDasharray={`${arcLength} ${circumference}`}
            transform="rotate(135, 66, 66)"
          />
          <circle
            cx="66"
            cy="66"
            r={radius}
            fill="none"
            stroke={accent}
            strokeWidth="8"
            strokeLinecap="round"
            strokeDasharray={`${filledLength} ${circumference}`}
            transform="rotate(135, 66, 66)"
            style={{ transition: 'stroke-dasharray 160ms ease, stroke 160ms ease' }}
          />
        </svg>

        <div style={{ textAlign: 'center' }}>
          <div
            style={{
              fontSize: '34px',
              fontWeight: '700',
              lineHeight: 1,
              color: muted ? 'rgba(255,255,255,0.32)' : 'rgba(255,255,255,0.95)',
              fontVariantNumeric: 'tabular-nums',
            }}
          >
            {muted ? '0' : volume}
          </div>
          <div
            style={{
              marginTop: '5px',
              fontSize: '10px',
              letterSpacing: '1.4px',
              textTransform: 'uppercase',
              color: muted ? 'rgba(255,255,255,0.32)' : 'rgba(255,255,255,0.52)',
            }}
          >
            {muted ? 'Muted' : 'Output'}
          </div>
        </div>

        <button
          onClick={() => setVolume(volume - 5)}
          style={{
            position: 'absolute',
            left: 0,
            top: 0,
            width: '50%',
            height: '100%',
            opacity: 0,
            cursor: 'w-resize',
          }}
          aria-label="Decrease volume"
        />
        <button
          onClick={() => setVolume(volume + 5)}
          style={{
            position: 'absolute',
            right: 0,
            top: 0,
            width: '50%',
            height: '100%',
            opacity: 0,
            cursor: 'e-resize',
          }}
          aria-label="Increase volume"
        />
      </div>

      <div style={{ display: 'flex', gap: '8px' }}>
        <button onClick={() => setVolume(volume - 10)} style={buttonStyle}>
          -
        </button>
        <button
          onClick={() => setMuted(!muted)}
          style={{
            ...buttonStyle,
            flex: 1.4,
            color: muted ? '#ffd27a' : 'rgba(255,255,255,0.88)',
          }}
        >
          {muted ? 'Muted' : 'Sound'}
        </button>
        <button onClick={() => setVolume(volume + 10)} style={buttonStyle}>
          +
        </button>
      </div>
    </div>
  )
}

export const render = ({ output }) => <Widget output={output} />


path: templates/weather-canvas.jsx
content: import { React } from 'uebersicht'

export const command = `
  DATA=$(curl -s --max-time 8 "wttr.in/?format=j1" 2>/dev/null)
  if [ -z "$DATA" ]; then
    echo "temp:--"
    echo "feels:--"
    echo "humidity:--"
    echo "wind:--"
    echo "code:113"
    echo "desc:Unknown"
  else
    TEMP=$(echo "$DATA" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current_condition'][0]['temp_C'])" 2>/dev/null || echo "--")
    FEELS=$(echo "$DATA" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current_condition'][0]['FeelsLikeC'])" 2>/dev/null || echo "--")
    HUMIDITY=$(echo "$DATA" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current_condition'][0]['humidity'])" 2>/dev/null || echo "--")
    WIND=$(echo "$DATA" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current_condition'][0]['windspeedKmph'])" 2>/dev/null || echo "--")
    CODE=$(echo "$DATA" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current_condition'][0]['weatherCode'])" 2>/dev/null || echo "113")
    DESC=$(echo "$DATA" | /usr/bin/python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current_condition'][0]['weatherDesc'][0]['value'])" 2>/dev/null || echo "Unknown")
    echo "temp:$TEMP"
    echo "feels:$FEELS"
    echo "humidity:$HUMIDITY"
    echo "wind:$WIND"
    echo "code:$CODE"
    echo "desc:$DESC"
  fi
`

export const refreshFrequency = 600000

export const className = `
  position: fixed;
  top: 40px;
  left: 40px;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
  pointer-events: none;
`

const parseOutput = (raw) => {
  const result = { temp: '--', feels: '--', humidity: '--', wind: '--', code: '113', desc: 'Unknown' }
  if (!raw) return result
  raw.trim().split('\n').forEach(line => {
    const idx = line.indexOf(':')
    if (idx > -1) {
      const key = line.slice(0, idx).trim()
      const val = line.slice(idx + 1).trim()
      if (result.hasOwnProperty(key)) result[key] = val
    }
  })
  return result
}

const classifyWeather = (code) => {
  const codeNum = parseInt(code) || 113
  const rainCodes = [176, 293, 296, 299, 302, 305, 308]
  const snowCodes = [227, 230, 323, 326, 329, 332, 335, 338]
  const cloudyCodes = [119, 122]

  if (rainCodes.includes(codeNum)) return 'rain'
  if (snowCodes.includes(codeNum)) return 'snow'
  if (cloudyCodes.includes(codeNum)) return 'cloudy'
  if (codeNum === 116) return 'partly-cloudy'
  return 'sunny'
}

const getGradient = (weather) => {
  switch (weather) {
    case 'rain':
      return 'linear-gradient(160deg, rgba(40, 50, 70, 0.85), rgba(30, 40, 60, 0.9))'
    case 'snow':
      return 'linear-gradient(160deg, rgba(50, 60, 90, 0.85), rgba(40, 50, 80, 0.9))'
    case 'cloudy':
      return 'linear-gradient(160deg, rgba(55, 60, 70, 0.85), rgba(40, 45, 55, 0.9))'
    case 'partly-cloudy':
      return 'linear-gradient(160deg, rgba(50, 55, 75, 0.85), rgba(45, 50, 65, 0.9))'
    default: // sunny
      return 'linear-gradient(160deg, rgba(60, 45, 30, 0.85), rgba(50, 35, 25, 0.9))'
  }
}

const getAccentColor = (weather) => {
  switch (weather) {
    case 'rain':    return 'rgba(100, 160, 220, 0.8)'
    case 'snow':    return 'rgba(160, 190, 240, 0.8)'
    case 'cloudy':  return 'rgba(140, 155, 170, 0.7)'
    case 'partly-cloudy': return 'rgba(180, 180, 200, 0.7)'
    default:        return 'rgba(255, 200, 100, 0.8)'
  }
}

// Keyframes CSS injected via <style>
const keyframesCSS = `
@keyframes wc-raindrop {
  0% {
    transform: translateY(-20px) translateX(0px);
    opacity: 0;
  }
  10% {
    opacity: 0.7;
  }
  90% {
    opacity: 0.5;
  }
  100% {
    transform: translateY(200px) translateX(-15px);
    opacity: 0;
  }
}

@keyframes wc-snowflake {
  0% {
    transform: translateY(-20px) translateX(0px) rotate(0deg);
    opacity: 0;
  }
  10% {
    opacity: 0.8;
  }
  50% {
    transform: translateY(90px) translateX(20px) rotate(180deg);
    opacity: 0.6;
  }
  90% {
    opacity: 0.3;
  }
  100% {
    transform: translateY(200px) translateX(-10px) rotate(360deg);
    opacity: 0;
  }
}

@keyframes wc-sun-glow {
  0% {
    transform: scale(1);
    opacity: 0.15;
  }
  50% {
    transform: scale(1.3);
    opacity: 0.35;
  }
  100% {
    transform: scale(1);
    opacity: 0.15;
  }
}

@keyframes wc-sun-particle {
  0% {
    transform: translateY(0px) scale(0.8);
    opacity: 0;
  }
  30% {
    opacity: 0.5;
  }
  70% {
    opacity: 0.3;
  }
  100% {
    transform: translateY(-40px) scale(0.2);
    opacity: 0;
  }
}

@keyframes wc-cloud-drift {
  0% {
    transform: translateX(-30px);
    opacity: 0;
  }
  20% {
    opacity: 0.35;
  }
  80% {
    opacity: 0.25;
  }
  100% {
    transform: translateX(230px);
    opacity: 0;
  }
}

@keyframes wc-cloud-pulse {
  0% {
    opacity: 0.15;
    transform: scale(1);
  }
  50% {
    opacity: 0.3;
    transform: scale(1.08);
  }
  100% {
    opacity: 0.15;
    transform: scale(1);
  }
}
`

const makeRainParticles = (count) => {
  return Array.from({ length: count }, (_, index) => {
    const left = Math.random() * 100
    const delay = Math.random() * 3
    const duration = 0.8 + Math.random() * 0.6
    const width = 1 + Math.random() * 1.5
    const height = 12 + Math.random() * 18
    const opacity = 0.3 + Math.random() * 0.4
    return (
      <div key={`rain-${index}`} style={{
        position: 'absolute',
        left: `${left}%`,
        top: '-10px',
        width: `${width}px`,
        height: `${height}px`,
        background: `linear-gradient(180deg, rgba(120, 180, 240, ${opacity}), rgba(80, 140, 220, 0.1))`,
        borderRadius: '1px',
        animation: `wc-raindrop ${duration}s ${delay}s linear infinite`,
        opacity: 0,
      }} />
    )
  })
}

const makeSnowParticles = (count) => {
  return Array.from({ length: count }, (_, index) => {
    const left = Math.random() * 100
    const delay = Math.random() * 5
    const duration = 3 + Math.random() * 4
    const size = 2 + Math.random() * 4
    const opacity = 0.3 + Math.random() * 0.5
    return (
      <div key={`snow-${index}`} style={{
        position: 'absolute',
        left: `${left}%`,
        top: '-10px',
        width: `${size}px`,
        height: `${size}px`,
        background: `rgba(220, 230, 255, ${opacity})`,
        borderRadius: '50%',
        boxShadow: `0 0 ${size * 2}px rgba(200, 215, 255, ${opacity * 0.5})`,
        animation: `wc-snowflake ${duration}s ${delay}s ease-in-out infinite`,
        opacity: 0,
      }} />
    )
  })
}

const makeSunParticles = (count) => {
  const particles = []
  // Central glow orbs
  particles.push(
    <div key="sun-glow-1" style={{
      position: 'absolute',
      top: '10px',
      right: '10px',
      width: '60px',
      height: '60px',
      borderRadius: '50%',
      background: 'radial-gradient(circle, rgba(255, 200, 80, 0.4), transparent 70%)',
      animation: 'wc-sun-glow 4s ease-in-out infinite',
    }} />
  )
  particles.push(
    <div key="sun-glow-2" style={{
      position: 'absolute',
      top: '20px',
      right: '20px',
      width: '40px',
      height: '40px',
      borderRadius: '50%',
      background: 'radial-gradient(circle, rgba(255, 180, 60, 0.3), transparent 70%)',
      animation: 'wc-sun-glow 3s 1s ease-in-out infinite',
    }} />
  )
  // Floating warm particles
  for (let index = 0; index < count; index++) {
    const left = 50 + Math.random() * 50
    const top = Math.random() * 60
    const delay = Math.random() * 4
    const duration = 3 + Math.random() * 3
    const size = 2 + Math.random() * 3
    particles.push(
      <div key={`sun-p-${index}`} style={{
        position: 'absolute',
        left: `${left}%`,
        top: `${top}%`,
        width: `${size}px`,
        height: `${size}px`,
        borderRadius: '50%',
        background: `rgba(255, 210, 100, ${0.2 + Math.random() * 0.3})`,
        boxShadow: `0 0 ${size * 3}px rgba(255, 190, 70, 0.3)`,
        animation: `wc-sun-particle ${duration}s ${delay}s ease-out infinite`,
        opacity: 0,
      }} />
    )
  }
  return particles
}

const makeCloudParticles = (count) => {
  const particles = []
  // Drifting clouds
  for (let index = 0; index < Math.min(count, 8); index++) {
    const top = 10 + Math.random() * 60
    const delay = Math.random() * 12
    const duration = 10 + Math.random() * 10
    const height = 6 + Math.random() * 10
    const width = 30 + Math.random() * 40
    const opacity = 0.1 + Math.random() * 0.2
    particles.push(
      <div key={`cloud-d-${index}`} style={{
        position: 'absolute',
        left: '-30px',
        top: `${top}%`,
        width: `${width}px`,
        height: `${height}px`,
        borderRadius: '50%',
        background: `rgba(180, 190, 210, ${opacity})`,
        filter: `blur(${3 + Math.random() * 4}px)`,
        animation: `wc-cloud-drift ${duration}s ${delay}s linear infinite`,
        opacity: 0,
      }} />
    )
  }
  // Pulsing haze patches
  for (let index = 0; index < count - 8; index++) {
    const left = Math.random() * 80
    const top = Math.random() * 70
    const delay = Math.random() * 5
    const duration = 4 + Math.random() * 4
    const size = 15 + Math.random() * 25
    particles.push(
      <div key={`cloud-p-${index}`} style={{
        position: 'absolute',
        left: `${left}%`,
        top: `${top}%`,
        width: `${size}px`,
        height: `${size * 0.6}px`,
        borderRadius: '50%',
        background: `rgba(160, 175, 200, ${0.08 + Math.random() * 0.12})`,
        filter: `blur(${4 + Math.random() * 3}px)`,
        animation: `wc-cloud-pulse ${duration}s ${delay}s ease-in-out infinite`,
      }} />
    )
  }
  return particles
}

const getParticles = (weather) => {
  switch (weather) {
    case 'rain':          return makeRainParticles(28)
    case 'snow':          return makeSnowParticles(24)
    case 'cloudy':        return makeCloudParticles(20)
    case 'partly-cloudy': return makeCloudParticles(14)
    default:              return makeSunParticles(20)
  }
}

const WeatherCanvas = ({ output }) => {
  const { useEffect, useRef } = React
  const styleRef = useRef(null)

  // Inject keyframes into document once
  useEffect(() => {
    if (!document.getElementById('wc-keyframes-style')) {
      const styleEl = document.createElement('style')
      styleEl.id = 'wc-keyframes-style'
      styleEl.textContent = keyframesCSS
      document.head.appendChild(styleEl)
      styleRef.current = styleEl
    }
    return () => {
      const el = document.getElementById('wc-keyframes-style')
      if (el) el.remove()
    }
  }, [])

  const data = parseOutput(output)
  const weather = classifyWeather(data.code)
  const gradient = getGradient(weather)
  const accent = getAccentColor(weather)
  const particles = getParticles(weather)

  return (
    <div style={{
      position: 'relative',
      width: '200px',
      height: '180px',
      background: gradient,
      backdropFilter: 'blur(24px)',
      WebkitBackdropFilter: 'blur(24px)',
      borderRadius: '18px',
      border: '1px solid rgba(255, 255, 255, 0.1)',
      boxShadow: '0 12px 40px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06)',
      overflow: 'hidden',
      padding: '18px 20px',
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'space-between',
    }}>
      {/* Particle layer */}
      <div style={{
        position: 'absolute',
        inset: 0,
        overflow: 'hidden',
        borderRadius: '18px',
        pointerEvents: 'none',
      }}>
        {particles}
      </div>

      {/* Content layer */}
      <div style={{ position: 'relative', zIndex: 1 }}>
        {/* Weather description */}
        <div style={{
          fontSize: '11px',
          letterSpacing: '1px',
          color: accent,
          textTransform: 'uppercase',
          fontWeight: 500,
          marginBottom: '4px',
        }}>
          {data.desc}
        </div>

        {/* Temperature */}
        <div style={{
          fontSize: '72px',
          fontWeight: 100,
          lineHeight: 1,
          color: 'rgba(255, 255, 255, 0.92)',
          letterSpacing: '-4px',
          textShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
        }}>
          {data.temp}
          <span style={{
            fontSize: '28px',
            fontWeight: 200,
            opacity: 0.5,
            letterSpacing: '0px',
            marginLeft: '2px',
            verticalAlign: 'top',
            lineHeight: '1.2',
            display: 'inline-block',
          }}>
            °
          </span>
        </div>
      </div>

      {/* Bottom info row */}
      <div style={{
        position: 'relative',
        zIndex: 1,
        display: 'flex',
        justifyContent: 'space-between',
        fontSize: '10px',
        color: 'rgba(255, 255, 255, 0.4)',
        letterSpacing: '0.3px',
      }}>
        <span>
          Feels {data.feels}°
        </span>
        <span>
          {data.humidity}%
        </span>
        <span>
          {data.wind}km/h
        </span>
      </div>
    </div>
  )
}

export const render = ({ output }) => <WeatherCanvas output={output} />


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)