Communityコーディング&開発github.com

veritatron/scriptable-widget

一文からScriptableアプリ用のiPhoneホーム画面ウィジェットを構築します。ユーザーがiPhoneウィジェットやScriptableスクリプトを要求したときに使用します。

scriptable-widget とは?

このスキルは、ユーザーが一言で説明するだけで、Scriptableアプリ用の完全なJavaScriptウィジェットを自動生成します。ユーザーはコードを書く必要がなく、スキルがテンプレートからスクリプトを生成します。天気、為替レート、暗号通貨価格などの無料データソースを利用し、APIキーやログインは不要です。4つのプリセットスタイル(Clean、Minimal、Bold、Playful)から選択でき、ユーザーが独自のスタイルを指定することも可能です。スキルは、コンパクトで読みやすいデザインを重視し、小、中、大の3サイズに対応。ネットワークデータのキャッシュによりオフラインでも動作し、エラー時は適切なフォールバック表示を行います。このスキルは、Claude Code、Cursor、Codexなどのプラットフォームで使用できます。

対応~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に質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

Scriptable Widget Builder

一文を、Scriptable iPhoneアプリ用の完成したJavaScriptウィジェットに変換します。ユーザーはコードを書かないため、スクリプトの編集を依頼しないでください。

ワークフロー

  1. ウィジェットが何を表示し、データの出所はどこかを明確にします。
  2. 構築前に、短いフォローアップメッセージを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アプリでスクリプトを一度実行するようユーザーに指示します。

スタイル

以下の4つを名前と簡単な説明と共に提示します。ユーザーが独自のスタイルを説明した場合は、それに従います。色はColor.dynamic(new Color(light), new Color(dark))としてCOLORSに設定します。

スタイル説明背景プライマリセカンダリアクセントフォント
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も表示できません。ライブカウントダウンには、applyTimerStyle()を指定してaddDateを使用します。
  • ホーム画面サイズのみ対応します。ロック画面ウィジェットを要求された場合は、ホーム画面ウィジェットを構築し、その旨を一文で伝えます。

デザインルール

  • 主要な情報を1つ、大きくひと目で読めるように表示します。
  • 3サイズすべてを使用します:小は必須情報のみ、中はその横に詳細を追加、大はさらに下部に追加します。
  • 選択されたスタイルを適用します。Color.dynamicを使用してライトモードとダークモードに対応し、色はCOLORSに保持します。
  • アイコンには絵文字やダウンロード画像ではなく、SF Symbolsを使用します。
  • テキストサイズは最大3種類、アクセントカラーは1つまでとします。
  • テキストが不自然に途切れないように、lineLimitとminimumScaleFactorを設定します。頭の中で長い値をテストします。
  • 表示されるテキストはすべて選択された言語で記述します(エラーメッセージやオフラインメッセージを含む)。DateFormatterもその言語を使用するように、CONFIGのlocaleを一致させます(例:de_DE)。

テンプレート

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リファレンス

スクリプト内で存在が確実でない限り、ここにリストされているメンバーのみを使用します。

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.batteryLevel(), Device.isCharging(), Device.screenSize(), Device.name(), Device.systemVersion(), Device.model()

Script: Script.name(), Script.setWidget(widget), Script.complete()

config: config.runsInWidget (ブール値), config.widgetFamily ("small", "medium", "large", "extraLarge" のいずれか)

Date: 標準のJavaScript Date。new Date()、.getTime()、.toISOString()、.toLocaleDateString()のみを使用します。Math: 標準のメソッド(round、floor、ceil、min、max、abs)。

返信形式

以下の内容を含むMarkdownブロックで返信します。

  1. 準備完了 — 完了を示す絵文字。
  2. 設定内容の短い要約: titleと選択されたスタイル。
  3. スクリプトコード。Markdownコードブロックで囲みます。
  4. 可能であれば、最終的なtitleで保存するように指示します。
  5. ウィジェットにパーミッションが必要な場合は、その指示。

必要に応じて、ユーザーがウィジェットを編集せずに使用できるように、CONFIGの変更方法の例を1つ追加できます(例:「CONFIG.titleを変更してタイトルを編集します」)。

✅ **準備完了**

**天気ウィジェット** · Cleanスタイル, ドイツ語

```javascript
// 完全なスクリプトコード

title My Widget で保存してください。

Scriptableアプリで一度スクリプトを実行して、場所のパーミッションを許可してください。


## チェックリスト

スクリプトを送信する前に、以下のすべてを確認します。

- [ ] Scriptableヘッダーがファイルの先頭にある(コメントと`icon-color`/`icon-glyph`)
- [ ] 選択されたスタイルの色が`Color.dynamic`で設定されている
- [ ] 選択された言語のロケールが`CONFIG.locale`に設定されている
- [ ] `refreshAfterDate`が15分以上に設定されている
- [ ] `Script.setWidget(widget)`が存在する
- [ ] スクリプトの末尾は`Script.complete()`
- [ ] `buildErrorWidget`が実装されている
- [ ] ネットワークデータのキャッシュがある(`readCache` / `saveCache`)
- [ ] ネットワークデータが不要な場合、キャッシュ関数が削除されている
- [ ] アイコンにSF Symbolが使用されている(絵文字や画像ではない)
- [ ] テキストに`lineLimit`と`minimumScaleFactor`が設定されている
- [ ] 表示テキストが選択された言語で書かれている
- [ ] エラーメッセージが選択された言語で書かれている
- [ ] すべての3サイズ(小、中、大)が実装されている
- [ ] スクリプトにシークレット情報(APIキーなど)が含まれていない
- [ ] スクリプトに三重バッククォートが含まれていない
- [ ] テキストサイズが最大3種類
- [ ] アクセントカラーが1つだけ
- [ ] ロック画面ウィジェットではなくホーム画面ウィジェットである
- [ ] 主要な情報が1つ、大きく表示されている

関連スキル