Live preview
Try starting a session, then pausing or skipping the phase. When a phase ends the script flips to the next one automatically and the completed sessions counter increments.
Three phases — focus, short break, long break — automatically cycle
and a single Script drives the countdown. Phase changes
trigger a small audio beep and a notification if the user grants
permission. The entire UI (rings, controls, history) is plain
components.
Try starting a session, then pausing or skipping the phase. When a phase ends the script flips to the next one automatically and the completed sessions counter increments.
Note how every state transition is expressed as
Action([@Set(...)]) — the script doesn't decide policy,
it just runs the clock and signals when a phase ends by mutating
state.
$phase = "focus"
$remaining = 1500
$running = false
$completed = 0
$totalSeconds = 1500
ticker = Script("ticker", "if (!ctx.state.get('running')) return; const tick = async () => { if (ctx.signal.aborted) return; const r = (ctx.state.get('remaining') ?? 0) - 1; if (r <= 0) { ctx.state.set('remaining', 0); ctx.state.set('running', false); const next = await ctx.tools.advance_phase({ phase: ctx.state.get('phase'), completed: ctx.state.get('completed') ?? 0 }); if (ctx.signal.aborted) return; ctx.state.set('phase', next.phase); ctx.state.set('remaining', next.seconds); ctx.state.set('totalSeconds', next.seconds); ctx.state.set('completed', next.completed); } else { ctx.state.set('remaining', r); } }; const id = setInterval(tick, 1000); ctx.cleanup(() => clearInterval(id));", ["running", "phase"])
formatter = Script("formatter", "const r = ctx.state.get('remaining') ?? 0; const m = Math.floor(r / 60); const s = r % 60; const pad = (n) => String(n).padStart(2, '0'); ctx.state.set('clock', pad(m) + ':' + pad(s));", ["remaining"])
$clock = "25:00"
phaseLabel = $phase == "focus" ? "Focus" : ($phase == "short" ? "Short break" : "Long break")
phaseHint = $phase == "focus" ? "Deep work, no distractions." : ($phase == "short" ? "Stand up, look out the window." : "Step away. Stretch. Hydrate.")
phaseColor = $phase == "focus" ? "primary" : ($phase == "short" ? "success" : "info")
header = Card([
CardHeader(phaseLabel, phaseHint),
Stack([
Tag(phaseLabel, null, "sm", phaseColor),
Tag(@Count($completed) + " sessions today", null, "sm", "neutral")
], "row", "s", "center", "start", true)
], "elevated")
display = Card([TextContent($clock, "large-heavy")], "outlined")
primary = $running ? "Pause" : "Start"
primaryVariant = $running ? "secondary" : "primary"
controls = Buttons([
Button(primary, Action([@Set($running, !$running)]), primaryVariant),
Button("Skip phase", Action([
@Set($running, false),
@Js("const next = await ctx.tools.advance_phase({ phase: ctx.state.get('phase'), completed: ctx.state.get('completed') ?? 0 }); ctx.state.set('phase', next.phase); ctx.state.set('remaining', next.seconds); ctx.state.set('totalSeconds', next.seconds); ctx.state.set('completed', next.completed);")
]), "ghost"),
Button("Reset", Action([
@Set($running, false),
@Set($phase, "focus"),
@Set($remaining, 1500),
@Set($totalSeconds, 1500),
@Reset($completed)
]), "ghost")
])
progressPct = ($totalSeconds - $remaining) * 100 / $totalSeconds
progressLabel = "" + @Round(progressPct) + "% of " + phaseLabel + " complete"
progressTag = Tag(progressLabel, null, "sm", phaseColor)
settingsHint = TextContent("Tip: skipping advances to the next phase in the focus / short / focus / short / focus / long cycle.", "small", "muted")
root = Stack([header, display, progressTag, controls, settingsHint, ticker, formatter])
advance_phase works
Phase rotation belongs to the host page (so it can persist progress,
ring a bell, push a notification, etc.), so the script just calls
ctx.tools.advance_phase({}). The host implements the
policy:
// Pure host code: derive the next phase from the current one, fire a beep,
// return the new values. The script writes them back into `ctx.state`, which
// keeps the reactive UI in sync without exposing any private renderer
// internals. Reset becomes trivial because there's no host-side mutable state
// to clear.
el.setTools({
advance_phase: ({ phase, completed }) => {
const nextCompleted = phase === "focus" ? (completed ?? 0) + 1 : (completed ?? 0);
const isLong = phase === "focus" && nextCompleted % 4 === 0;
const next = phase === "focus" ? (isLong ? "long" : "short") : "focus";
const seconds = ({ focus: 25 * 60, short: 5 * 60, long: 15 * 60 })[next];
playChime();
return { phase: next, seconds, completed: nextCompleted };
},
});