Собрали слова поддержки от разных людей из дизайн-индустрии в одном месте.
Чтобы установить, скачай бесплатное приложение Scriptable из App Store.
Бесплатное приложение из App Store. На нём работают кастомные виджеты.
Открой код ниже, скопируй. В Scriptable нажми «+», вставь и назови «Рядом».
Долгий тап по экрану → «+» → Scriptable → medium → Edit Widget → Script: «Рядом». Готово.
// ============================================================================
// CREATIVE FUEL — Scriptable widget (финал)
//
// Цитата — Droid Serif, подпись — JetBrains Mono Medium. Оба шрифта зашиты
// в данные как ВЕКТОРНЫЕ КОНТУРЫ, поэтому ставить шрифты НЕ нужно — ни тебе,
// ни любому пользователю. Работает офлайн (кэш).
//
// НАСТРОЙКА (один раз):
// 1. Обнови creative-fuel-data.json на своём гисте (тот же URL ниже).
// 2. Скопируй весь этот скрипт в Scriptable (новый скрипт «+»).
// 3. Запусти один раз при интернете → на домашний экран (крупный размер).
//
// ОБНОВЛЕНИЕ ЦИТАТ: меняешь только JSON на гисте, скрипт не трогаешь.
// ПАРАМЕТРЫ (поле Parameter): a=14 · i=5 · rnd
// ============================================================================
const DATA_URL = "https://gist.githubusercontent.com/valery2teta/2f0f0270ebdbe24c3605af784d38bd81/raw/creative-fuel-data.json";
// ===== КОНФИГ (референс в px при ширине карточки 340; масштабируется под устройство) =====
const REF_W = 340, PAD = 28;
const ROTATE_MIN = 15; // как часто просим iOS обновить (мин); ~практический минимум для виджетов iOS
const CENTER_Y = -0.15; // центр радиала по высоте (отрицательный = выше верхнего края, круг уходит за кадр)
const TONE_MIX = 0.6; // тон центра подтягиваем к следующему слою (0..1) — растворяет контур круга
const QUOTE_BIG = 46, QUOTE_SMALL = 32, QUOTE_MIN = 20;
const QUOTE_TRACK = -0.03, QUOTE_LEAD = 0.94, SHORT_LEN = 50; // Droid Serif: −3% / 94%
let AUTHOR_SIZE = 18;
const SIG_TRACK = -0.02, SIG_GAP = 6; // JetBrains Mono: −2%
const AUTHOR_OPACITY = 0.5, AUTHOR_BOTTOM = 26;
// ===== ЗАГРУЗКА ДАННЫХ (с офлайн-кэшем) =====
const fm = FileManager.local();
const cachePath = fm.joinPath(fm.cacheDirectory(), "creative-fuel-data.json");
let data = null;
try {
const r = new Request(DATA_URL); r.timeoutInterval = 10;
data = await r.loadJSON();
fm.writeString(cachePath, JSON.stringify(data));
} catch (e) {
if (fm.fileExists(cachePath)) data = JSON.parse(fm.readString(cachePath));
}
if (!data) { await showError("Нет данных. Проверь DATA_URL и запусти при интернете."); return; }
const Q = data.quote; // Droid Serif: {upm, cap, em, glyphs}
const S = data.sig; // JetBrains Mono
const quotes = data.quotes, palettes = data.palettes, brand = data.brand;
// ===== ХЕЛПЕРЫ =====
function parseParams(s) {
const o = {}; if (!s) return o;
s.split(/[;,]/).forEach(t => { t = t.trim(); const kv = t.split("=");
if (kv.length === 2) o[kv[0].trim()] = kv[1].trim(); else if (t) o[t] = true; });
return o;
}
function bindShortWords(str) {
const t = str.split(" "); let out = "";
for (let i = 0; i < t.length; i++) { out += t[i];
if (i < t.length - 1) out += /^[а-яёa-z]{1,2}$/i.test(t[i]) ? "\u00A0" : " "; }
return out;
}
function advOf(font, ch, size) { return (font.em[ch] != null ? font.em[ch] : 0.6) * size; }
// раскладка цитаты в строки с позициями символов (учитывая трекинг)
function layoutQuote(disp, size, maxW) {
const track = QUOTE_TRACK * size, spaceAdv = advOf(Q, " ", size) + track;
const words = disp.split(" "); const lines = [{ chars: [], width: 0 }]; let cur = lines[0];
for (const w of words) {
const adv = []; let ww = 0;
for (const ch of w) { const a = advOf(Q, ch, size) + track; adv.push({ c: ch, adv: a }); ww += a; }
const need = (cur.width > 0 ? spaceAdv : 0) + ww;
if (cur.width > 0 && cur.width + need > maxW) { cur = { chars: [], width: 0 }; lines.push(cur); }
let x = cur.width > 0 ? cur.width + spaceAdv : 0;
for (const a of adv) { cur.chars.push({ c: a.c, x }); x += a.adv; }
cur.width = x;
}
let mw = 0; for (const l of lines) mw = Math.max(mw, l.width);
return { lines, maxLineW: mw };
}
function fitQuote(disp, start, min, maxW, maxH) {
for (let s = Math.round(start); s >= min; s--) {
const L = layoutQuote(disp, s, maxW);
const blockH = (L.lines.length - 1) * s * QUOTE_LEAD + Q.cap * s;
if (blockH <= maxH && L.maxLineW <= maxW + 0.5) return { size: s, leading: s * QUOTE_LEAD, blockH, lines: L.lines };
}
const s = min, L = layoutQuote(disp, s, maxW);
return { size: s, leading: s * QUOTE_LEAD, blockH: (L.lines.length - 1) * s * QUOTE_LEAD + Q.cap * s, lines: L.lines };
}
function sigWidth(text, size) { let w = 0; for (const ch of text) w += advOf(S, ch, size) + SIG_TRACK * size; return w; }
function fitSig(lines, maxW, k) {
let s = AUTHOR_SIZE * k;
while (s > 10 * k && lines.some(ln => sigWidth(ln, s) > maxW)) s -= 1;
return s;
}
function buildOrder(q) {
const rem = q.map((_, i) => i); const order = []; let last = null;
while (rem.length) {
const cnt = {}; rem.forEach(i => cnt[q[i].a] = (cnt[q[i].a] || 0) + 1);
let pool = rem.filter(i => q[i].a !== last); if (!pool.length) pool = rem.slice();
pool.sort((x, y) => cnt[q[y].a] - cnt[q[x].a]);
const pick = pool[0]; order.push(pick); last = q[pick].a; rem.splice(rem.indexOf(pick), 1);
}
return order;
}
// 5 обычных → 1 брендовая (текст бренда чередуется)
function buildDisplay(order) {
const disp = []; let sc = 0;
order.forEach((qIdx, i) => {
disp.push({ special: false, qIdx });
if ((i + 1) % 5 === 0) { disp.push({ special: true, sIdx: sc % brand.cards.length }); sc++; }
});
return disp;
}
function cardSize(fam) {
const sc = Device.screenSize(), L = Math.round(Math.max(sc.width, sc.height));
const t = { 956:{s:170,m:[364,170],l:[364,382]},932:{s:170,m:[364,170],l:[364,382]},
926:{s:170,m:[364,170],l:[364,382]},896:{s:169,m:[360,169],l:[360,379]},
874:{s:162,m:[344,162],l:[344,366]},852:{s:158,m:[338,158],l:[338,354]},
844:{s:158,m:[338,158],l:[338,354]},812:{s:155,m:[329,155],l:[329,345]},780:{s:155,m:[329,155],l:[329,345]} };
const d = t[L] || { s: 158, m: [338, 158], l: [338, 354] };
if (fam === "small") return { w: d.s, h: d.s };
if (fam === "medium") return { w: d.m[0], h: d.m[1] };
return { w: d.l[0], h: d.l[1] };
}
function drawGlyph(ctx, contours, ox, baseY, scale, color) {
const path = new Path();
for (const c of contours) {
const st = c[0];
path.move(new Point(ox + st[0] * scale, baseY - st[1] * scale));
for (let i = 1; i < c.length; i++) {
const g = c[i];
if (g[0] === 0) path.addLine(new Point(ox + g[1] * scale, baseY - g[2] * scale));
else if (g[0] === 1) path.addQuadCurve(new Point(ox + g[3] * scale, baseY - g[4] * scale), new Point(ox + g[1] * scale, baseY - g[2] * scale));
else path.addCurve(new Point(ox + g[5] * scale, baseY - g[6] * scale), new Point(ox + g[1] * scale, baseY - g[2] * scale), new Point(ox + g[3] * scale, baseY - g[4] * scale));
}
path.closeSubpath();
}
ctx.addPath(path); ctx.setFillColor(color); ctx.fillPath();
}
// строка подписи вектором (JetBrains Mono), базовая линия baseY
function drawSig(ctx, text, size, ox, baseY, color) {
const scale = size / S.upm; let x = 0;
for (const ch of text) {
const g = S.glyphs[ch]; if (g) drawGlyph(ctx, g, ox + x, baseY, scale, color);
x += advOf(S, ch, size) + SIG_TRACK * size;
}
}
async function showError(msg) {
const w = new ListWidget(); w.backgroundColor = new Color("#1F1F21");
const t = w.addText(msg); t.textColor = Color.white(); t.font = Font.systemFont(13);
if (config.runsInWidget) Script.setWidget(w); else await w.presentLarge();
Script.complete();
}
// радиальный градиент в холсте (в Scriptable нет нативного) — центр 50% / 5%
function _hex2rgb(h){const n=parseInt(h.slice(1),16);return [(n>>16)&255,(n>>8)&255,n&255];}
function _blendHex(a,b,f){const A=_hex2rgb(a),B=_hex2rgb(b),to=v=>Math.round(Math.max(0,Math.min(255,v))).toString(16).padStart(2,'0');return '#'+[0,1,2].map(k=>to(A[k]+(B[k]-A[k])*f)).join('');}
function _col(rgb){const to=v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,'0');return new Color('#'+to(rgb[0])+to(rgb[1])+to(rgb[2]));}
function gradColor(grad,gstops,t){
const s=gstops.map(x=>x/100), rgb=grad.map(_hex2rgb);
if(t<=s[0]) return rgb[0];
for(let i=0;i<s.length-1;i++){ if(t<=s[i+1]){ const f=(t-s[i])/(s[i+1]-s[i]); return [0,1,2].map(k=>rgb[i][k]+(rgb[i+1][k]-rgb[i][k])*f); } }
return rgb[rgb.length-1];
}
// градиент рисуем в НИЗКОМ разрешении и растягиваем — интерполяция iOS сглаживает ступени (фикс бандинга)
function _lowres(W,H,grad,gstops,mode){
const SC=6, lw=Math.max(2,Math.round(W/SC)), lh=Math.max(2,Math.round(H/SC));
const lc=new DrawContext(); lc.size=new Size(lw,lh); lc.opaque=true; lc.respectScreenScale=false;
if(mode==='lin'){
for(let y=0;y<lh;y++){ lc.setFillColor(_col(gradColor(grad,gstops,y/(lh-1)))); lc.fillRect(new Rect(0,y,lw,1)); }
} else {
const g = [_blendHex(grad[0],grad[1],TONE_MIX), grad[1], grad[2], grad[3]]; // OPTION2: тон центра ближе к соседнему
const cx=0.5*lw, cy=CENTER_Y*lh, R=Math.sqrt(Math.max(cx,lw-cx)**2+Math.max(cy,lh-cy)**2);
lc.setFillColor(_col(gradColor(g,gstops,1))); lc.fillRect(new Rect(0,0,lw,lh));
const N=120; for(let i=N;i>=1;i--){ const t=i/N, r=R*t; lc.setFillColor(_col(gradColor(g,gstops,t))); lc.fillEllipse(new Rect(cx-r,cy-r,2*r,2*r)); }
}
return lc.getImage();
}
function drawRadial(ctx,W,H,grad,gstops,mode){ ctx.drawImageInRect(_lowres(W,H,grad,gstops,mode||'rad'), new Rect(0,0,W,H)); }
// ===== ВЫБОР КАРТОЧКИ =====
const P = parseParams(args.widgetParameter);
if (P.a) AUTHOR_SIZE = parseFloat(P.a);
const GMODE = (P.g === 'lin') ? 'lin' : 'rad'; // g=lin — линейный градиент для сравнения
const display = buildDisplay(buildOrder(quotes));
let idx;
if (P.i != null && /^\d+$/.test(P.i)) idx = parseInt(P.i) % display.length;
else if (P.rnd) idx = Math.floor(Math.random() * display.length);
else idx = Math.floor(Math.random() * display.length); // новая случайная карточка при каждом обновлении iOS
const entry = display[idx];
const special = entry.special;
const pal = palettes[idx % palettes.length];
const quoteText = special ? brand.cards[entry.sIdx].t : quotes[entry.qIdx].t;
// ===== РЕНДЕР =====
const fam = config.widgetFamily || "large";
const cs = cardSize(fam), k = cs.w / REF_W;
const ctx = new DrawContext();
ctx.size = new Size(cs.w, cs.h); ctx.opaque = false; ctx.respectScreenScale = true;
if (pal.grad) drawRadial(ctx, cs.w, cs.h, pal.grad, pal.gstops, GMODE); else { ctx.setFillColor(new Color(pal.bg)); ctx.fillRect(new Rect(0, 0, cs.w, cs.h)); } // фон: радиал; скругление — от системы
const pad = PAD * k;
const maxW = cs.w - 2 * pad;
const sigLines = special ? 2 : 1;
const authRes = (AUTHOR_BOTTOM + sigLines * AUTHOR_SIZE + (sigLines - 1) * SIG_GAP + 14) * k;
const maxH = cs.h - pad - authRes;
// ЦИТАТА — Droid Serif, векторно
const disp = "«" + bindShortWords(quoteText) + "»";
const L = fitQuote(disp, (quoteText.length <= SHORT_LEN ? QUOTE_BIG : QUOTE_SMALL) * k, QUOTE_MIN * k, maxW, maxH);
const fg = new Color(pal.fg);
const qScale = L.size / Q.upm, capH = Q.cap * L.size;
const blockVis = (L.lines.length - 1) * L.leading + capH;
const regionTop = pad, regionBot = cs.h - authRes;
const startTop = regionTop + Math.max(0, (regionBot - regionTop - blockVis) / 2);
const baseline0 = startTop + capH;
for (let i = 0; i < L.lines.length; i++) {
const baseY = baseline0 + i * L.leading;
for (const c of L.lines[i].chars) { const g = Q.glyphs[c.c]; if (g) drawGlyph(ctx, g, pad + c.x, baseY, qScale, fg); }
}
// ПОДПИСЬ — JetBrains Mono, векторно, +12%
const muted = new Color(pal.fg, AUTHOR_OPACITY);
if (special) {
const sSize = fitSig([brand.cards[entry.sIdx].sub, brand.attr], maxW, k);
const attrBase = cs.h - AUTHOR_BOTTOM * k;
const subBase = attrBase - (sSize + SIG_GAP * k);
drawSig(ctx, brand.cards[entry.sIdx].sub, sSize, pad, subBase, muted); // подстрока — приглушённо
drawSig(ctx, brand.attr, sSize, pad, attrBase, fg); // бренд — 100% непрозрачность
} else {
const line = "— " + quotes[entry.qIdx].a.toUpperCase();
const sSize = fitSig([line], maxW, k);
drawSig(ctx, line, sSize, pad, cs.h - AUTHOR_BOTTOM * k, muted);
}
// ===== ВЫВОД =====
const widget = new ListWidget();
widget.setPadding(0, 0, 0, 0);
widget.backgroundColor = new Color(pal.bg);
widget.backgroundImage = ctx.getImage();
widget.refreshAfterDate = new Date(Date.now() + ROTATE_MIN*60*1000); // просим iOS обновить через ~30 мин
if (config.runsInWidget) Script.setWidget(widget); else await widget.presentLarge();
Script.complete();
Скопируй код в новый скрипт Scriptable и назови его «Creative Fuel».
После модерации свяжемся с тобой.
Там ежедневно публикуем поддержку от наших авторов и все обновления виджета.
Перейти в канал
Мульти-дизайнер и автор канала про развитие креативности
visuaaaals
Диджитал-дизайнер и создатель проекта spros
spros