Community编程与开发github.com

veritatron/scriptable-widget

根据一句话描述,为 Scriptable 应用构建可直接使用的 iPhone 主屏幕小组件。适用于有人请求 iPhone 小组件或 Scriptable 脚本的场景。

scriptable-widget 是什么?

该技能能够将用户的一句话描述,自动转化为一个可直接在 Scriptable iPhone 应用中运行的 JavaScript 小组件脚本。它集成了完整的工作流程:从理解用户意图、确认关键信息(如语言、样式),到基于预置模板自动生成代码。所有脚本均遵循最佳实践,如使用 `CONFIG` 进行配置、支持多种尺寸和样式(Clean、Minimal、Bold、Playful)、内置缓存和错误处理机制。用户无需编写任何代码,只需描述需求即可获得一个完整、美观且可离线使用的小组件。技能核心逻辑清晰,涵盖了数据获取(优先使用免 API 密钥的免费服务,如 Open-Meteo)、布局设计、颜色主题和动画限制等关键点。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/veritatron/words2widget/tree/HEAD/skills/scriptable-widget

Installed? Explore more 编程与开发 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

Scriptable 小组件构建器

将一句话转化为 Scriptable iPhone 应用的完整 JavaScript 小组件。用户无需编码,因此永远不要要求他们编辑脚本。

工作流程

  1. 确定小组件显示的内容以及数据来源。
  2. 在构建前发送一条简短的确认消息。询问用户小组件的语言和样式(提供下方列出的样式),以及其他构建小组件所必需的信息,例如天气小组件的地点。省略用户已说明的内容,如果用户已说明所有信息,则直接开始构建。不要发送第二条确认消息。为所有其他项选择合理的默认值。
  3. 通过调整下面的模板来编写脚本。
  4. 完成清单检查。
  5. 按照“回复格式”中描述的格式进行回复。

数据源

永远不要使用需要 API 密钥、账户或登录的服务。脚本中绝不能包含密钥。如果请求依赖于此类服务,则使用最接近的免费数据源,并用一句话说明。如果没有免费来源,则说明无法实现的内容,并提供最接近的小组件。

优先使用原生数据,其次是这些免费数据源:

数据来源
天气https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&current=temperature_2m,weather_code,is_day&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto
空气质量, 花粉https://air-quality-api.open-meteo.com/v1/air-quality?latitude=..&longitude=..&current=european_aqi,birch_pollen
地点坐标https://geocoding-api.open-meteo.com/v1/search?name=..&count=1
汇率https://api.frankfurter.dev/v1/latest?base=EUR&symbols=USD
加密货币价格https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur

将地点的坐标直接写入 CONFIG。仅当用户要求获取其当前位置时,才使用 Location.current()。

原生数据无需网络:CalendarEvent, Reminder, Device, 日期。对于这些数据,移除缓存函数和 loadData,直接构建数据。日历、提醒事项和位置信息需要权限,因此请告知用户需要在 Scriptable 应用中运行该脚本一次。

样式

按名称提供以下四种样式及其简短描述。如果用户描述了他们自己的样式,则遵循用户描述。将颜色放入 COLORS 中,格式为 Color.dynamic(new Color(light), new Color(dark))。

样式描述背景色主色次要色强调色字体
Clean平静整洁,蓝色调#EAF3FF / #14202E#10253F / #FFFFFF#52657C / #A9B8C9#E88A00 / #FFC24DSystem
Minimal仅黑白#FFFFFF / #000000#000000 / #FFFFFF#6E6E73 / #98989D#000000 / #FFFFFFLight system, large values
Bold醒目的强色调#2F54EB / #1D39C4#FFFFFF / #FFFFFF#D6E0FF / #D6E0FF#FFD666 / #FFD666Bold system
Playful温暖友好#FFF1E6 / #2B1B14#4A2511 / #FFF1E6#9A6B4F / #D4B8A6#FF6B4A / #FF9A76Bold rounded system

