Community코딩 & 개발github.com

Scriptable 위젯 빌더

한 문장으로 Scriptable 앱용 iPhone 홈 화면 위젯을 바로 만들어줍니다. iPhone 위젯이나 Scriptable 스크립트를 요청할 때 사용하세요.

Scriptable 위젯 빌더란 무엇인가요?

이 스킬은 사용자의 한 문장 설명을 기반으로 Scriptable 앱(iOS)용 JavaScript 위젯을 완전히 생성합니다. 사용자가 코딩할 필요 없이, 스타일(깔끔, 미니멀, 볼드, 플레이풀)과 언어를 묻는 단 한 번의 짧은 확인 후 바로 빌드합니다. API 키나 계정이 필요 없는 무료 데이터 소스(Open-Meteo 날씨/대기질, CoinGecko 암호화폐 가격, Frankfurter 환율 등)를 사용하며, 캐싱을 통해 오프라인에서도 작동합니다. 최종 결과물은 SF Symbols 아이콘, 다크/라이트 모드 지원, 세 가지 위젯 크기(소형/중형/대형)를 갖춘 완성된 JavaScript 코드로 제공되며, 사용자는 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에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Scriptable 위젯 빌더은(는) 무엇을 하나요?

한 문장으로 Scriptable iPhone 앱용 JavaScript 위젯을 완성합니다. 사용자는 코딩하지 않으므로, 절대로 스크립트를 수정하라고 요청하지 마세요.

작업 흐름

  1. 위젯이 무엇을 표시하고 데이터가 어디서 오는지 파악합니다.
  2. 빌드하기 전에 짧은 확인 메시지를 한 번만 보냅니다. 위젯의 언어와 스타일(아래 스타일 중에서 선택)을 묻고, 날씨 위젯을 위한 장소처럼 위젯을 빌드하는 데 반드시 필요한 정보가 있다면 함께 물어보세요. 사용자가 이미 말한 내용은 생략하고, 이미 모든 것을 말했다면 바로 빌드하세요. 두 번째 확인 메시지는 보내지 마세요. 나머지 모든 것에는 합리적인 기본값을 선택하세요.
  3. 아래 템플릿을 기반으로 스크립트를 작성합니다.
  4. 체크리스트를 확인합니다.
  5. "응답 형식"에 설명된 형식으로 응답합니다.

데이터 소스

API 키, 계정 또는 로그인이 필요한 서비스는 절대 사용하지 마세요. 스크립트에는 비밀이 포함되어서는 안 됩니다. 요청이 그러한 서비스에 의존하는 경우, 가장 가까운 무료 소스를 사용하고 그 사실을 한 문장으로 알려주세요. 사용할 수 있는 무료 소스가 없다면, 무엇을 할 수 없는지 말하고 가장 유사한 위젯을 제안하세요.

기본 데이터(native data)를 우선 사용하고, 다음 무료 소스를 선호하세요:

데이터소스
날씨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 앱에서 스크립트를 한 번 실행하라고 알려주세요.

스타일

네 가지 스타일을 이름과 간단한 설명과 함께 제안하세요. 사용자가 자신만의 스타일을 설명하면 그 스타일을 따르세요. 색상은 Color.dynamic(new Color(라이트), new Color(다크)) 형태로 COLORS에 넣으세요.

스타일설명배경기본보조강조글꼴
깔끔함 (Clean)차분하고 정돈된, 파란색 톤#EAF3FF / #14202E#10253F / #FFFFFF#52657C / #A9B8C9#E88A00 / #FFC24D시스템
미니멀 (Minimal)흑백만 사용#FFFFFF / #000000#000000 / #FFFFFF#6E6E73 / #98989D#000000 / #FFFFFF라이트 시스템, 큰 값
볼드 (Bold)강렬하고 튀는 색상#2F54EB / #1D39C4#FFFFFF / #FFFFFF#D6E0FF / #D6E0FF#FFD666 / #FFD666볼드 시스템
플레이풀 (Playful)따뜻하고 친근한#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를 사용하세요.
  • 홈 화면 크기만 지원합니다. 잠금 화면 위젯을 요청하면 홈 화면 위젯을 만들고 그 사실을 한 문장으로 알려주세요.

