Design reference
How to make PopCraft designs in code, as the official templates are made: the kit by topic, editing existing designs, making the decisions, and worked examples that build with no check failing.
Design reference
This is the reference an AI agent uses to make designs in code, inside the editor (it reads the same text with
read_design_reference) or from a terminal with the command line. The method — build, check, look, fix —
is in Designing with an AI agent. Generated from lib/agent/design-script/reference.ts; do not edit
it here.
How a design script works
A design script is plain JavaScript that builds a design with the template kit. It runs in a sandbox; every kit call is recorded and then made for real, so the whole design lands in one step.
kis the kit, building intok.sheet: the frame the design starts as, at the size you asked for (W×H, namedNAME).- Helper modules:
web(pages),beat(music),drawandmarks(paths),texture(glows, halos, shadows),captions,pointer,windows(app and browser windows),devices,motion,overlays,fit,type(font objects:type.SERIF,type.MONO),social(post and feed variants, outros, pills, logo badges),video(reel sheets and cuts, camera moves, portrait slots, animated grids),site(page type scales, responsive columns, compact nav, footers),effects(shader backdrops);easefor curves;hex()for a colour;preset()for a size. Outside the editor the same modules are@popcraft/kit's exports. - Plain JavaScript runs as usual: loops, arrays,
Math, your own functions and data. Write the content as data (a list of plans, dates, rows) and loop over it. - What the kit hands back (an id, a paint, a colour, the result of a helper) is a reference. Pass it to the kit, or
index it (
k.named(id, 'Hook')[0],music.beat), but never read it as a number or a word, add to it or put it in text. Do arithmetic on your own numbers (W,H, your constants). - Callbacks (
k.component(name, o, id => …), a variant'sadjust) are fine: they run when the kit calls them. TRACKSis plain data you can do arithmetic on:TRACKS.tension.beatMs(500),.bpm,.durationMsfor every track. Use it for beat maths (const beat = n => n * TRACKS.tension.beatMs);beat.bed(…)'s results are references.- The kit as a library:
npm i @popcraft/kit(the CLI installs it too) is the same Kit and helpers, with full types. Start a script with/// <reference types="@popcraft/kit/design-script" />for completion and checking, or build in your own code:runDesign(script, { size })runs a script in a sandbox and returns the document and the checks,toPopcraft(document)gives the text of a .popcraft file, andKit,preset,web,beat… are exported to use directly. Working projects that use it: getpopcraft/examples (event cards from data, a design check for CI, a typed script). - A mistake comes back with its line number and nothing is placed until the script builds. A failure while the design is laid out (after the script has run) points at the last kit call; look for a wrong value in a layer's options.
Sizes (preset ids): ig-story 1080×1920, ig-post 1080×1080, ig-portrait 1080×1350, video-1080p 1920×1080,
web-desktop 1440 wide (laptop 1280, tablet 768, mobile 375), og-image 1200×630, github-social 1280×640, x-post 1600×900,
email-header 600×300, ad-landscape 1200×628, ad-square 1200×1200, linkedin-post 1200×627, iphone-17 402×874,
android-large 412×915; or { width, height }.
Colour and type
Call these first. Every colour after them is a role name, so a user's brand kit re-skins the design.
k.brand({ paper: '#F4F1EA', text: '#141414', primary: '#E8361E', onPrimary: '#FFFFFF', card: '#FFFFFF', onCard: '#141414' }, { name: 'Acme', site: 'acme.example' })— brand roles, and brand fields used in text as{{brand.name}},{{brand.site}}. Give every fill colour its "on" colour for the text on it (primary/onPrimary,card/onCard).- Which roles a brand kit re-skins: a user's kit has
primary,secondary,accent,backgroundandtext;paperis read asbackgroundandinkastext; anyon<Role>(onPrimary, onCard…) gets an ink that reads on that role. Other role names (card,muted,line) keep your colour under a kit, so check them under both review kits; put words intext/ink/ anon…role so they always read. k.palette({ sea: '#1B4B5A', sand: '#E9D8B4' })— scenery colours a brand kit should NOT swap (an illustration's).k.textStyle('Hook', { size: 120, fontWeight: 900, lineHeight: 112, letterSpacing: -4, fontFamily: 'Inter', fontFeatures: { tnum: 1 }, textAlign: 'CENTER' })— a named style; texts use it by name. Measured faces: Inter, Source Serif 4, JetBrains Mono (type.SERIF,type.MONOare spreads:{ ...type.MONO, size: 16, fontWeight: 500 }). Fix the line height in px.- Fills:
k.paint(role),k.tint(role, opacity),k.gradient({ from, to, kind: 'LINEAR' | 'RADIAL', angle, fromOpacity, toOpacity })(a gradient's stops do not follow a brand kit: use it as atmosphere over a role-filled ground, never behind words alone),k.shader(presetName, params)for a live shader ground.k.color(role, alpha)gives a colour for effects. - Fills and effects: a layer's
fill/fillsandeffectstake one paint or effect, or a list of them. - Effects for a layer's
effects:texture.shadow(k, role, alpha, y, blur),texture.hardShadow(k, role, x, y),texture.halo(k, role, alpha, blur),texture.bloom(k, role, blur),texture.blackShadow(alpha, y, blur),texture.blurred(radius),texture.grain(amount)(a list).texture.glow(k, parent, { x, y, w, role, alpha })is different: it makes a layer (a soft radial light centred at x, y) and returns its id. Exact parameters are in the type declarations (@popcraft/kit).
Layers and layout
Every layer call returns its id. Name every layer a cue, count or swap must find, and every layer a person will look for.
k.flowSheet(k.sheet, gap, { top, right, bottom, left }, groundRole, justify?, align?)— the frame itself becomes a vertical stack with those margins. Start most designs here. (A stack withheight: 'FILL'inside a frame that is not a stack collapses to 1 px: use flowSheet, or give the height in px.)k.stack(parent, { direction: 'VERTICAL' | 'HORIZONTAL', gap, padding, width: n | 'FILL' | 'HUG', height: n | 'HUG' | 'FILL', align: 'MIN' | 'CENTER' | 'MAX', justify: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN', fill: role, radius, stroke: { color: role, width, dash: [on, off], align: 'INSIDE' }, clip, effects, wrap, absolute: { x, y }, name })— auto layout. Use stacks for anything with words in it, so the copy can change and the layout reflows.k.frame(parent, { x, y, width, height }, { name, fill, clip, radius, effects, stroke, opacity, visible })— a free frame out of its parent's flow, for placed art and camera worlds. Give every childabsolute: { x, y }: without it a child is laid out as if in a stack and lands somewhere unexpected. It sits on top of the flow: never use one where it would cover words.k.text(parent, words, { style, color: role, width: n | 'FILL', align: 'LEFT' | 'CENTER' | 'RIGHT', autoWidth, name, absolute })— live text.autoWidth: truehugs the words (labels, buttons);width: 'FILL'wraps in the column.k.rect(parent, w | 'FILL', h, fill, { radius, stroke, effects, opacity, absolute, name, mask }),k.ellipse(parent, size, fill, o),k.star(parent, size, fill, o).k.vector(parent, w, h, [paths], fill, { strokeWidth, stroke, absolute, name, effects })— paths are{ closed, points: [{ x, y }] }in the vector's own w × h box. Helpers:draw.box,draw.line,draw.poly,draw.circle,draw.smooth;marks.gridPaths,marks.dotPaths,marks.confettiPaths,marks.burstPath,marks.arrowPath,marks.sparkPath,marks.chart(values, w, h, max)(.line,.area),marks.barPaths. Keep a vector's box tight: a big box counts as the background of every word over it.k.photo(parent, w, h, { radius, name })— a photo slot a person drops a picture into.k.logo(parent, w, h)— the brand logo.k.picture(parent, url, w, h, { fit }),k.image(parent, url, w, h)— pictures from the app's files.k.group(parent, box, { name, absolute })andk.scene3d(id, scene)— a 3D scene (text inside it is measured flat).k.patch(id, { x, y, width, height, rotation, opacity, fills, effects, visible, padding, itemSpacing, … })— any property.k.restyle(id, styleName)— another text style.k.named(rootId, name)— the layers named so, as a list.
Components, variants and interaction
k.component('Button', { direction: 'HORIZONTAL', width: 'HUG', radius: 10, padding: { top: 14, right: 24, bottom: 14, left: 24 }, fill: 'primary', props: { Label: 'Buy' } }, id => { k.propText(id, 'Label', { style: 'Button', color: 'onPrimary', autoWidth: true }) })thenk.instance(parent, 'Button', { Label: 'Buy now' }, { name: 'Call', width }). Make a component for anything that repeats (a button, a price tag, a stamp, a row);web.buttons(k, { style })makes 'Button' and 'Button quiet'.- Props: a string is a text property;
{ number, min, max },{ color: role },{ swap: 'Component' }are inputs (k.bindPath(layer, path, prop)connects them inside the component). k.variantSet('Switch', 'State', ['Off', 'On'], boxOptions, (state, id) => { … })— one component per state;k.changeTo('Switch/Off', 'ON_CLICK', 'Switch/On', 240, 'EASE_IN_OUT')makes clicking it change state in present mode.- Live state:
const yearly = k.variable('Billing', 'yearly', 'BOOLEAN', false),k.setVariable(layer, yearly, { toggle: true })on click,k.bindVariable(layer, 'visible', yearly)to show a layer while it is true (see the pricing example). - Prototype links:
k.link(fromLayer, toFrame, 'PUSH_FROM_RIGHT')between screens made withk.addScreen(name), andk.flowStart(frame, name). 3D transitions ('CUBE_FROM_RIGHT','CAROUSEL_FROM_LEFT','FLIP_HORIZONTAL','SWING_OPEN','STACK_IN','DEPTH_PUSH'and their pairs) turn the screens through a perspective and default to 650 ms; a published site draws them on the GPU only when one is used, so keep a plain site's links flat.
Motion and music
Set the timeline first, then add moves. Times are ms on the frame's timeline.
k.timeline(k.sheet, { duration, fps: 30, audio }).- Music:
const music = beat.bed(track, beats)givesmusic.beat(n)(ms of beat n),music.durationandmusic.audio(for the timeline).beat.score(track)plays a track whole. Tracks: launch (120 BPM), tension (120, its drop at 2.0 s), trailer (100, a braam), festive (108), spooky (104), acoustic (96), calm (72), noirJazz (90), bigBand (140), chiptune (150), organ (66), psychRock (110), edm, synthwave, lofi, hiphop, house, indie. k.cues(k.sheet, [{ layer: 'Name', preset, at, params: { direction: 'up' | 'down' | 'left' | 'right' }, stagger: { delay } }]). Presets — entrances: fade-in, rise-in, drop-in, pop-in, scale-in, slide-in, wipe-in, blur-in; exits: the same with -out; emphasis: pulse, shake, bounce, wobble, flash, impact-frame; camera: push-in, pull-out, dolly, drift, zoom-through, truck, crane, pan, orbit, camera-shake, whip-pan; text: typewriter, letters-in, words-in, lines-in, letters-out, words-out, lines-out. Withstagger, every layer of that name plays in turn (use stagger on any cue whose name matches several layers). Do not wipe a layer that moves or resizes per size.k.counts(k.sheet, [{ layer, at, duration, counter: { from, to, prefix, suffix, decimals }, tick }])— a number that counts;tick: 1000steps like a clock (duration a whole number of ticks). Give the figure a fixed-width box.k.swaps(k.sheet, [{ layer, words: [...], at, hold, stagger, in, out, unit: 'word' | 'letter', rise }])— one text that says several things in turn: captions, rotating words.k.animate(id, path, [{ t, v, ease }])— keys: opacity, translateX, translateY, scaleX, scaleY, rotation, trim.end (0→1 draws a line on). The last key holds. Curves:ease.in('cubic'),ease.out('cubic'),ease.inOut('quint'),ease.back(1.7),ease.spring(170, 26),ease.linear,ease.hold(a step).k.behave(id, path, [modifier])— idle motion:{ kind: 'sine', amp, freq },{ kind: 'noise', amp, freq, seed },{ kind: 'spin', rate }, spin with{ kind: 'mod', period }(a strip that scrolls for ever),{ kind: 'loop', mode: 'cycle' }. For a loop, freq × length is a whole number. A behaviour on a property drives that property: to sway something that rests tilted, put the tilt on a parent (a stack or frame around it) and the behaviour on the child.- Helpers:
captions.captionStrip(k, parent, firstLine, { style, fill, ink, … })andcaptions.captionSwap(lines, at, until);pointer.pointer(...),pointer.clicks(...),pointer.glide(...)for a demo's cursor;motion.cutOn,motion.drawOn,motion.appear,motion.blink. - Rules: the hook within the first second, entrances on beats, the one loud moment on the music's loudest, the frame at rest is the finished picture, a loop ends where it began.
Other sizes
k.variants(k.sheet, [{ preset: 'ig-post', adjust: (kit, id) => { … } }, …])— copies of the design at other sizes, motion included. Magic resize moves and scales things;adjustthen recomposes:kit.patch(id, { padding, itemSpacing })for the frame,kit.named(id, 'Hook')[0]to find a layer in that copy,kit.restyle(…)for smaller or larger type,kit.patch(layer, { x, y, width, height, visible })to move art. Absolute art keeps its first size and paths: place it again from that size's height (see the reel's till printer).- Make each size a composition of its own: a square and a 4:5 use the full width and keep every element; a 16:9 is a new layout (two columns or a wide scene); tall cuts scale tags, buttons and stamps up with the headline.
- Safe areas: 9:16 words stay inside x 64–940, y 250–1440; 16:9 (video-1080p) words inside x 96–1824, y 54–1026.
- A layout can change direction per size:
kit.patch(id, { layoutMode: 'HORIZONTAL' })turns a column into a row (a 16:9's two columns).
Web pages
web.page(k, k.sheet, { gap, top })— the desktop page: a column on the page ground, as tall as its content.web.buttons(k, { style })— 'Button' and 'Button quiet' components (Labelprop).web.nav(k, k.sheet, { links: [...], cta, style, brandStyle })— logo tile, name, links and a button (the links fold away on the phone).- Sections are stacks under the page: hero, proof, features, the offer, questions, a closing call, a footer. The hero
shows the product working and loops in 6 s on
k.timeline(k.sheet, { duration: 6000, fps: 30 })(what comes in goes out again before the end). web.webResponsive(k, k.sheet, { stack, stackMobile, restyle, hide, top, gap, tweaks })— makes the laptop, tablet and phone copies and the page responsive:stack: rows (by layer name) that become columns at tablet and below;stackMobile: only on the phone;restyle:{ 'Display 64': { laptop, tablet, mobile } }steps type down;hide:{ mobile: ['Layer name'] };gapandtop: spacing per size;tweaks: { mobile: (kit, id) => { … } }anything else, one function per size (a function's arguments are references, so testingsize === 'mobile'inside one is always false).- On the phone
webResponsivefolds the nav's links and the layer named "Brand name" away, leaving the logo tile and the button; to keep the name, call the brand text something else. Keep body text 14 px or more on the phone, recompose the hero rather than cropping it, and keep every size under 8,192 px tall. Leave room in text boxes: a line that only just fits wraps differently in a browser.
Editing designs that already exist
Use run_script (from a terminal popcraft run <file> run_script --code @fix.js) to change many layers in one call.
docis the document as data:doc.nodes[id]hasid, type, name, parentId, childIds, x, y, width, height, visible, opacity, rotation, fills, strokes, effects, text hascharacters, fontSize, fontWeight, fontFamily, textStyleId;doc.pageId,doc.selection,doc.styles,doc.variables.- Walk it with plain JavaScript:
Object.values(doc.nodes).filter(n => n.type === 'TEXT' && n.fontSize < 16), or down from a frame throughchildIds. - Change it through tools, batched:
tools.update_nodes({ updates: [{ id, props: { fontSize: 16 } }, …] }),tools.set_text({ … }),tools.rename_nodes({ items: [{ id, name }] }),tools.replace_color({ … }),tools.set_node_style({ … }),tools.move_nodes,tools.resize_nodes,tools.apply_auto_layout. Read a tool's parameters from its description (popcraft commands <tool>in a terminal). - End with
tools.look_at({ ids: [...] })to get the pictures back in the same call, andprint/returnwhat you found. - Every edit is one undo step; if any call fails, nothing changes and the line is reported.
- For a design built from a script, change the script and rebuild (
write_designwithframe) rather than editing its layers; edit layers directly only for a small finishing touch.
Making the decisions
How the gallery's designs were decided, in order. Make every decision yourself; do not ask the user to choose.
- The job and the reader. Who sees this, where (a feed, an inbox, a store, a page), at what size, for how long, and what they should do next. That sets the formats and the one action.
- The idea. List five or six worlds for it in a short table (idea, kept or not, why). Reject clichés and anything close to a picture already in the file. Keep the real material nobody uses for this job that gives the information a home: tour dates as tape on a road case, postmarks on a letter, bars of a conductor's score; a sale as a till receipt, a trading floor, a coupon circular, a scratch card that cannot lose.
- The world, written down. The object the information lives on; every state as an action on it (stamped, slapped on, pencilled in); colour roles; a type pair with a reason; a motion character (slam, place, legato); the track; a copy voice. Facts stay plain in any voice.
- The subject. One invented product or act with a believable name on a
.exampledomain, real prices, dates with time zones, venues and capacities that fit, codes built the way real ones are; the same facts in every piece. - The composition, per size, with arithmetic. Add up the heights of the title, the list, the caption and the call to action against the room you have; if they do not fit, change the composition, never shrink the type. Decide what fills the bottom third and how the 16:9 differs.
- The motion. What arrives on which beat and why; the one loud moment on the music's loudest; how the loop closes.
- Build, review, fix, at least three rounds (the design-review procedure), naming each problem specifically.
- Report what is still not right and what you did not look at.
Worked examples
Each builds with no check failing. Copy one as a starting point and change everything about it.
Example: Sale reel cut to music
Size ig-story. Shows music and beats, cues, a counter, a word swap for captions, art in the bottom third, three other sizes recomposed.
// A 14 s sale reel on the Countdown track, cut on its beats. Idea: a till receipt that will not stop printing.
const music = beat.bed('tension', 28)
const b = music.beat
k.brand({ paper: '#F4F1EA', ink: '#141414', hot: '#E8361E', muted: '#6B6760' }, { name: 'Tillwise', site: 'tillwise.example' })
k.textStyle('Hook', { size: 132, fontWeight: 900, lineHeight: 120, letterSpacing: -4 })
k.textStyle('Price', { size: 220, fontWeight: 900, lineHeight: 236, letterSpacing: -8, fontFeatures: { tnum: 1 } })
k.textStyle('Line', { size: 34, fontWeight: 500, lineHeight: 44, fontFamily: 'JetBrains Mono' })
k.textStyle('Caption', { size: 44, fontWeight: 700, lineHeight: 52 })
k.textStyle('Hook small', { size: 96, fontWeight: 900, lineHeight: 90, letterSpacing: -3 })
k.textStyle('Hook square', { size: 72, fontWeight: 900, lineHeight: 70, letterSpacing: -2 })
k.textStyle('Price small', { size: 120, fontWeight: 900, lineHeight: 128, letterSpacing: -5, fontFeatures: { tnum: 1 } })
const sheet = k.sheet
// The frame itself lays out its content: a vertical stack with margins clear of the apps' buttons (the safe area).
k.flowSheet(sheet, 36, { top: 270, right: 150, bottom: 500, left: 80 }, 'paper')
k.timeline(sheet, { duration: music.duration, fps: 30, audio: music.audio })
const col = sheet
k.text(col, 'This receipt won\u2019t stop printing.', { style: 'Hook', color: 'ink', width: 'FILL', name: 'Hook' })
const slip = k.stack(col, { gap: 18, padding: 48, width: 'FILL', fill: 'paper', radius: 6, stroke: { color: 'ink', width: 3, dash: [10, 8] }, name: 'Receipt' })
for (const [item, was] of [['Counter plan', '$228.00'], ['Shop plan', '$588.00'], ['Chain plan', '$1,428.00']]) {
const row = k.stack(slip, { direction: 'HORIZONTAL', justify: 'SPACE_BETWEEN', width: 'FILL', name: 'Item' })
k.text(row, item, { style: 'Line', color: 'ink', autoWidth: true })
k.text(row, was, { style: 'Line', color: 'muted', autoWidth: true, name: 'Was' })
}
k.text(slip, '$235', { style: 'Price', color: 'hot', width: 'FILL', name: 'Total' })
k.text(col, 'Cyber Monday: 60% off.', { style: 'Caption', color: 'ink', width: 'FILL', name: 'Caption' })
// The till the receipt prints from fills the foot of the frame (no words below the apps' safe line, so it is all drawing).
k.rect(sheet, W - 160, 460, 'ink', { radius: 48, name: 'Printer', absolute: { x: 80, y: H - 360 } })
k.rect(sheet, W - 300, 18, 'muted', { radius: 9, name: 'Paper slot', absolute: { x: 150, y: H - 300 } })
k.rect(sheet, W - 360, 120, 'paper', { name: 'Paper tongue', stroke: { color: 'ink', width: 3, dash: [10, 8] }, absolute: { x: 180, y: H - 420 } })
k.ellipse(sheet, 36, 'hot', { name: 'Power light', absolute: { x: W - 190, y: H - 220 } })
k.cues(sheet, [
{ layer: 'Printer', preset: 'rise-in', at: 0 },
{ layer: 'Paper tongue', preset: 'slide-in', at: b(1), params: { direction: 'down' } },
{ layer: 'Hook', preset: 'words-in', at: 0 },
{ layer: 'Receipt', preset: 'wipe-in', at: b(2), params: { direction: 'down' } },
{ layer: 'Item', preset: 'rise-in', at: b(3), stagger: { delay: b(1) } },
{ layer: 'Total', preset: 'pop-in', at: b(8) },
{ layer: 'Total', preset: 'pulse', at: b(20) },
])
k.counts(sheet, [{ layer: 'Total', at: b(8), duration: b(4), counter: { from: 588, to: 235, prefix: '$' } }])
k.swaps(sheet, [{ layer: 'Caption', words: ['Cyber Monday: 60% off.', 'Every plan reprices.', 'Ends at midnight.'], at: b(10), hold: b(4), stagger: 30, in: 300, out: 240, unit: 'word', rise: 24 }])
// Each size is recomposed, not just resized: tighter margins and smaller display type where the frame is shorter.
const recompose = (top, bottom, hook, price, gap, h) => (kit, id) => {
kit.patch(id, { padding: { top, right: 72, bottom, left: 72 }, itemSpacing: gap })
// The till keeps to the foot of each size: placed from that size's height, not stretched with the frame.
kit.patch(kit.named(id, 'Printer')[0], { y: h - 210 })
kit.patch(kit.named(id, 'Paper slot')[0], { y: h - 150 })
kit.patch(kit.named(id, 'Paper tongue')[0], { y: h - 250, height: 100 })
kit.patch(kit.named(id, 'Power light')[0], { y: h - 90 })
kit.patch(kit.named(id, 'Receipt')[0], { padding: { top: 28, right: 32, bottom: 28, left: 32 }, itemSpacing: 8 })
kit.restyle(kit.named(id, 'Hook')[0], hook)
kit.restyle(kit.named(id, 'Total')[0], price)
}
k.variants(sheet, [{ preset: 'ig-post', adjust: recompose(56, 230, 'Hook square', 'Price small', 20, 1080) }, { preset: 'ig-portrait', adjust: recompose(96, 260, 'Hook small', 'Price', 36, 1350) }])Example: Responsive landing page
Size web-desktop. Shows web.page, web.nav, web.buttons, sections, an animated hero on a 6 s timeline, web.webResponsive with stack, restyle, gap and per-size tweaks.
// A responsive landing page: desktop master, laptop, tablet and phone, with an animated hero that loops in 6 s.
k.brand({ paper: '#F7F5F0', text: '#17181C', onPaper: '#4D5059', primary: '#1F6F5C', onPrimary: '#FFFFFF', card: '#FFFFFF', onCard: '#17181C', cardMuted: '#5A5E68', line: '#E2DFD8' }, { name: 'Tidewell', site: 'tidewell.example' })
const t = (name, size, weight, lh, more = {}) => k.textStyle(name, { size, fontWeight: weight, lineHeight: lh, ...more })
t('Nav 16', 16, 500, 24); t('Brand 20', 20, 800, 28); t('Button 16', 16, 600, 24)
t('Display 64', 64, 800, 70, { letterSpacing: -2 }); t('Display 44', 44, 800, 50, { letterSpacing: -1.2 }); t('Display 34', 34, 800, 40, { letterSpacing: -0.8 })
t('Lead 20', 20, 400, 32); t('Lead 17', 17, 400, 27); t('Section 32', 32, 800, 40, { letterSpacing: -0.6 }); t('Section 26', 26, 800, 32)
t('Card 20', 20, 700, 28); t('Body 16', 16, 400, 25); t('Figure 44', 44, 800, 48, { fontFeatures: { tnum: 1 } }); t('Small 14', 14, 400, 21)
web.buttons(k, { style: 'Button 16' })
const sheet = k.sheet
web.page(k, sheet, { gap: 72, top: 32 })
web.nav(k, sheet, { links: ['Product', 'Pricing', 'Customers', 'Sign in'], cta: 'Start free', style: 'Nav 16', brandStyle: 'Brand 20' })
// Hero: words on the left, the product working on the right.
const hero = k.stack(sheet, { direction: 'HORIZONTAL', gap: 56, align: 'CENTER', name: 'Hero' })
const copy = k.stack(hero, { gap: 22, name: 'Hero copy' })
k.text(copy, 'Your tide tables, in the inbox by six.', { style: 'Display 64', color: 'text', width: 'FILL', name: 'Headline' })
k.text(copy, 'Tidewell sends every harbour master the day’s tides, swell and berths before the first boat moves. 214 harbours use it every morning.', { style: 'Lead 20', color: 'onPaper', width: 'FILL', name: 'Lead' })
const ctas = k.stack(copy, { direction: 'HORIZONTAL', gap: 14, width: 'HUG', name: 'Calls' })
k.instance(ctas, 'Button', { Label: 'Start free' }, { name: 'Primary call' })
k.instance(ctas, 'Button quiet', { Label: 'See a sample' }, { name: 'Quiet call' })
const panel = k.stack(hero, { width: 520, gap: 14, fill: 'card', radius: 18, padding: 28, stroke: { color: 'line', width: 1 }, name: 'Product' })
k.text(panel, 'TODAY · PORTHLEVEN', { style: 'Small 14', color: 'cardMuted', name: 'Panel kicker' })
for (const [time, what] of [['05:42', 'High water · 4.8 m'], ['11:58', 'Low water · 0.9 m'], ['18:07', 'High water · 4.6 m']]) {
const row = k.stack(panel, { direction: 'HORIZONTAL', justify: 'SPACE_BETWEEN', width: 'FILL', padding: { top: 10, right: 0, bottom: 10, left: 0 }, name: 'Tide row' })
k.text(row, time, { style: 'Card 20', color: 'onCard', autoWidth: true, name: 'Tide time' })
k.text(row, what, { style: 'Body 16', color: 'cardMuted', autoWidth: true, name: 'Tide what' })
}
k.vector(panel, 464, 90, [{ closed: false, points: [{ x: 0, y: 70 }, { x: 80, y: 20 }, { x: 170, y: 72 }, { x: 260, y: 22 }, { x: 350, y: 70 }, { x: 464, y: 30 }] }], 'primary', { strokeWidth: 4, name: 'Tide line' })
k.timeline(sheet, { duration: 6000, fps: 30 })
// A hero loops: what comes in goes out again before 6 s, so the first frame and the last are the same picture.
k.cues(sheet, [
{ layer: 'Tide row', preset: 'rise-in', at: 300, stagger: { delay: 160 } },
{ layer: 'Tide row', preset: 'fade-out', at: 5300, stagger: { delay: 0 } },
])
k.animate(k.named(sheet, 'Tide line')[0], 'trim.end', [{ t: 600, v: 0, ease: ease.linear }, { t: 2400, v: 1, ease: ease.hold }, { t: 5200, v: 1, ease: ease.inOut('cubic') }, { t: 5900, v: 0 }])
// Proof, features, the offer, questions and a closing call.
const proof = k.stack(sheet, { direction: 'HORIZONTAL', gap: 24, name: 'Proof' })
for (const [figure, label] of [['214', 'harbours every morning'], ['6:00', 'tables in the inbox'], ['0', 'missed tides since 2025']]) {
const c = k.stack(proof, { gap: 6, fill: 'card', radius: 14, padding: 24, name: 'Proof card' })
k.text(c, figure, { style: 'Figure 44', color: 'onCard', name: 'Proof figure' })
k.text(c, label, { style: 'Body 16', color: 'cardMuted', name: 'Proof label' })
}
const feats = k.stack(sheet, { gap: 24, name: 'Features' })
k.text(feats, 'Built for the quay, not the office', { style: 'Section 32', color: 'text', name: 'Section title' })
const grid = k.stack(feats, { direction: 'HORIZONTAL', gap: 24, name: 'Feature row' })
for (const [title, body] of [['Every berth', 'Who is in, who is due, and the draught each one needs at low water.'], ['Swell and wind', 'The morning forecast for your harbour mouth, not the county.'], ['Printed or phone', 'A one-page PDF for the board, and the same on every phone.']]) {
const c = k.stack(grid, { gap: 10, fill: 'card', radius: 14, padding: 24, name: 'Feature' })
k.text(c, title, { style: 'Card 20', color: 'onCard', name: 'Feature title' })
k.text(c, body, { style: 'Body 16', color: 'cardMuted', name: 'Feature body' })
}
const close = k.stack(sheet, { gap: 18, fill: 'primary', radius: 20, padding: 48, name: 'Close' })
k.text(close, 'Free for your first harbour.', { style: 'Section 32', color: 'onPrimary', name: 'Close title' })
k.text(close, 'Set up in ten minutes. No card.', { style: 'Body 16', color: 'onPrimary', name: 'Close line' })
k.text(sheet, '© 2026 Tidewell · tidewell.example · Tide data from the national survey; check it against your charts.', { style: 'Small 14', color: 'onPaper', width: 'FILL', name: 'Footer' })
// The other sizes: rows become columns, type steps down, the nav folds on the phone.
web.webResponsive(k, sheet, {
stack: ['Hero', 'Feature row'],
stackMobile: ['Proof', 'Calls'],
restyle: { 'Display 64': { laptop: 'Display 44', tablet: 'Display 44', mobile: 'Display 34' }, 'Lead 20': { mobile: 'Lead 17' }, 'Section 32': { mobile: 'Section 26' } },
gap: { tablet: 56, mobile: 40 },
// One function per size: a script's function is recorded before the kit runs, so it cannot test which size it is in.
// On the phone the panel is narrower than the 464 px tide line: the line is scaled to fit it, not cut off.
tweaks: {
tablet: (kit, id) => kit.patch(kit.named(id, 'Product')[0], { layoutSizingHorizontal: 'FILL' }),
mobile: (kit, id) => {
kit.patch(kit.named(id, 'Product')[0], { layoutSizingHorizontal: 'FILL' })
kit.patch(kit.named(id, 'Tide line')[0], { scaleX: 0.58, scaleY: 0.58, anchorX: 0, anchorY: 0 })
},
},
})Example: Pricing page with a working switch
Size web-desktop. Shows variables, a variant set with changeTo, setVariable on click, bindVariable on every price block.
// A pricing page whose monthly/yearly switch really works: two variables, a variant set, and every price block bound.
k.brand({ paper: '#FAFAF7', text: '#1A1D24', onPaper: '#555C69', primary: '#0E7C66', onPrimary: '#FFFFFF', card: '#FFFFFF', onCard: '#1A1D24', cardMuted: '#5C6370', line: '#E3E5E8' }, { name: 'Ledgerly' })
const monthly = k.variable('Billing', 'monthly', 'BOOLEAN', true)
const yearly = k.variable('Billing', 'yearly', 'BOOLEAN', false)
const t = (name, size, weight, lh, more = {}) => k.textStyle(name, { size, fontWeight: weight, lineHeight: lh, ...more })
t('Nav 16', 16, 500, 24); t('Brand 20', 20, 800, 28); t('Button 16', 16, 600, 24); t('Display 52', 52, 800, 60, { letterSpacing: -1.5 }); t('Display 36', 36, 800, 44)
t('Switch 16', 16, 500, 24); t('Switch on 16', 16, 700, 24); t('Plan 22', 22, 800, 28); t('Price 48', 48, 800, 52, { letterSpacing: -1.5 }); t('Body 15', 15, 400, 22); t('Small 14', 14, 400, 20)
web.buttons(k, { style: 'Button 16' })
k.variantSet('Billing switch', 'Billing', ['Monthly', 'Yearly'], { direction: 'HORIZONTAL', width: 'HUG', gap: 14, align: 'CENTER', padding: 4, props: { Left: 'Monthly', Right: 'Yearly' } }, (state, id) => {
k.propText(id, 'Left', { style: state === 'Monthly' ? 'Switch on 16' : 'Switch 16', color: state === 'Monthly' ? 'text' : 'onPaper', autoWidth: true })
const track = k.stack(id, { direction: 'HORIZONTAL', width: 56, height: 30, radius: 15, padding: 3, align: 'CENTER', justify: state === 'Yearly' ? 'MAX' : 'MIN', fill: 'primary', name: 'Track' })
k.ellipse(track, 24, 'card', { name: 'Knob' })
k.propText(id, 'Right', { style: state === 'Yearly' ? 'Switch on 16' : 'Switch 16', color: state === 'Yearly' ? 'text' : 'onPaper', autoWidth: true })
})
k.changeTo('Billing switch/Monthly', 'ON_CLICK', 'Billing switch/Yearly', 240, 'EASE_IN_OUT')
k.changeTo('Billing switch/Yearly', 'ON_CLICK', 'Billing switch/Monthly', 240, 'EASE_IN_OUT')
const sheet = k.sheet
web.page(k, sheet, { gap: 56, top: 32 })
web.nav(k, sheet, { links: ['Features', 'Pricing', 'Sign in'], cta: 'Start free trial', style: 'Nav 16', brandStyle: 'Brand 20' })
const head = k.stack(sheet, { gap: 18, name: 'Header' })
k.text(head, 'Simple pricing for independent work', { style: 'Display 52', color: 'text', width: 'FILL', name: 'Headline' })
const toggle = k.instance(head, 'Billing switch', { Left: 'Monthly', Right: 'Yearly' }, { name: 'Billing toggle' })
k.setVariable(toggle, monthly, { toggle: true })
k.setVariable(toggle, yearly, { toggle: true })
const plans = k.stack(sheet, { direction: 'HORIZONTAL', gap: 24, name: 'Plans' })
for (const { name, month } of [{ name: 'Solo', month: 12 }, { name: 'Studio', month: 29 }, { name: 'Agency', month: 59 }]) {
const card = k.stack(plans, { gap: 16, fill: 'card', radius: 18, padding: 32, stroke: { color: 'line', width: 1 }, name: 'Plan' })
k.text(card, name, { style: 'Plan 22', color: 'onCard', name: 'Plan name' })
const price = k.stack(card, { name: 'Price' })
const block = (which, amount, note) => {
const b = k.stack(price, { gap: 4, fill: 'card', name: which + ' price' })
k.text(b, amount, { style: 'Price 48', color: 'onCard', name: 'Amount' })
k.text(b, note, { style: 'Small 14', color: 'cardMuted', name: 'Billed' })
return b
}
// Each price block shows while its variable is true: the switch flips both variables, so every price changes at once.
k.bindVariable(block('Monthly', '$' + month, 'per month, billed monthly'), 'visible', monthly)
k.bindVariable(block('Yearly', '$' + month * 10, 'per year: two months free'), 'visible', yearly)
k.instance(card, 'Button', { Label: 'Choose ' + name }, { name: 'Plan call' })
}
const faq = k.stack(sheet, { gap: 18, name: 'Questions' })
k.text(faq, 'Questions', { style: 'Display 36', color: 'text', name: 'Questions title' })
for (const [q, a] of [['Can I switch plans later?', 'Yes, whenever you like; the difference is prorated to the day.'], ['Is there a trial?', 'Thirty days on any plan, and we never ask for a card up front.'], ['Do charities get a discount?', 'Registered charities get 40% off any plan.']]) {
// A rule between rows is its own 1 px layer: a stroke on a stack draws a whole box.
k.patch(k.rect(faq, 10, 1, 'line', { name: 'Rule' }), { layoutSizingHorizontal: 'FILL' })
const row = k.stack(faq, { gap: 6, padding: { top: 4, right: 0, bottom: 4, left: 0 }, name: 'Question' })
k.text(row, q, { style: 'Plan 22', color: 'text', name: 'Question text' })
k.text(row, a, { style: 'Body 15', color: 'onPaper', name: 'Answer' })
}
k.text(sheet, 'Prices in US dollars before tax. Cancel any time.', { style: 'Small 14', color: 'onPaper', width: 'FILL', name: 'Footer' })
web.webResponsive(k, sheet, { stack: ['Plans'], restyle: { 'Display 52': { tablet: 'Display 36', mobile: 'Display 36' } }, gap: { mobile: 40 } })Example: Email header GIF
Size email-header. Shows the whole message on frame one, idle behaviours that close the loop.
// An email header GIF, 600×300: the whole message reads on the first frame (many mail apps show only that), and the
// loop returns exactly to where it began.
k.brand({ paper: '#101820', text: '#F4F1EA', onPaper: '#C9C4B8', primary: '#F2B33D', onPrimary: '#101820' }, { name: 'Harbourline' })
k.textStyle('Head 40', { size: 40, fontWeight: 800, lineHeight: 44, letterSpacing: -1 })
k.textStyle('Sub 18', { size: 18, fontWeight: 500, lineHeight: 24 })
k.textStyle('Code 16', { size: 16, fontWeight: 700, lineHeight: 20, fontFamily: 'JetBrains Mono' })
const sheet = k.sheet
k.flowSheet(sheet, 14, { top: 44, right: 230, bottom: 40, left: 40 }, 'paper')
k.timeline(sheet, { duration: 6000, fps: 30 })
k.text(sheet, 'Spring sale: 40% off a year.', { style: 'Head 40', color: 'text', width: 'FILL', name: 'Headline' })
k.text(sheet, 'Every plan, until Sunday 23:59 BST.', { style: 'Sub 18', color: 'onPaper', width: 'FILL', name: 'Line' })
const chip = k.stack(sheet, { direction: 'HORIZONTAL', width: 'HUG', fill: 'primary', radius: 8, padding: { top: 8, right: 14, bottom: 8, left: 14 }, name: 'Code chip' })
k.text(chip, 'CODE SPRING40', { style: 'Code 16', color: 'onPrimary', autoWidth: true, name: 'Code' })
// A lighthouse at the right: its lamp pulses and its beam sweeps, both a whole number of cycles in 6 s, so the loop closes.
k.rect(sheet, 46, 170, 'text', { radius: 6, name: 'Tower', absolute: { x: 470, y: 120 } })
k.rect(sheet, 70, 26, 'onPaper', { radius: 4, name: 'Gallery', absolute: { x: 458, y: 108 } })
const lamp = k.ellipse(sheet, 34, 'primary', { name: 'Lamp', absolute: { x: 476, y: 70 } })
const beam = k.vector(sheet, 160, 40, [{ closed: true, points: [{ x: 0, y: 20 }, { x: 160, y: 0 }, { x: 160, y: 40 }] }], 'primary', { name: 'Beam', opacity: 0.35, absolute: { x: 330, y: 67 } })
k.behave(lamp, 'opacity', [{ kind: 'sine', amp: 0.25, freq: 1 }])
k.behave(beam, 'opacity', [{ kind: 'sine', amp: 0.2, freq: 0.5 }])Example: Display ad set
Size ad-landscape. Shows a static design with drawn art, and tall cuts recomposed in adjust (art on top, type and button scaled up).
// A static display ad set: 1200×628 master with square, portrait and LinkedIn cuts, each recomposed for its shape.
k.brand({ paper: '#FFF4E8', text: '#2A1A12', primary: '#D9481F', onPrimary: '#FFFFFF', card: '#FFFFFF', onCard: '#2A1A12' }, { name: 'Crumb' })
k.textStyle('Head 64', { size: 64, fontWeight: 900, lineHeight: 64, letterSpacing: -2 })
k.textStyle('Head 88', { size: 88, fontWeight: 900, lineHeight: 86, letterSpacing: -3 })
k.textStyle('Sub 22', { size: 22, fontWeight: 500, lineHeight: 30 })
k.textStyle('Sub 28', { size: 28, fontWeight: 500, lineHeight: 36 })
k.textStyle('Button 22', { size: 22, fontWeight: 700, lineHeight: 28 })
k.textStyle('Button 30', { size: 30, fontWeight: 700, lineHeight: 36 })
k.textStyle('Price 40', { size: 40, fontWeight: 900, lineHeight: 44 })
const sheet = k.sheet
k.flowSheet(sheet, 20, { top: 64, right: 560, bottom: 64, left: 64 }, 'paper', 'CENTER')
k.text(sheet, 'Bread that keeps its books.', { style: 'Head 64', color: 'text', width: 'FILL', name: 'Headline' })
k.text(sheet, 'Orders, flour and wages for small bakeries, in one ledger.', { style: 'Sub 22', color: 'text', width: 'FILL', name: 'Sub' })
const btn = k.stack(sheet, { direction: 'HORIZONTAL', width: 'HUG', fill: 'primary', radius: 12, padding: { top: 14, right: 26, bottom: 14, left: 26 }, name: 'Button' })
k.text(btn, 'Try Crumb free', { style: 'Button 22', color: 'onPrimary', autoWidth: true, name: 'Button label' })
// The art: a loaf on a board with a price tag, drawn on the right.
k.ellipse(sheet, 520, 'card', { name: 'Plate', absolute: { x: 650, y: 54 } })
// The loaf is a stack so its score marks stay centred on it when the taller cuts resize it.
const loaf = k.stack(sheet, { direction: 'HORIZONTAL', align: 'CENTER', justify: 'CENTER', gap: 44, width: 380, height: 190, fill: 'primary', radius: 95, name: 'Loaf', absolute: { x: 720, y: 220 } })
for (let i = 0; i < 3; i++) k.patch(k.rect(loaf, 20, 110, 'card', { radius: 10, name: 'Score' }), { rotation: -24 })
const tag = k.stack(sheet, { direction: 'HORIZONTAL', width: 'HUG', fill: 'text', radius: 10, padding: { top: 10, right: 16, bottom: 10, left: 16 }, name: 'Tag', absolute: { x: 940, y: 120 } })
k.text(tag, '£9 a month', { style: 'Price 40', color: 'paper', autoWidth: true, name: 'Tag price' })
// Each tall cut puts the art on top and the words under it, with type and the button scaled up for the bigger frame.
const tall = (h) => (kit, id) => {
kit.patch(id, { padding: { top: h - 470, right: 72, bottom: 72, left: 72 }, primaryAxisAlignItems: 'MIN' })
kit.restyle(kit.named(id, 'Headline')[0], 'Head 88')
kit.restyle(kit.named(id, 'Sub')[0], 'Sub 28')
kit.restyle(kit.named(id, 'Button label')[0], 'Button 30')
kit.patch(kit.named(id, 'Plate')[0], { x: 140, y: 60, width: h - 560, height: h - 560 })
kit.patch(kit.named(id, 'Loaf')[0], { x: 210, y: (h - 560) / 2 - 20, width: h - 700, height: (h - 700) / 2 })
kit.patch(kit.named(id, 'Tag')[0], { x: 640, y: 90 })
}
k.variants(sheet, [
{ preset: 'ad-square', adjust: tall(1200) },
{ preset: 'ig-portrait', adjust: tall(1350) },
{ preset: 'linkedin-post', adjust: (kit, id) => kit.patch(id, { padding: { top: 64, right: 560, bottom: 64, left: 64 } }) },
])Example: Mobile wallet ticket
Size iphone-17. Shows a phone screen laid out at phone size, a clipped card, a drawn QR block, readable type.
// A mobile app screen (a wallet ticket): the real layout of that screen at phone size, every word 13 px or more.
k.brand({ paper: '#F3EFE6', text: '#1D1B18', onPaper: '#5B564D', card: '#FFFDF8', onCard: '#1D1B18', cardMuted: '#6A645A', primary: '#B6461F', onPrimary: '#FFFFFF' }, { name: 'Barley Moon' })
const t = (name, size, weight, lh, more = {}) => k.textStyle(name, { size, fontWeight: weight, lineHeight: lh, ...more })
t('Status 15', 15, 600, 20); t('Title 30', 30, 800, 36, { letterSpacing: -0.6 }); t('Label 13', 13, 700, 18, { letterSpacing: 1.4 }); t('Value 18', 18, 500, 24); t('Small 14', 14, 400, 20)
const sheet = k.sheet
k.flowSheet(sheet, 18, { top: 20, right: 20, bottom: 28, left: 20 }, 'paper')
const bar = k.stack(sheet, { direction: 'HORIZONTAL', justify: 'SPACE_BETWEEN', name: 'Status bar' })
k.text(bar, '9:41', { style: 'Status 15', color: 'text', autoWidth: true, name: 'Clock' })
k.text(sheet, 'Wallet', { style: 'Title 30', color: 'text', name: 'Title' })
const pass = k.stack(sheet, { gap: 0, fill: 'card', radius: 22, clip: true, stroke: { color: 'text', width: 1.5 }, name: 'Pass' })
// The art strip is part of the card's flow (a free frame here would sit behind the words below it).
const art = k.stack(pass, { height: 150, fill: 'primary', clip: true, name: 'Pass art' })
k.ellipse(art, 120, 'card', { name: 'Sun', absolute: { x: 220, y: -30 }, opacity: 0.9 })
const body = k.stack(pass, { gap: 14, padding: 22, name: 'Pass body' })
for (const [label, value] of [['HOLDER', 'Robin Achterberg'], ['PASS', 'Weekend, with camping'], ['ENTRY', '22–25 July 2027'], ['GATE', 'Orchard Gate']]) {
const f = k.stack(body, { gap: 2, name: 'Field' })
k.text(f, label, { style: 'Label 13', color: 'cardMuted', name: 'Field label' })
k.text(f, value, { style: 'Value 18', color: 'onCard', name: 'Field value' })
}
const qr = k.stack(body, { direction: 'HORIZONTAL', width: 'FILL', justify: 'CENTER', padding: 18, fill: 'paper', radius: 14, name: 'QR block' })
const cells = []
for (let y = 0; y < 21; y++) for (let x = 0; x < 21; x++) if (((x * 7 + y * 13) % 5 < 2) || ((x < 7 || x > 13) && (y < 7 || y > 13) && (x % 6 === 0 || y % 6 === 0))) cells.push({ closed: true, points: [{ x: x * 8, y: y * 8 }, { x: x * 8 + 8, y: y * 8 }, { x: x * 8 + 8, y: y * 8 + 8 }, { x: x * 8, y: y * 8 + 8 }] })
k.vector(qr, 168, 168, cells, 'text', { name: 'QR' })
k.text(body, 'Show at the gate. The code refreshes every 30 seconds.', { style: 'Small 14', color: 'cardMuted', width: 'FILL', name: 'QR note' })
// The foot of the screen does something useful: the next pass waiting under this one, and the action.
const next = k.stack(sheet, { direction: 'HORIZONTAL', justify: 'SPACE_BETWEEN', align: 'CENTER', fill: 'card', radius: 16, padding: 18, name: 'Next pass' })
k.text(next, 'Car park · Field B', { style: 'Value 18', color: 'onCard', autoWidth: true, name: 'Next pass name' })
k.text(next, '1 of 2', { style: 'Small 14', color: 'cardMuted', autoWidth: true, name: 'Next pass count' })
const add = k.stack(sheet, { direction: 'HORIZONTAL', justify: 'CENTER', fill: 'primary', radius: 14, padding: 16, name: 'Add button' })
k.text(add, 'Add to calendar', { style: 'Value 18', color: 'onPrimary', autoWidth: true, name: 'Add label' })