脚本规则

  • 从模板开始并保持其结构。Scriptable 头部必须始终位于最顶部。
  • CONFIG 必须有一个简短的 title 作为其第一个条目。它将作为建议的脚本名称。用户可能想要更改的所有内容都应放在 CONFIG 中。
  • 保留 Script.setWidget(widget),并以 Script.complete(); 作为脚本的最后一行。
  • 设置 refreshAfterDate。iOS 决定小组件实际刷新的时间,因此绝不要承诺精确的刷新时间。至少使用 15 分钟。
  • 通过 buildErrorWidget 显示可读的错误信息。永远不要让小组件保持空白。
  • 对于网络数据,保留缓存以便小组件离线时仍能工作。
  • 仅使用 Scriptable 提供的内容。没有 fetch, document, window, require, localStorage, import 或 export。使用 Request 进行网络请求,使用 FileManager 进行文件操作。不能使用 npm 包。
  • 代码中不能包含三个反引号。
  • 小组件不能响应点击(除了打开 URL)、不能动画、不能显示 Alert。对于实时倒计时,使用 addDate 并配合 applyTimerStyle()。
  • 仅限主屏幕尺寸。如果有人请求锁屏小组件,则构建一个主屏幕小组件,并用一句话说明。

设计规则

  • 显示一条主要信息,大且在一瞥之下即可阅读。
  • 使用所有三种尺寸:小尺寸仅显示核心内容,中尺寸在旁边添加详细信息,大尺寸在下方添加更多内容。
  • 应用所选样式。使用 Color.dynamic 支持浅色和深色模式,并将颜色保持在 COLORS 中。
  • 使用 SF Symbols 作为图标,不要使用表情符号或下载的图片。
  • 最多使用三种文本尺寸和一种强调色。
  • 在文本上设置 lineLimit 和 minimumScaleFactor,确保文本不会尴尬地被截断。在脑海中测试长值。
  • 所有可见文本使用所选语言编写,包括错误和离线消息。将 CONFIG 中的 locale 设置为匹配的语言,例如 de_DE,这样 DateFormatter 也会使用该语言。

模板

替换 requestData、CONFIG 值、COLORS 和图标。根据内容调整布局函数。

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: blue; icon-glyph: star;

const CONFIG = {
  title: "My Widget",
  locale: "en_US", // 日期和星期几的语言
  refreshMinutes: 30,
  previewSize: "medium", // 在应用中运行时显示的大小: "small", "medium" 或 "large"
};

const COLORS = {
  background: Color.dynamic(new Color("#EAF3FF"), new Color("#14202E")),
  primary: Color.dynamic(new Color("#10253F"), new Color("#FFFFFF")),
  secondary: Color.dynamic(new Color("#52657C"), new Color("#A9B8C9")),
  accent: Color.dynamic(new Color("#E88A00"), new Color("#FFC24D")),
};

const ROW_COUNTS = { medium: 3, large: 6 };

// --- 数据 ---

function cachePath() {
  const fm = FileManager.local();
  return fm.joinPath(fm.libraryDirectory(), `${Script.name()}-cache.json`);
}

function saveCache(data) {
  FileManager.local().writeString(cachePath(), JSON.stringify({ savedAt: Date.now(), data }));
}

function readCache() {
  const fm = FileManager.local();
  const path = cachePath();
  if (!fm.fileExists(path)) return null;
  try {
    return JSON.parse(fm.readString(path));
  } catch {
    return null;
  }
}

// 替换此函数。它返回布局显示的内容。
async function requestData() {
  const request = new Request(
    "https://api.open-meteo.com/v1/forecast?latitude=50.94&longitude=6.96&current=temperature_2m&daily=temperature_2m_max&timezone=auto"
  );
  request.timeoutInterval = 10;
  const response = await request.loadJSON();

  if (response?.current?.temperature_2m === undefined) {
    throw new Error(response?.reason ?? "Unexpected data.");
  }
  return {
    symbol: "thermometer.medium",
    value: `${Math.round(response.current.temperature_2m)}°`,
    caption: "Köln",
    rows: response.daily.time.slice(1).map((day, index) => ({
      label: formatDate(new Date(`${day}T12:00:00`), "EEE"),
      value: `${Math.round(response.daily.temperature_2m_max[index + 1])}°`,
    })),
  };
}