디자인 규칙

  • 한눈에 알아보기 쉽고 크게 읽을 수 있는 하나의 주요 정보를 표시하세요.
  • 세 가지 크기를 모두 사용하세요: 작은 크기(small)는 필수 정보만, 중간 크기(medium)는 옆에 세부 정보를 추가, 큰 크기(large)는 아래에 더 많은 정보를 추가합니다.
  • 선택한 스타일을 적용하세요. 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", // 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, "데이터를 불러올 수 없습니다", 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(), Device.isLowPowerModeEnabled(), Device.name(), Device.systemVersion(), Device.screenResolution(), Device.preferredLanguage(), Device.model()

Location: Location.current() (권한 필요, 정확도 10m), Location.setAccuracyToTenMeters(), Location.setAccuracyToKilometer(), Location.setAccuracyToHundredMeters(), Location.setAccuracyToThreeKilometers()

CalendarEvent: CalendarEvent.today(calendars?), CalendarEvent.thisWeek(calendars?), title, startDate, endDate, location, notes, calendar, identifier

Reminder: Reminder.allIncomplete(calendars?), Reminder.allCompleted(calendars?), title, dueDate, notes, isCompleted, calendar, identifier

Calendar: Calendar.defaultForEvents(), Calendar.defaultForReminders(), Calendar.forEventsByTitle(title), Calendar.forRemindersByTitle(title), title, color, identifier

Date: 표준 JavaScript Date. Scriptable에는 scriptName(), name(), setWidget(widget), complete()가 있습니다. config에는 widgetFamily가 있습니다 (문자열: "small", "medium", "large", "extraLarge").

응답 형식

  • 위젯의 미리보기 이미지를 표시합니다(presentSmall/Medium/Large 실행 직후의 스크린샷). 네트워크 데이터의 경우 미리보기에 데이터가 표시되어야 하지만 앱에서는 제대로 작동합니다.
  • 스크립트 이름이라고 씁니다 – CONFIG의 title입니다.
  • 코드 블록으로 전체 JavaScript를 표시합니다. 사용자가 Scriptable로 직접 가져올 수 있도록 javascript: URL 딥 링크도 제공합니다. 예: [Scriptable로 가져오기](javascript:...). 사용자에게 Scriptable에서 실행하라고 말하지 마세요. 위젯으로 추가해야 한다고 알려주세요.
  • 엄격한 문자 제한이 있는 상점 설명을 작성합니다: 80자 제목과 140자 부제목. 간결하게 홍보하세요.

체크리스트

응답을 보내기 전에 이 목록을 하나씩 확인하세요.

  • 데이터는 무료 소스에서 오며 스크립트에 비밀(API 키, 토큰 등)이 포함되어 있지 않습니다.
  • CONFIG에 짧은 title이 있으며 모든 사용자 정의 가능 항목이 거기에 있습니다.
  • COLORS가 존재하며 Color.dynamic을 사용합니다.
  • 오류 및 오프라인 메시지가 선택한 현지 언어로 되어 있습니다.
  • CONFIG.locale이 선택한 언어와 일치합니다 (예: de_DE).
  • 텍스트가 lineLimit과 minimumScaleFactor를 설정했습니다.
  • 아이콘은 SF Symbols이며 기능을 나타냅니다.
  • 세 가지 위젯 크기(소형, 중형, 대형)가 존재하며 각각 고유한 가치를 제공합니다.
  • refreshAfterDate가 설정되었으며 15분 미만이 아닙니다.
  • 네트워크 데이터는 캐시됩니다.
  • 스크립트가 Script.complete();로 끝납니다.
  • 코드에 세 개의 백틱이 포함되어 있지 않습니다.

관련 스킬