Community程式設計與開發github.com

veritatron/scriptable-widget

將一句話轉換成 Scriptable 應用程式的 iPhone 主畫面小組件。當有人要求 iPhone 小工具或 Scriptable 腳本時使用。

scriptable-widget 是什麼?

此技能可讓 AI 代理程式根據單一自然語言句子,為 Scriptable iOS 應用程式建立功能完整的 JavaScript 小組件。它透過結構化工作流程運作:解析使用者意圖、在必要時提出一個簡短的追問(語言和樣式),若所有必要資訊都足夠則直接建置。此技能提供四種預先定義的樣式(簡潔、極簡、大膽、活潑),支援明暗模式,並優先使用無 API 金鑰的免費資料來源,例如 Open-Meteo(天氣、空氣品質)、Frankfurter(匯率)和 CoinGecko(加密貨幣)。生成的程式碼遵循嚴格的架構,包含可設定的 CONFIG、內建快取機制和錯誤處理,確保小組件離線工作且不會顯示空白。它支援小型、中型和大型主畫面尺寸,使用 SF 符號作為圖示,並完全以使用者的語言呈現文字。代理程式從不要求使用者編輯腳本,而是提供一個可直接在 Scriptable 應用程式中使用的即用型小工具。

相容平台~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 Widget Builder

將一句話轉換為 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))。

樣式描述背景主要次要強調字型
簡潔平靜整潔,藍色調#EAF3FF / #14202E#10253F / #FFFFFF#52657C / #A9B8C9#E88A00 / #FFC24D系統
極簡僅限黑白#FFFFFF / #000000#000000 / #FFFFFF#6E6E73 / #98989D#000000 / #FFFFFF輕系統,大數值
大膽鮮明突出的色彩#2F54EB / #1D39C4#FFFFFF / #FFFFFF#D6E0FF / #D6E0FF#FFD666 / #FFD666粗體系統
活潑溫暖友善#FFF1E6 / #2B1B14#4A2511 / #FFF1E6#9A6B4F / #D4B8A6#FF6B4A / #FF9A76粗圓系統

腳本規則

  • 從範本開始並保持其結構。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。對於即時倒數計時,請使用帶有 applyTimerStyle() 的 addDate。
  • 僅限主畫面尺寸。如果有人要求鎖定畫面小組件,請建立一個主畫面小組件並用一句話說明。

設計規則

  • 顯示一個主要的資訊片段,字型大且一目了然。
  • 使用所有三種尺寸:小型僅顯示基本資訊,中型在旁邊添加詳細資訊,大型則在下方添加更多內容。
  • 套用所選樣式。使用 Color.dynamic 支援明暗模式,並將顏色保留在 COLORS 中。
  • 使用 SF Symbols 作為圖示,而不是表情符號或下載的圖片。
  • 最多使用三種文字大小和一種強調色。
  • 對文字使用 lineLimit 和 minimumScaleFactor,以免文字被不自然地截斷。在心中測試長數值。
  • 以所選語言書寫所有可見文字,包括錯誤和離線訊息。在 CONFIG 中設定 locale 以匹配,例如 zh_TW,這樣 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", // Language for dates and weekdays
  refreshMinutes: 30,
  previewSize: "medium", // Size shown when run in the app: "small", "medium" or "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 };

// --- Data ---

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;
  }
}

// Replace this function. It returns what the layout shows.
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])}°`,
    })),
  };
}

// Returns fresh data if possible and the last saved data otherwise.
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;
  }
}

// --- Drawing helpers ---

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;
}

// --- Layout ---

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 ? `Offline · ${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, "Couldn't load the data", Font.semiboldSystemFont(14), COLORS.primary);
  widget.addSpacer(4);
  const message = addLabel(widget, error.message, Font.systemFont(12), COLORS.secondary);
  message.lineLimit = 3;
  return widget;
}

// --- Run ---

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()、Dev

…

[已截斷以利翻譯]

相關技能