// 如果可能则返回新数据,否则返回最后保存的数据。
async function loadData() {
  try {
    const data = await requestData();
    saveCache(data);
    return { data, savedAt: null };
  } catch (error) {
    const cache = readCache();
    if (cache?.data) return { data: cache.data, savedAt: cache.savedAt };
    throw error;
  }
}

// --- 绘图辅助函数 ---

function formatDate(date, pattern) {
  const formatter = new DateFormatter();
  formatter.locale = CONFIG.locale;
  formatter.dateFormat = pattern;
  return formatter.string(date);
}

function addLabel(stack, text, font, color) {
  const label = stack.addText(text);
  label.font = font;
  label.textColor = color;
  label.lineLimit = 1;
  label.minimumScaleFactor = 0.6;
  return label;
}

function addSymbol(stack, name, size, color) {
  const symbol = SFSymbol.named(name) ?? SFSymbol.named("questionmark.circle.fill");
  symbol.applyFont(Font.systemFont(size));
  const image = stack.addImage(symbol.image);
  image.imageSize = new Size(size, size);
  image.tintColor = color;
  return image;
}

// --- 布局 ---

function addMainContent(stack, { data, savedAt }) {
  addLabel(stack, CONFIG.title, Font.semiboldSystemFont(14), COLORS.primary);
  stack.addSpacer();
  addSymbol(stack, data.symbol, 30, COLORS.accent);
  stack.addSpacer(4);
  addLabel(stack, data.value, Font.boldSystemFont(38), COLORS.primary);
  const caption = savedAt ? `离线 · ${formatDate(new Date(savedAt), "HH:mm")}` : data.caption;
  addLabel(stack, caption, Font.systemFont(12), COLORS.secondary);
}

function addRow(stack, row) {
  const line = stack.addStack();
  line.layoutHorizontally();
  line.centerAlignContent();
  addLabel(line, row.label, Font.semiboldSystemFont(14), COLORS.primary);
  line.addSpacer();
  addLabel(line, row.value, Font.systemFont(14), COLORS.secondary);
}

function addRows(stack, rows) {
  stack.addSpacer();
  rows.forEach((row) => {
    addRow(stack, row);
    stack.addSpacer();
  });
}

function createWidget() {
  const widget = new ListWidget();
  widget.backgroundColor = COLORS.background;
  widget.setPadding(14, 16, 14, 16);
  widget.refreshAfterDate = new Date(Date.now() + CONFIG.refreshMinutes * 60 * 1000);
  return widget;
}

function buildSmallWidget(result) {
  const widget = createWidget();
  addMainContent(widget, result);
  return widget;
}

function buildMediumWidget(result) {
  const widget = createWidget();
  const columns = widget.addStack();
  columns.layoutHorizontally();

  const main = columns.addStack();
  main.layoutVertically();
  addMainContent(main, result);

  columns.addSpacer(24);

  const details = columns.addStack();
  details.layoutVertically();
  addRows(details, result.data.rows.slice(0, ROW_COUNTS.medium));
  return widget;
}

function buildLargeWidget(result) {
  const widget = createWidget();
  const main = widget.addStack();
  main.layoutVertically();
  main.size = new Size(0, 150);
  addMainContent(main, result);

  addRows(widget, result.data.rows.slice(0, ROW_COUNTS.large));
  return widget;
}

function buildErrorWidget(error) {
  const widget = createWidget();
  addSymbol(widget, "exclamationmark.triangle.fill", 26, COLORS.accent);
  widget.addSpacer(8);
  addLabel(widget, "无法加载数据", Font.semiboldSystemFont(14), COLORS.primary);
  widget.addSpacer(4);
  const message = addLabel(widget, error.message, Font.systemFont(12), COLORS.secondary);
  message.lineLimit = 3;
  return widget;
}

