2Add CelesteCopy the widget code. In Scriptable, tap +, paste it, and name the script Celeste Streak.Preparing code…Download script insteadShow code for manual copying// AnimeTutor · Celeste Streak · v1.0 // Create a Scriptable script, paste this code, run it once, then add a small Scriptable widget. // Connect with a read-only key from https://www.animetutor.com/widget . Never paste your password. const HOME = 'https://www.animetutor.com'; const KEY = 'animetutor.celeste.connection.v1'; const fm = FileManager.local(); const folder = fm.joinPath(fm.libraryDirectory(), 'AnimeTutor-Celeste-v1'); if (!fm.fileExists(folder)) fm.createDirectory(folder, true); const snapshotPath = fm.joinPath(folder, 'streak.json'); const moods = { ready: ['Time to practise!', '#16c6b4', '#9ee6d7'], proud: ['Streak saved!', '#14b9cf', '#93e5e1'], curious: ['Ready to learn?', '#8457fa', '#b792fa'], worried: ['Save your streak!', '#e9378c', '#f077b4'], sleepy: ['One quick lesson?', '#ba2648', '#761b3d'], fresh: ["Let's start again!", '#264a91', '#12265c'], }; function clearSnapshot() { if (fm.fileExists(snapshotPath)) fm.remove(snapshotPath); } function savedSnapshot() { try { return JSON.parse(fm.readString(snapshotPath)); } catch { return null; } } function validSnapshot(value) { return value && Number.isInteger(value.streak) && value.streak >= 0 && value.streak <= 100000 && typeof value.lastDay === 'string' && (value.lastDay === '' || /^\d{4}-\d{2}-\d{2}$/.test(value.lastDay)); } function stateAt(snapshot, now = new Date()) { if (!validSnapshot(snapshot)) return {streak: null, mood: 'fresh'}; const today = now.toISOString().slice(0, 10); const yesterday = new Date(now.getTime() - 86400000).toISOString().slice(0, 10); const streak = [today, yesterday].includes(snapshot.lastDay) ? snapshot.streak : 0; const hour = now.getUTCHours(); const mood = snapshot.lastDay === today ? 'proud' : !streak ? 'fresh' : hour >= 23 ? 'sleepy' : hour >= 21 ? 'worried' : hour >= 16 ? 'curious' : 'ready'; return {streak, mood}; } async function fetchStreak(token) { const request = new Request(HOME + '/api/widget/streak'); request.headers = {Authorization: 'Bearer ' + token, Accept: 'application/json'}; request.timeoutInterval = 10; const value = await request.loadJSON(); if (request.response.statusCode === 401) { const error = new Error('Reconnect'); error.reconnect = true; throw error; } if (request.response.statusCode !== 200 || !validSnapshot(value)) throw new Error('Streak unavailable'); return value; } async function notify(title, message) { const alert = new Alert(); alert.title = title; alert.message = message; alert.addAction('OK'); await alert.presentAlert(); } async function connect() { const alert = new Alert(); alert.title = 'Connect Celeste'; alert.message = 'Copy your connection key from animetutor.com/widget and paste it here.'; alert.addSecureTextField('Connection key'); alert.addAction('Connect'); alert.addCancelAction('Cancel'); if (await alert.presentAlert() === -1) return false; const token = alert.textFieldValue(0).trim(); if (!/^cw1\.[A-Za-z0-9_-]{1,180}\.[A-Za-z0-9_-]{43}$/.test(token)) { await notify('Try again', 'Paste the whole connection key, not your password.'); return false; } try { const snapshot = await fetchStreak(token); clearSnapshot(); Keychain.set(KEY, token); fm.writeString(snapshotPath, JSON.stringify(snapshot)); await notify('Celeste is connected!', 'Add a small Scriptable widget to your Home Screen. Edit it and choose this script.'); return true; } catch (error) { await notify('Could not connect', error.reconnect ? 'This key has expired or been replaced. Create a new key on AnimeTutor.' : 'Check your connection and try again.'); return false; } } async function artwork(mood) { const path = fm.joinPath(folder, 'celeste-' + mood + '.png'); if (fm.fileExists(path)) { try { return fm.readImage(path); } catch { fm.remove(path); } } try { const request = new Request(HOME + '/widgets/art/celeste-' + mood + '.png'); request.timeoutInterval = 10; const image = await request.loadImage(); fm.writeImage(path, image); return image; } catch { const fallback = fm.joinPath(folder, 'celeste-ready.png'); try { return fm.readImage(fallback); } catch { return null; } } } function rgb(hex) { return [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)); } function backdrop(mood, image) { // Match the approved square composition. Text is a native widget overlay, never baked into art. const context = new DrawContext(); context.size = new Size(512, 512); context.opaque = true; context.respectScreenScale = false; const top = rgb(moods[mood][1]), bottom = rgb(moods[mood][2]); for (let y = 0; y < 512; y += 2) { const t = y / 511, shade = Math.max(0, 1 - t / 0.46) * 0.10; const hex = top.map((v, i) => Math.round((v + (bottom[i] - v) * t) * (1 - shade)).toString(16).padStart(2, '0')).join(''); context.setFillColor(new Color(hex)); context.fillRect(new Rect(0, y, 512, 2)); } if (image) context.drawImageInRect(image, new Rect(512 * 0.12, 512 * (['ready', 'proud', 'curious'].includes(mood) ? 0.28 : 0.31), 512 * 0.76, 512 * 0.76)); return context.getImage(); } async function main() { if (config.runsInApp) { if (!Keychain.contains(KEY)) { if (!await connect()) return; } else { const menu = new Alert(); menu.title = 'Celeste Streak'; menu.message = 'Your study buddy, on your Home Screen.'; menu.addAction('Preview widget'); menu.addAction('Change connection key'); menu.addDestructiveAction('Disconnect this phone'); menu.addCancelAction('Cancel'); const choice = await menu.presentSheet(); if (choice === -1) return; if (choice === 1 && !await connect()) return; if (choice === 2) { Keychain.remove(KEY); clearSnapshot(); await notify('Disconnected', 'To revoke this key everywhere, disconnect it on animetutor.com/widget.'); return; } } } let snapshot = Keychain.contains(KEY) ? savedSnapshot() : null; let status = snapshot ? '' : 'Connect Celeste'; if (Keychain.contains(KEY)) { try { snapshot = await fetchStreak(Keychain.get(KEY)); fm.writeString(snapshotPath, JSON.stringify(snapshot)); status = ''; } catch (error) { if (error.reconnect) { Keychain.remove(KEY); clearSnapshot(); snapshot = null; status = 'Reconnect widget'; } else if (!validSnapshot(snapshot)) status = 'Open app to sync'; } } const state = stateAt(snapshot); const widget = new ListWidget(); widget.setPadding(10, 5, 0, 5); widget.backgroundImage = backdrop(state.mood, await artwork(state.mood)); widget.url = HOME + (status ? '/widget' : '/learn'); widget.refreshAfterDate = new Date(Date.now() + 15 * 60000); const row = widget.addStack(); row.centerAlignContent(); row.spacing = 4; row.addSpacer(); const symbol = SFSymbol.named('flame.fill'); symbol.applyFont(Font.heavyRoundedSystemFont(25)); const flame = row.addImage(symbol.image); flame.imageSize = new Size(20, 25); flame.tintColor = Color.white(); const number = row.addText(state.streak === null ? '–' : String(state.streak)); number.font = Font.heavyRoundedSystemFont(30); number.textColor = Color.white(); number.minimumScaleFactor = 0.65; number.lineLimit = 1; row.addSpacer(); widget.addSpacer(3); const caption = widget.addText(status || moods[state.mood][0]); caption.font = Font.semiboldRoundedSystemFont(12); caption.textColor = Color.white(); caption.centerAlignText(); caption.lineLimit = 1; caption.minimumScaleFactor = 0.85; widget.addSpacer(); Script.setWidget(widget); if (config.runsInApp) await widget.presentSmall(); } await main(); Script.complete();
3Connect your streakRun Celeste Streak in Scriptable. Paste your connection key when it asks.Checking connection…
4Put Celeste on your Home ScreenTouch and hold your Home Screen. Choose Edit → Add Widget → Scriptable and pick the small square widget.Touch and hold the new widget, tap Edit Widget, and choose Celeste Streak as the script. You’re ready!