// --- 运行 ---

function currentSize() {
  const family = config.widgetFamily ?? CONFIG.previewSize;
  if (family === "medium") return "medium";
  if (family === "large" || family === "extraLarge") return "large";
  return "small";
}

async function buildWidget(size) {
  try {
    const result = await loadData();
    if (size === "medium") return buildMediumWidget(result);
    if (size === "large") return buildLargeWidget(result);
    return buildSmallWidget(result);
  } catch (error) {
    return buildErrorWidget(error);
  }
}

const size = currentSize();
const widget = await buildWidget(size);

Script.setWidget(widget);

if (!config.runsInWidget) {
  if (size === "medium") await widget.presentMedium();
  else if (size === "large") await widget.presentLarge();
  else await widget.presentSmall();
}

Script.complete();

API 参考

除非你确定它们存在于 Scriptable 中,否则只使用此处列出的成员。

ListWidget: addText(text), addDate(date), addImage(image), addSpacer(length?), addStack(), backgroundColor, backgroundImage, backgroundGradient, setPadding(top, leading, bottom, trailing), spacing, url, refreshAfterDate, presentSmall(), presentMedium(), presentLarge()

WidgetStack (来自 addStack()): 相同的 add… 方法, layoutHorizontally(), layoutVertically(), topAlignContent(), centerAlignContent(), bottomAlignContent(), size, spacing, backgroundColor, cornerRadius, borderWidth, borderColor, setPadding(…), url

WidgetText: font, textColor, textOpacity, lineLimit, minimumScaleFactor, leftAlignText(), centerAlignText(), rightAlignText(), url

WidgetDate: 同 WidgetText, 外加 applyTimeStyle(), applyDateStyle(), applyRelativeStyle(), applyOffsetStyle(), applyTimerStyle()

WidgetImage: imageSize, tintColor, imageOpacity, cornerRadius, resizable, applyFittingContentMode(), applyFillingContentMode()

Font: Font.systemFont(size), lightSystemFont, mediumSystemFont, semiboldSystemFont, boldSystemFont, boldRoundedSystemFont, regularMonospacedSystemFont

Color: new Color(hex, alpha?), Color.dynamic(light, dark), Color.white(), Color.black(), Color.clear()

LinearGradient: new LinearGradient(), colors, locations, startPoint, endPoint. 在渐变中使用纯色,不要使用 Color.dynamic。

SFSymbol: SFSymbol.named(name) (可能返回 null), applyFont(font), image

Size, Point: new Size(width, height), 其中 0 表示灵活。 new Point(x, y)。

DrawContext (用于图表和环): new DrawContext(), size, opaque, respectScreenScale, setFillColor(color), setStrokeColor(color), setLineWidth(width), fillRect(rect), fillEllipse(rect), addPath(path), strokePath(), fillPath(), getImage()。 Path: move(point), addLine(point)。 new Rect(x, y, width, height)。

Request: new Request(url), method, headers, body, timeoutInterval, loadJSON(), loadString(), loadImage(), response

FileManager: FileManager.local(), libraryDirectory(), joinPath(a, b), fileExists(path), readString(path), writeString(path, text)

DateFormatter: new DateFormatter(), locale, dateFormat, string(date)

Device: Device.batteryLevel() (0 到 1), Device.isCharging(), Device.name(), Device.systemVersion(), Device.model()

Location: Location.current() (需要一次性权限,需要 LocationWhenInUseUsageDescription 权限字符串)

CalendarEvent: CalendarEvent.between(start, end, calendars), title, startDate, endDate, location, notes

Reminder: Reminder.allCompleted(), Reminder.allIncomplete(), Reminder.completed(), title, notes, dueDate, isCompleted, completionDate

Safari: Safari.openInApp(url), Safari.open(url)

回复格式

直接回复脚本代码。不要添加额外文本。代码可以放在消息代码块或文件中。

相关技能